diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 1cb83f4f..ef29288f 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -51,6 +51,7 @@ export { deleteSharingDomainProjectMapping, enrollPeer, importCoordinatorInvite, + LegacySharedReviewConfirmationError, loadCoordinatorGroupPreferences, loadPairing, loadProjectScopeInventory, @@ -58,6 +59,7 @@ export { loadSyncActors, loadSyncStatus, mergeActor, + reassignLegacySharedReviewGroup, reassignProjectInventoryProject, renameActor, renamePeer, diff --git a/packages/ui/src/lib/api/sync.ts b/packages/ui/src/lib/api/sync.ts index c97e3229..64e9f510 100644 --- a/packages/ui/src/lib/api/sync.ts +++ b/packages/ui/src/lib/api/sync.ts @@ -245,6 +245,25 @@ export interface ProjectReassignmentResult { moved_memory_count: number; } +export interface LegacySharedReviewReassignmentPreview { + workspace_identity: string; + scope_id: string; + target_scope_label: string; + memory_count: number; + reassignable_memory_count: number; + skipped_memory_count: number; + affected_peer_device_count: number; + affected_peer_device_ids: string[]; + warning: string; +} + +export interface LegacySharedReviewReassignmentResult + extends LegacySharedReviewReassignmentPreview { + ok?: boolean; + reassigned_memory_count: number; + legacy_shared_review?: Record | null; +} + export interface SharingDomainSettings { scopes: SharingDomainScope[]; mappings: ProjectScopeMapping[]; @@ -270,6 +289,16 @@ export class SharingDomainGuardrailConfirmationError extends Error { } } +export class LegacySharedReviewConfirmationError extends Error { + preview: LegacySharedReviewReassignmentPreview; + + constructor(preview: LegacySharedReviewReassignmentPreview) { + super("Legacy shared review confirmation required"); + this.name = "LegacySharedReviewConfirmationError"; + this.preview = preview; + } +} + export async function loadSharingDomainSettings(): Promise { return fetchJson("/api/sync/sharing-domains/settings"); } @@ -353,6 +382,32 @@ export async function reassignProjectInventoryProject(input: { return payload; } +export async function reassignLegacySharedReviewGroup(input: { + workspace_identity: string; + scope_id: string; + confirmed_old_copies?: boolean; +}): Promise { + const resp = await fetch("/api/sync/legacy-shared-review/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const { text, payload } = await readJsonPayload< + LegacySharedReviewReassignmentResult & { + error?: string; + preview?: LegacySharedReviewReassignmentPreview; + } + >(resp); + if (!resp.ok) { + if (payload?.error === "legacy_review_confirmation_required" && payload.preview) { + throw new LegacySharedReviewConfirmationError(payload.preview); + } + throw new Error(payloadError(payload) || text || "request failed"); + } + if (!payload?.workspace_identity) throw new Error("response missing legacy review reassignment"); + return payload; +} + export async function loadCoordinatorGroupPreferences( groupId: string, ): Promise { diff --git a/packages/ui/src/tabs/sync/components/sync-sharing-review.test.tsx b/packages/ui/src/tabs/sync/components/sync-sharing-review.test.tsx index f4629f29..39a447c4 100644 --- a/packages/ui/src/tabs/sync/components/sync-sharing-review.test.tsx +++ b/packages/ui/src/tabs/sync/components/sync-sharing-review.test.tsx @@ -61,9 +61,80 @@ describe("SyncSharingReview", () => { "Remapping or revocation does not erase data already copied", ); - const button = root.querySelector("button"); - expect(button?.textContent).toBe("Review projects"); + const buttons = [...root.querySelectorAll("button")]; + const button = buttons.find((item) => item.textContent === "Review projects"); + expect(button).toBeTruthy(); button?.click(); expect(onLegacyReview).toHaveBeenCalledTimes(1); }); + + it("requires explicit confirmation before applying a suggested legacy domain", async () => { + const onLegacyReassign = vi + .fn() + .mockResolvedValueOnce({ + affected_peer_device_count: 1, + affected_peer_device_ids: ["peer-a"], + memory_count: 3, + reassignable_memory_count: 2, + scope_id: "oss", + skipped_memory_count: 1, + target_scope_label: "OSS", + warning: + "This changes future sync authorization but does not erase data already copied to peers.", + workspace_identity: "https://git.example.invalid/oss/dev.git", + }) + .mockResolvedValueOnce(null); + const root = renderReview( + {}} + />, + ); + + const applyButton = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "Review suggested reassignment", + ); + expect(applyButton).toBeTruthy(); + await act(async () => { + applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLegacyReassign).toHaveBeenCalledWith( + expect.objectContaining({ workspaceIdentity: "https://git.example.invalid/oss/dev.git" }), + "oss", + false, + ); + expect(root.textContent).toContain("2 of 3 memories"); + expect(root.textContent).toContain("1 peer-owned copies will be left unchanged"); + + const confirmButton = [...root.querySelectorAll("button")].find( + (button) => button.textContent === "I understand, reassign memories", + ); + expect(confirmButton).toBeTruthy(); + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLegacyReassign).toHaveBeenLastCalledWith( + expect.objectContaining({ workspaceIdentity: "https://git.example.invalid/oss/dev.git" }), + "oss", + true, + ); + }); }); diff --git a/packages/ui/src/tabs/sync/components/sync-sharing-review.tsx b/packages/ui/src/tabs/sync/components/sync-sharing-review.tsx index e96dbc67..7492b7c7 100644 --- a/packages/ui/src/tabs/sync/components/sync-sharing-review.tsx +++ b/packages/ui/src/tabs/sync/components/sync-sharing-review.tsx @@ -1,3 +1,7 @@ +import { useState } from "preact/hooks"; + +import type { LegacySharedReviewReassignmentPreview } from "../../../lib/api/sync"; + export interface SyncSharingReviewItem { actorDisplayName: string; actorId: string; @@ -26,6 +30,11 @@ export interface SyncLegacySharedReviewGroup { type SyncSharingReviewProps = { items: SyncSharingReviewItem[]; legacyReview?: SyncLegacySharedReviewItem | null; + onLegacyReassign?: ( + group: SyncLegacySharedReviewGroup, + scopeId: string, + confirmedOldCopies: boolean, + ) => Promise; onLegacyReview?: () => void; onReview: () => void; }; @@ -60,12 +69,41 @@ function SharingReviewRow({ function LegacySharedReviewRow({ item, + onReassign, onReview, }: { item: SyncLegacySharedReviewItem; + onReassign?: ( + group: SyncLegacySharedReviewGroup, + scopeId: string, + confirmedOldCopies: boolean, + ) => Promise; onReview: () => void; }) { const groups = item.groups ?? []; + const [pending, setPending] = useState>({}); + const [busyIdentity, setBusyIdentity] = useState(null); + async function applyGroup(group: SyncLegacySharedReviewGroup) { + if (!group.suggestedScopeId || !onReassign || busyIdentity) return; + setBusyIdentity(group.workspaceIdentity); + try { + const preview = await onReassign( + group, + group.suggestedScopeId, + Boolean(pending[group.workspaceIdentity]), + ); + if (preview) setPending((current) => ({ ...current, [group.workspaceIdentity]: preview })); + else { + setPending((current) => { + const next = { ...current }; + delete next[group.workspaceIdentity]; + return next; + }); + } + } finally { + setBusyIdentity(null); + } + } return (
@@ -92,6 +130,31 @@ function LegacySharedReviewRow({ {group.suggestionReason ? ( {group.suggestionReason} ) : null} + {pending[group.workspaceIdentity] ? ( + + {pending[group.workspaceIdentity].warning} This will reassign{" "} + {pending[group.workspaceIdentity].reassignable_memory_count.toLocaleString()} of{" "} + {pending[group.workspaceIdentity].memory_count.toLocaleString()} memories + {pending[group.workspaceIdentity].skipped_memory_count + ? `; ${pending[group.workspaceIdentity].skipped_memory_count.toLocaleString()} peer-owned copies will be left unchanged` + : ""} + . + + ) : null} + {group.suggestedScopeId && onReassign ? ( + + ) : null} ))} @@ -109,13 +172,18 @@ function LegacySharedReviewRow({ export function SyncSharingReview({ items, legacyReview, + onLegacyReassign, onLegacyReview, onReview, }: SyncSharingReviewProps) { return ( <> {legacyReview ? ( - + ) : null} {items.map((item) => ( { + try { + const result = (await api.reassignLegacySharedReviewGroup({ + confirmed_old_copies: confirmedOldCopies, + scope_id: scopeId, + workspace_identity: group.workspaceIdentity, + })) as LegacySharedReviewReassignmentResult; + state.lastSyncLegacySharedReview = result.legacy_shared_review ?? null; + showGlobalNotice( + `Reassigned ${result.reassigned_memory_count.toLocaleString()} legacy review memor${result.reassigned_memory_count === 1 ? "y" : "ies"} to ${result.target_scope_label}.`, + ); + renderSyncSharingReview(); + return null; + } catch (error) { + if (error instanceof api.LegacySharedReviewConfirmationError) return error.preview; + showGlobalNotice( + error instanceof Error ? error.message : "Unable to reassign legacy review memories.", + "warning", + ); + return null; + } +} + export function renderSyncSharingReview() { const panel = document.getElementById("syncSharingReview"); const meta = document.getElementById("syncSharingReviewMeta"); @@ -79,6 +113,7 @@ export function renderSyncSharingReview() { h(SyncSharingReview, { items: reviewItems, legacyReview, + onLegacyReassign: reassignLegacySharedReviewGroup, onLegacyReview: openProjectsReview, onReview: openFeedSharingReview, }), diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 41a6d723..c6d9d999 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -2662,6 +2662,197 @@ describe("viewer-server", () => { } }); + it("previews and applies legacy shared review reassignment explicitly", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + const _warmup = await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const now = new Date().toISOString(); + store.db + .prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('oss', 'OSS', 'team', 'coordinator', 1, 'active', ?, ?)`, + ) + .run(now, now); + grantSyncScopeToDevices(store, "oss", [store.deviceId]); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET cwd = ?, git_remote = ?, project = ? WHERE id = ?") + .run( + "/workspace/oss/dev", + "https://git.example.invalid/oss/dev.git", + "oss-dev", + sessionId, + ); + insertTestMemory(store, { + kind: "discovery", + scopeId: "legacy-shared-review", + sessionId, + title: "legacy local shared", + }); + insertTestMemory(store, { + actorId: "remote-actor", + kind: "discovery", + originDeviceId: "peer-device", + scopeId: "legacy-shared-review", + sessionId, + title: "legacy peer shared", + }); + + const previewRes = await app.request("/api/sync/legacy-shared-review/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope_id: "oss", + workspace_identity: "https://git.example.invalid/oss/dev.git", + }), + }); + expect(previewRes.status).toBe(409); + const preview = (await previewRes.json()) as { + error: string; + preview: { memory_count: number; reassignable_memory_count: number; warning: string }; + }; + expect(preview.error).toBe("legacy_review_confirmation_required"); + expect(preview.preview).toMatchObject({ + memory_count: 2, + reassignable_memory_count: 1, + }); + expect(preview.preview.warning).toContain("does not erase data already copied"); + + const applyRes = await app.request("/api/sync/legacy-shared-review/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmed_old_copies: true, + scope_id: "oss", + workspace_identity: "https://git.example.invalid/oss/dev.git", + }), + }); + expect(applyRes.status).toBe(200); + const applied = (await applyRes.json()) as { + legacy_shared_review: { memory_count: number }; + reassigned_memory_count: number; + }; + expect(applied.reassigned_memory_count).toBe(1); + expect(applied.legacy_shared_review.memory_count).toBe(1); + const counts = store.db + .prepare("SELECT scope_id, COUNT(*) AS n FROM memory_items GROUP BY scope_id") + .all() as Array<{ scope_id: string; n: number }>; + expect(counts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ n: 1, scope_id: "oss" }), + expect.objectContaining({ n: 1, scope_id: "legacy-shared-review" }), + ]), + ); + const ops = store.db + .prepare("SELECT op_type, scope_id FROM replication_ops ORDER BY op_id") + .all() as Array<{ op_type: string; scope_id: string }>; + expect(ops).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op_type: "delete", scope_id: "legacy-shared-review" }), + expect.objectContaining({ op_type: "upsert", scope_id: "oss" }), + ]), + ); + } finally { + cleanup(); + } + }); + + it("rejects legacy shared review reassignment when the local device lacks target membership", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + const _warmup = await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const now = new Date().toISOString(); + store.db + .prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('oss', 'OSS', 'team', 'coordinator', 1, 'active', ?, ?)`, + ) + .run(now, now); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET git_remote = ? WHERE id = ?") + .run("https://git.example.invalid/oss/dev.git", sessionId); + insertTestMemory(store, { + kind: "discovery", + scopeId: "legacy-shared-review", + sessionId, + title: "legacy local shared", + }); + + const res = await app.request("/api/sync/legacy-shared-review/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmed_old_copies: true, + scope_id: "oss", + workspace_identity: "https://git.example.invalid/oss/dev.git", + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("local device is not a member"); + const memory = store.db.prepare("SELECT scope_id FROM memory_items LIMIT 1").get() as { + scope_id: string; + }; + expect(memory.scope_id).toBe("legacy-shared-review"); + const ops = store.db.prepare("SELECT COUNT(*) AS n FROM replication_ops").get() as { + n: number; + }; + expect(ops.n).toBe(0); + } finally { + cleanup(); + } + }); + + it("rejects local-default as a legacy shared review reassignment target", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + const _warmup = await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET git_remote = ? WHERE id = ?") + .run("https://git.example.invalid/oss/dev.git", sessionId); + insertTestMemory(store, { + kind: "discovery", + scopeId: "legacy-shared-review", + sessionId, + title: "legacy local shared", + }); + + const res = await app.request("/api/sync/legacy-shared-review/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmed_old_copies: true, + scope_id: "local-default", + workspace_identity: "https://git.example.invalid/oss/dev.git", + }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("local-default is not a valid target"); + const memory = store.db.prepare("SELECT scope_id FROM memory_items LIMIT 1").get() as { + scope_id: string; + }; + expect(memory.scope_id).toBe("legacy-shared-review"); + const ops = store.db.prepare("SELECT COUNT(*) AS n FROM replication_ops").get() as { + n: number; + }; + expect(ops.n).toBe(0); + } finally { + cleanup(); + } + }); + it("surfaces inbound scope-rejection summary per peer", async () => { const { app, getStore, cleanup } = createTestApp(); try { diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index 972876ee..887dd899 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -1359,6 +1359,188 @@ function legacySharedReviewSummary(store: MemoryStore): Record }; } +interface LegacySharedReviewReassignmentMemoryRow { + id: number; + active: number | null; + deleted_at: string | null; + scope_id: string | null; + project: string | null; + cwd: string | null; + git_remote: string | null; + git_branch: string | null; + workspace_id: string | null; + actor_id: string | null; + origin_device_id: string | null; + metadata_json: string | null; +} + +interface LegacySharedReviewReassignmentPreview { + workspace_identity: string; + scope_id: string; + target_scope_label: string; + memory_count: number; + reassignable_memory_count: number; + skipped_memory_count: number; + affected_peer_device_count: number; + affected_peer_device_ids: string[]; + warning: string; +} + +function assertLocalDeviceScopeMembership(store: MemoryStore, scopeId: string): void { + if (scopeId === LOCAL_DEFAULT_SCOPE_ID) return; + const row = store.db + .prepare( + `SELECT 1 + FROM scope_memberships sm + JOIN replication_scopes rs ON rs.scope_id = sm.scope_id + WHERE sm.scope_id = ? + AND sm.device_id = ? + AND sm.status = 'active' + AND rs.status = 'active' + AND sm.membership_epoch >= rs.membership_epoch + LIMIT 1`, + ) + .get(scopeId, store.deviceId); + if (!row) throw new Error(`local device is not a member of Sharing domain ${scopeId}`); +} + +function legacySharedReviewRowsForWorkspace( + store: MemoryStore, + workspaceIdentity: string, +): LegacySharedReviewReassignmentMemoryRow[] { + const rows = store.db + .prepare( + `SELECT m.id, + m.active, + m.deleted_at, + m.scope_id, + s.project, + s.cwd, + s.git_remote, + s.git_branch, + m.workspace_id, + m.actor_id, + m.origin_device_id, + m.metadata_json + FROM memory_items m + LEFT JOIN sessions s ON s.id = m.session_id + WHERE m.scope_id = ? + AND m.active = 1 + AND m.deleted_at IS NULL`, + ) + .all(LEGACY_SHARED_REVIEW_SCOPE_ID) as LegacySharedReviewReassignmentMemoryRow[]; + return rows.filter((row) => { + const identity = canonicalWorkspaceIdentity({ + cwd: row.cwd, + gitBranch: row.git_branch, + gitRemote: row.git_remote, + project: row.project, + workspaceId: row.workspace_id, + }); + return identity.value === workspaceIdentity; + }); +} + +function reassignableLegacySharedReviewRows( + store: MemoryStore, + rows: LegacySharedReviewReassignmentMemoryRow[], +): LegacySharedReviewReassignmentMemoryRow[] { + const reassignableRows = rows.filter((row) => + store.memoryOwnedBySelf(row as unknown as Record), + ); + if (reassignableRows.length === 0) { + throw new Error("legacy shared review group has no locally owned memories to reassign"); + } + const currentRows = store.db + .prepare( + `SELECT id, scope_id, active, deleted_at + FROM memory_items + WHERE id IN (${reassignableRows.map(() => "?").join(",")})`, + ) + .all(...reassignableRows.map((row) => row.id)) as Array<{ + active: number | null; + deleted_at: string | null; + id: number; + scope_id: string | null; + }>; + const currentById = new Map(currentRows.map((row) => [Number(row.id), row])); + for (const row of reassignableRows) { + const current = currentById.get(row.id); + if ( + current?.scope_id !== LEGACY_SHARED_REVIEW_SCOPE_ID || + current.active !== 1 || + current.deleted_at + ) { + throw new Error( + "legacy shared review group changed before reassignment; refresh and try again", + ); + } + } + return reassignableRows; +} + +function legacySharedReviewReassignmentPreview( + store: MemoryStore, + input: { scopeId: string; workspaceIdentity: string }, +): LegacySharedReviewReassignmentPreview { + if (!input.workspaceIdentity.trim()) + throw new Error("workspace_identity must be a non-empty string"); + if (!input.scopeId.trim()) throw new Error("scope_id must be a non-empty string"); + if (input.scopeId === LOCAL_DEFAULT_SCOPE_ID) { + throw new Error("local-default is not a valid target for legacy shared review reassignment"); + } + if (input.scopeId === LEGACY_SHARED_REVIEW_SCOPE_ID) { + throw new Error("legacy-shared-review is a review bucket, not an assignable Sharing domain"); + } + const targetScope = listSharingDomainSettingsScopes(store.db).find( + (scope) => scope.scope_id === input.scopeId, + ); + if (!targetScope) throw new Error(`scope_id ${input.scopeId} is not an active Sharing domain`); + assertLocalDeviceScopeMembership(store, input.scopeId); + const rows = legacySharedReviewRowsForWorkspace(store, input.workspaceIdentity); + if (rows.length === 0) throw new Error("legacy shared review group not found"); + const reassignableRows = reassignableLegacySharedReviewRows(store, rows); + const peerDeviceIds = [ + ...new Set( + rows + .map((row) => String(row.origin_device_id ?? "").trim()) + .filter((deviceId) => deviceId && deviceId !== store.deviceId), + ), + ].sort(); + return { + affected_peer_device_count: peerDeviceIds.length, + affected_peer_device_ids: peerDeviceIds.slice(0, 5), + memory_count: rows.length, + reassignable_memory_count: reassignableRows.length, + scope_id: input.scopeId, + skipped_memory_count: rows.length - reassignableRows.length, + target_scope_label: targetScope.label || targetScope.scope_id, + warning: + "This changes future sync authorization for reassignable local memories. It does not erase data already copied to peers under legacy-shared-review.", + workspace_identity: input.workspaceIdentity, + }; +} + +function reassignLegacySharedReviewGroup( + store: MemoryStore, + input: { scopeId: string; workspaceIdentity: string }, +): Record { + const preview = legacySharedReviewReassignmentPreview(store, input); + const rows = reassignableLegacySharedReviewRows( + store, + legacySharedReviewRowsForWorkspace(store, input.workspaceIdentity), + ); + for (const row of rows) { + store.reassignMemoryScope(row.id, input.scopeId); + } + return { + ok: true, + ...preview, + reassigned_memory_count: rows.length, + legacy_shared_review: legacySharedReviewSummary(store), + }; +} + // Aggregate ops_in / ops_out across recent successful sync_attempts per peer. // Window: last 24 hours. Feeds the per-peer direction glyph on the Sync tab // (↕ bidirectional, ↑ publishing, ↓ subscribed) off real traffic instead of @@ -2506,6 +2688,40 @@ export function syncRoutes( } }); + app.post("/api/sync/legacy-shared-review/reassign", async (c) => { + const store = getStore(); + const body = await parseViewerJsonBody(c); + if (!body) return c.json({ error: "invalid json" }, 400); + try { + const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity") ?? ""; + const scopeId = optionalViewerStrictString(body, "scope_id") ?? ""; + const confirmedOldCopies = body.confirmed_old_copies === true; + const preview = legacySharedReviewReassignmentPreview(store, { + scopeId, + workspaceIdentity, + }); + if (!confirmedOldCopies) { + return c.json( + { + error: "legacy_review_confirmation_required", + message: preview.warning, + preview, + }, + 409, + ); + } + return c.json( + reassignLegacySharedReviewGroup(store, { + scopeId, + workspaceIdentity, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return c.json({ error: message }, message.includes("not found") ? 404 : 400); + } + }); + app.put("/api/sync/sharing-domains/project-mappings", async (c) => { const store = getStore(); const body = await parseViewerJsonBody(c);