Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ The default listener binds to `127.0.0.1` on an available port from 7981–7990.
`--port 0` chooses an OS-assigned port. A non-loopback listener exposes local
reviews to reachable clients. The browser opens a loopback URL for wildcard binds.

Legacy `review`, `serve`, and `service` commands remain compatibility
paths during migration. Use `servediff --help` for usage.
Use `servediff --help` for usage. Legacy `review`, `serve`, `service` and
`capture` commands are removed. Development fixtures use `servediff dev --fixture`.

## Agent hooks and remote ingestion

Expand All @@ -107,7 +107,7 @@ Configure a remote server and token first. Plugins are remote-only.
The plugin returns after scheduling; a detached Go worker collects the checkout
and all registered worktrees using the same branch comparison as manual review.
Non-Git directories are ignored. Child repositories and branches without live
checkouts are excluded; use `review --branch` for explicit object-only recovery.
checkouts are excluded; use `sync --branch` for explicit object-only recovery.
Persistent Git identities survive checkout moves and branch renames.
Initially clean automatic captures are skipped. Dirty-to-clean transitions and
new session associations are submitted. Unchanged uploads are skipped, overlapping requests
Expand All @@ -123,7 +123,7 @@ Inspect collection/delivery activity and retry waiting payloads:
servediff collector status
servediff collector retry
tail -n 50 ~/.local/state/servediff/hooks/hooks.log
servediff review --branch feature --base main --no-browser
servediff sync --branch feature --base main --no-browser
```

Manually schedule the same detached collection used by harness plugins:
Expand Down Expand Up @@ -238,9 +238,8 @@ triggers or `servediff collector retry`. Their pending-data retention remains
seven days independently of server retention. Manual commands always submit and
report failures directly.

Collectors advertise ingestion protocol 2 for comparison and session metadata;
upgrade collector and server together. Servers still accept protocol 1 payloads
and retries from older collectors.
Collectors and servers require ingestion protocol 3; upgrade them together.
Public v1 routes and synchronous ingestion are removed.

`/api/v2/contexts` lists stored observations with `q`, `repository`, `branch`,
`worktree`, `hostname`, `sourceId`, `runId`, `harness`, `sessionId` and `sessionName`
Expand All @@ -257,9 +256,9 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md).

Local data lives in `$XDG_STATE_HOME/servediff/state.db`, or
`~/.local/state/servediff/state.db`. `--state memory` disables persistence.
Migrations preserve historical reviews and retained captures. Legacy worktree
contexts cannot load Git through the server; submit a new observation with
`servediff review`. Unsupported databases are rejected without deleting data.
Schema 9 intentionally resets older versioned databases on first open. Current
state persists across restarts; newer or unrecognized databases are refused.
Legacy commands and pending producer payloads are retired.
Stop older processes before upgrading. Local service lifecycle uses a private
authenticated loopback control listener; its token does not authenticate the
local public API.
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/DeleteContextButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export function DeleteContextButton() {
const [error, setError] = useState("");
const pending = useRef(false);
const cancelRef = useRef<HTMLButtonElement>(null);
const snapshot = session.kind !== "worktree";
const label = snapshot ? "Delete snapshot" : "Delete context";
const label = "Delete snapshot";

async function confirm() {
if (pending.current) return;
Expand Down Expand Up @@ -69,10 +68,9 @@ export function DeleteContextButton() {
>
<DialogTitle>{label}?</DialogTitle>
<DialogDescription>
This permanently deletes{" "}
{snapshot ? "this snapshot" : "this context and all its stored diffs"}
, including comments and reviewed-file marks. This cannot be undone.
Your Git files stay unchanged. New collections can appear again.
This permanently deletes this snapshot, including comments and
reviewed-file marks. This cannot be undone. Your Git files stay
unchanged. New collections can appear again.
</DialogDescription>
<p
className="delete-context-details"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/DiffWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ export function DiffWorkspace() {
? "Run a command that emits a Git patch, then pipe it into servediff."
: mode === "staged"
? "No staged changes were collected in this snapshot."
: "No changes in this snapshot. Run servediff review to submit another.";
: "No changes in this snapshot. Collect another snapshot with servediff or servediff sync.";
return (
<main>
<DiffToolbar />
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/ProjectPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export function ProjectPicker({
aria-selected={entry.id === active?.id}
aria-disabled={!available}
disabled={!available}
title={`${contextDiagnostics(context)}${available ? "" : `\n${contextUnavailableReason(context)}`}`}
title={`${contextDiagnostics(context)}${available ? "" : `\n${contextUnavailableReason()}`}`}
onClick={() => choose(entry.id)}
onMouseMove={() => {
if (available) setHighlightedId(entry.id);
Expand Down Expand Up @@ -421,7 +421,7 @@ export function ProjectPicker({
</span>
{!available && (
<span className="project-picker-unavailable-reason">
{contextUnavailableReason(context)}
{contextUnavailableReason()}
</span>
)}
{(!pipedContext || grouped) && (
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/project-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const defaultPickerFilters: PickerFilters = {
export const pipedGroupId = "piped";

export function contextIsPiped(context: ApiContext) {
return context.source === "stdin" || context.kind === "capture";
return context.source === "stdin";
}

export function contextTimestamp(context: ApiContext) {
Expand Down Expand Up @@ -134,9 +134,7 @@ function matchScore(value: string, word: string): number {

function pickerRecency(context: ApiContext) {
if (contextIsPiped(context)) return contextTimestamp(context);
return context.kind === "observation"
? context.lastSubmittedAt
: context.lastChangedAt;
return context.lastSubmittedAt;
}

export function pickerResults(
Expand Down Expand Up @@ -255,7 +253,7 @@ export function contextDiagnostics(context: ApiContext) {
const observation = context.observation;
return [
`${context.name} · ${contextDetail(context)}`,
context.kind !== "capture" ? contextCheckoutLabel(context) : "",
contextCheckoutLabel(context),
context.root ?? context.submittedFrom ?? "Piped",
observationSummary(context),
observation ? `Source: ${observation.sourceId}` : "",
Expand Down Expand Up @@ -286,10 +284,8 @@ export function pickerEntryAvailable(entry: PickerEntry) {
: entry.context.availability === "available";
}

export function contextUnavailableReason(context: ApiContext) {
return context.kind === "worktree"
? "No collected snapshot for this checkout"
: "Snapshot unavailable";
export function contextUnavailableReason() {
return "Snapshot unavailable";
}

export function pickerEntries(
Expand All @@ -316,7 +312,7 @@ export function pickerEntries(
const piped = results.filter(contextIsPiped);
for (const context of results) {
if (contextIsPiped(context)) continue;
if (context.kind !== "observation" || !context.repositoryId) {
if (!context.repositoryId) {
entries.push({ id: context.id, kind: "context", context });
continue;
}
Expand Down
46 changes: 41 additions & 5 deletions apps/web/test/browser/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test("disabled comments issue no requests and expose no comment UI", async ({
contentType: "application/json",
body: JSON.stringify({
id: new URL(route.request().url()).pathname.split("/").at(-1),
kind: "capture",
kind: "observation",
source: "stdin",
name: "Piped",
root: "fixture",
Expand Down Expand Up @@ -616,7 +616,25 @@ test("inline comments use a distinct structured surface while sidebar comments r
await page.setViewportSize({ width: 1280, height: 4_000 });
await page.goto("/");
await page.evaluate(async () => {
const response = await fetch("/api/v1/diffs/current?scope=all");
const catalog: unknown = await (await fetch("/api/v2/contexts")).json();
if (
typeof catalog !== "object" ||
catalog === null ||
!("contexts" in catalog) ||
!Array.isArray(catalog.contexts)
)
throw new Error("Missing catalog");
const context: unknown = catalog.contexts[0];
if (
typeof context !== "object" ||
context === null ||
!("id" in context) ||
typeof context.id !== "string"
)
throw new Error("Missing context");
const contextId = context.id;
const base = "/api/v2/contexts/" + encodeURIComponent(contextId);
const response = await fetch(base + "/diffs/current?scope=all");
const data: unknown = await response.json();
if (typeof data !== "object" || data === null)
throw new Error("Bad fixture");
Expand Down Expand Up @@ -644,7 +662,7 @@ test("inline comments use a distinct structured surface while sidebar comments r
throw new Error(`Missing fixture fingerprint for ${path}`);
return fingerprint;
};
const imported = await fetch("/api/v1/comments/import", {
const imported = await fetch(base + "/comments/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down Expand Up @@ -1262,7 +1280,25 @@ test("copy dialog reports clipboard status and restores focus", async ({
await page.goto("/");
await expect(page.locator("#connection")).toHaveText("Fixed snapshot");
await page.evaluate(async () => {
const response = await fetch("/api/v1/diffs/current?scope=all");
const catalog: unknown = await (await fetch("/api/v2/contexts")).json();
if (
typeof catalog !== "object" ||
catalog === null ||
!("contexts" in catalog) ||
!Array.isArray(catalog.contexts)
)
throw new Error("Missing catalog");
const context: unknown = catalog.contexts[0];
if (
typeof context !== "object" ||
context === null ||
!("id" in context) ||
typeof context.id !== "string"
)
throw new Error("Missing context");
const contextId = context.id;
const base = "/api/v2/contexts/" + encodeURIComponent(contextId);
const response = await fetch(base + "/diffs/current?scope=all");
const data: unknown = await response.json();
if (typeof data !== "object" || data === null)
throw new Error("Bad fixture");
Expand All @@ -1287,7 +1323,7 @@ test("copy dialog reports clipboard status and restores focus", async ({
: undefined;
if (typeof fingerprint !== "string")
throw new Error("Missing fixture fingerprint");
const imported = await fetch("/api/v1/comments/import", {
const imported = await fetch(base + "/comments/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down
15 changes: 10 additions & 5 deletions apps/web/test/browser/async-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ declare global {

async function fixture(baseURL: string | undefined) {
if (!baseURL) throw new Error("Missing test URL");
const { data } = await createApiClient({ baseUrl: baseURL }).GET(
"/api/v1/diffs/current",
{ params: { query: { scope: "all" } } },
const client = createApiClient({ baseUrl: baseURL });
const catalog = await client.GET("/api/v2/contexts");
const contextId = catalog.data?.contexts[0]?.id;
if (!contextId) throw new Error("Missing fixture context");
const { data } = await client.GET(
"/api/v2/contexts/{contextId}/diffs/current",
{ params: { path: { contextId }, query: { scope: "all" } } },
);
if (!data) throw new Error("Missing fixture diff");
return data;
Expand Down Expand Up @@ -503,9 +507,10 @@ test("reviewed-file failure remains visible with comments disabled and retries",
baseURL,
}) => {
if (!baseURL) throw new Error("Missing test URL");
const { data: session } = await createApiClient({ baseUrl: baseURL }).GET(
"/api/v1/session",
const catalog = await createApiClient({ baseUrl: baseURL }).GET(
"/api/v2/contexts",
);
const session = catalog.data?.contexts[0];
if (!session) throw new Error("Missing fixture session");
await page.route("**/api/v2/contexts/*", (route) =>
route.fulfill({
Expand Down
22 changes: 20 additions & 2 deletions apps/web/test/browser/design-refresh.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,25 @@ test.beforeEach(async ({ request }) => resetFixtureState(request));

async function seedReviewComments(page: Page) {
await page.evaluate(async () => {
const response = await fetch("/api/v1/diffs/current?scope=all");
const catalog: unknown = await (await fetch("/api/v2/contexts")).json();
if (
typeof catalog !== "object" ||
catalog === null ||
!("contexts" in catalog) ||
!Array.isArray(catalog.contexts)
)
throw new Error("Missing catalog");
const context: unknown = catalog.contexts[0];
if (
typeof context !== "object" ||
context === null ||
!("id" in context) ||
typeof context.id !== "string"
)
throw new Error("Missing context");
const contextId = context.id;
const base = "/api/v2/contexts/" + encodeURIComponent(contextId);
const response = await fetch(base + "/diffs/current?scope=all");
const data: unknown = await response.json();
if (typeof data !== "object" || data === null)
throw new Error("Bad fixture");
Expand All @@ -30,7 +48,7 @@ async function seedReviewComments(page: Page) {
: undefined;
if (typeof fingerprint !== "string")
throw new Error("Missing fixture fingerprint");
const imported = await fetch("/api/v1/comments/import", {
const imported = await fetch(base + "/comments/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down
13 changes: 10 additions & 3 deletions apps/web/test/browser/fixture-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@ function record(value: unknown): value is Record<string, unknown> {
}

export async function resetFixtureState(request: APIRequestContext) {
const response = await request.get("/api/v1/comments");
const catalog: unknown = await (await request.get("/api/v2/contexts")).json();
if (!record(catalog) || !Array.isArray(catalog.contexts))
throw new Error("Missing fixture catalog");
const context: unknown = catalog.contexts[0];
if (!record(context) || typeof context.id !== "string")
throw new Error("Missing fixture context");
const base = "/api/v2/contexts/" + encodeURIComponent(context.id);
const response = await request.get(base + "/comments");
const body: unknown = await response.json();
if (record(body) && Array.isArray(body.comments)) {
for (const comment of body.comments) {
if (record(comment) && typeof comment.id === "string")
await request.delete(
`/api/v1/comments/${encodeURIComponent(comment.id)}`,
`${base}/comments/${encodeURIComponent(comment.id)}`,
);
}
}
for (const scope of ["all", "staged", "unstaged"])
await request.delete(`/api/v1/review-marks?scope=${scope}`);
await request.delete(`${base}/review-marks?scope=${scope}`);
}
Loading
Loading