diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50e41824..eebfc2dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -370,6 +370,19 @@ export { projectMatchesFilter, resolveProject, } from "./project.js"; +export type { + ProjectScopeCandidate, + ProjectScopeSettingsMapping, + SharingDomainSettingsScope, + UpsertProjectScopeMappingInput, +} from "./project-scope-settings.js"; +export { + deleteProjectScopeSettingsMapping, + listProjectScopeCandidates, + listProjectScopeSettingsMappings, + listSharingDomainSettingsScopes, + upsertProjectScopeSettingsMapping, +} from "./project-scope-settings.js"; export type { FlushRawEventsOptions } from "./raw-event-flush.js"; export { buildSessionContext, flushRawEvents } from "./raw-event-flush.js"; export { RawEventSweeper } from "./raw-event-sweeper.js"; diff --git a/packages/core/src/project-scope-settings.test.ts b/packages/core/src/project-scope-settings.test.ts new file mode 100644 index 00000000..456c2d07 --- /dev/null +++ b/packages/core/src/project-scope-settings.test.ts @@ -0,0 +1,185 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { toJson } from "./db.js"; +import { + listProjectScopeCandidates, + listSharingDomainSettingsScopes, + upsertProjectScopeSettingsMapping, +} from "./project-scope-settings.js"; +import { LOCAL_DEFAULT_SCOPE_ID } from "./scope-resolution.js"; +import { initTestSchema } from "./test-utils.js"; + +function insertSession( + db: InstanceType, + input: { + cwd?: string | null; + project?: string | null; + gitRemote?: string | null; + gitBranch?: string | null; + } = {}, +) { + const now = "2026-05-06T00:00:00Z"; + const result = db + .prepare( + `INSERT INTO sessions(started_at, cwd, project, git_remote, git_branch, user, tool_version) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + now, + input.cwd === undefined ? "/Users/adam/work/acme/api" : input.cwd, + input.project === undefined ? "api" : input.project, + input.gitRemote === undefined ? "https://example.test/acme/api.git" : input.gitRemote, + input.gitBranch === undefined ? "main" : input.gitBranch, + "test-user", + "test", + ); + return Number(result.lastInsertRowid); +} + +function insertMemory( + db: InstanceType, + sessionId: number, + input: { workspaceId?: string | null } = {}, +) { + const now = "2026-05-06T00:00:00Z"; + db.prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, created_at, updated_at, + visibility, workspace_id, active, metadata_json + ) VALUES (?, 'discovery', 'Scoped project', 'Body', ?, ?, 'shared', ?, 1, ?)`, + ).run( + sessionId, + now, + now, + input.workspaceId === undefined ? "shared:acme" : input.workspaceId, + toJson({}), + ); +} + +describe("project scope settings", () => { + let db: InstanceType; + + beforeEach(() => { + db = new Database(":memory:"); + initTestSchema(db); + }); + + afterEach(() => { + db.close(); + }); + + it("lists local sharing-domain defaults and unknown projects as local-only", () => { + const sessionId = insertSession(db); + insertMemory(db, sessionId); + + const scopes = listSharingDomainSettingsScopes(db); + const projects = listProjectScopeCandidates(db); + + expect(scopes.map((scope) => scope.scope_id)).toContain(LOCAL_DEFAULT_SCOPE_ID); + expect(projects).toEqual([ + expect.objectContaining({ + display_project: "api", + identity_source: "git_remote", + resolved_scope_id: LOCAL_DEFAULT_SCOPE_ID, + resolution_reason: "local_default", + }), + ]); + }); + + it("includes workspace-id-only and unmapped sessions with memories as local-only", () => { + const workspaceOnlySession = insertSession(db, { + cwd: null, + gitBranch: null, + gitRemote: null, + project: null, + }); + insertMemory(db, workspaceOnlySession, { workspaceId: "shared:workspace-only" }); + const unmappedSession = insertSession(db, { + cwd: null, + gitBranch: null, + gitRemote: null, + project: null, + }); + insertMemory(db, unmappedSession, { workspaceId: null }); + + const projects = listProjectScopeCandidates(db); + + expect(projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + display_project: "shared:workspace-only", + identity_source: "workspace_id", + resolved_scope_id: LOCAL_DEFAULT_SCOPE_ID, + resolution_reason: "local_default", + }), + expect.objectContaining({ + identity_source: "unmapped", + resolved_scope_id: LOCAL_DEFAULT_SCOPE_ID, + resolution_reason: "local_default", + }), + ]), + ); + }); + + it("assigns a canonical project identity without granting membership", () => { + const sessionId = insertSession(db); + insertMemory(db, sessionId); + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('acme-work', 'Acme Work', 'team', 'coordinator', 1, 'active', ?, ?)`, + ).run("2026-05-06T00:00:00Z", "2026-05-06T00:00:00Z"); + + const [project] = listProjectScopeCandidates(db); + if (!project) throw new Error("project missing"); + const mapping = upsertProjectScopeSettingsMapping(db, { + workspace_identity: project.workspace_identity, + project_pattern: project.display_project, + scope_id: "acme-work", + }); + const [resolved] = listProjectScopeCandidates(db); + const memberships = db.prepare("SELECT COUNT(*) AS n FROM scope_memberships").get() as { + n: number; + }; + + expect(mapping).toMatchObject({ scope_id: "acme-work", source: "user" }); + expect(resolved).toMatchObject({ + resolved_scope_id: "acme-work", + resolution_reason: "exact_mapping", + mapping_id: mapping.id, + }); + expect(memberships.n).toBe(0); + }); + + it("rejects basename-only pattern mappings", () => { + expect(() => + upsertProjectScopeSettingsMapping(db, { + project_pattern: "api", + scope_id: LOCAL_DEFAULT_SCOPE_ID, + }), + ).toThrow(/canonical path, remote, or workspace pattern/); + }); + + it("rejects mappings to inactive or unknown Sharing domains", () => { + db.prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES ('inactive-work', 'Inactive Work', 'team', 'coordinator', 1, 'archived', ?, ?)`, + ).run("2026-05-06T00:00:00Z", "2026-05-06T00:00:00Z"); + + expect(() => + upsertProjectScopeSettingsMapping(db, { + workspace_identity: "https://example.test/acme/api.git", + project_pattern: "api", + scope_id: "missing-domain", + }), + ).toThrow(/not an active Sharing domain/); + expect(() => + upsertProjectScopeSettingsMapping(db, { + workspace_identity: "https://example.test/acme/api.git", + project_pattern: "api", + scope_id: "inactive-work", + }), + ).toThrow(/not an active Sharing domain/); + }); +}); diff --git a/packages/core/src/project-scope-settings.ts b/packages/core/src/project-scope-settings.ts new file mode 100644 index 00000000..0a12b092 --- /dev/null +++ b/packages/core/src/project-scope-settings.ts @@ -0,0 +1,303 @@ +import type { Database } from "./db.js"; +import { ensureScopeBackfillScopes } from "./scope-backfill.js"; +import { + canonicalWorkspaceIdentity, + LOCAL_DEFAULT_SCOPE_ID, + resolveProjectScope, + type ScopeMapping, + type ScopeResolutionReason, + type WorkspaceIdentitySource, +} from "./scope-resolution.js"; + +export interface SharingDomainSettingsScope { + scope_id: string; + label: string; + kind: string; + authority_type: string; + coordinator_id: string | null; + group_id: string | null; + membership_epoch: number; + status: string; + updated_at: string; +} + +export interface ProjectScopeSettingsMapping extends ScopeMapping { + id: number; + workspace_identity: string | null; + project_pattern: string; + scope_id: string; + priority: number; + source: string; + created_at: string; + updated_at: string; +} + +export interface ProjectScopeCandidate { + workspace_identity: string; + identity_source: WorkspaceIdentitySource; + display_project: string; + project: string | null; + cwd: string | null; + git_remote: string | null; + git_branch: string | null; + latest_session_at: string | null; + resolved_scope_id: string; + resolution_reason: ScopeResolutionReason; + mapping_id: number | null; + matched_pattern: string | null; +} + +export interface UpsertProjectScopeMappingInput { + id?: number | null; + workspace_identity?: string | null; + project_pattern?: string | null; + scope_id: string; + priority?: number | null; + source?: string | null; +} + +interface ProjectScopeCandidateRow { + id: number; + started_at: string | null; + cwd: string | null; + project: string | null; + git_remote: string | null; + git_branch: string | null; + workspace_id: string | null; +} + +function clean(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function assertCanonicalPattern(workspaceIdentity: string | null, projectPattern: string): void { + if (workspaceIdentity) return; + if (/[\\/:]/.test(projectPattern)) return; + if (/[*?]/.test(projectPattern) && /[\\/:]/.test(projectPattern.replace(/[*?]/g, ""))) return; + throw new Error("project_pattern must use a canonical path, remote, or workspace pattern"); +} + +function rowToScope(row: Record): SharingDomainSettingsScope { + return { + scope_id: String(row.scope_id ?? ""), + label: String(row.label ?? ""), + kind: String(row.kind ?? "user"), + authority_type: String(row.authority_type ?? "local"), + coordinator_id: clean(row.coordinator_id as string | null | undefined), + group_id: clean(row.group_id as string | null | undefined), + membership_epoch: Number(row.membership_epoch ?? 0), + status: String(row.status ?? "active"), + updated_at: String(row.updated_at ?? ""), + }; +} + +function rowToMapping(row: Record): ProjectScopeSettingsMapping { + return { + id: Number(row.id ?? 0), + workspace_identity: clean(row.workspace_identity as string | null | undefined), + project_pattern: String(row.project_pattern ?? ""), + scope_id: String(row.scope_id ?? ""), + priority: Number(row.priority ?? 0), + source: String(row.source ?? "user"), + created_at: String(row.created_at ?? ""), + updated_at: String(row.updated_at ?? ""), + }; +} + +export function listSharingDomainSettingsScopes(db: Database): SharingDomainSettingsScope[] { + ensureScopeBackfillScopes(db); + return db + .prepare( + `SELECT scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, updated_at + FROM replication_scopes + WHERE status = 'active' + ORDER BY CASE WHEN scope_id = ? THEN 0 ELSE 1 END, label COLLATE NOCASE, scope_id`, + ) + .all(LOCAL_DEFAULT_SCOPE_ID) + .map((row) => rowToScope(row as Record)); +} + +export function listProjectScopeSettingsMappings(db: Database): ProjectScopeSettingsMapping[] { + return db + .prepare( + `SELECT id, workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + FROM project_scope_mappings + ORDER BY priority DESC, updated_at DESC, id DESC`, + ) + .all() + .map((row) => rowToMapping(row as Record)); +} + +function getProjectScopeSettingsMappingById( + db: Database, + id: number, +): ProjectScopeSettingsMapping | null { + const row = db + .prepare( + `SELECT id, workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + FROM project_scope_mappings + WHERE id = ? + LIMIT 1`, + ) + .get(id) as Record | undefined; + return row ? rowToMapping(row) : null; +} + +function getProjectScopeSettingsMappingByWorkspaceIdentity( + db: Database, + workspaceIdentity: string, +): ProjectScopeSettingsMapping | null { + const row = db + .prepare( + `SELECT id, workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + FROM project_scope_mappings + WHERE workspace_identity = ? + ORDER BY priority DESC, updated_at DESC, id DESC + LIMIT 1`, + ) + .get(workspaceIdentity) as Record | undefined; + return row ? rowToMapping(row) : null; +} + +function assertActiveScope(db: Database, scopeId: string): void { + const row = db + .prepare("SELECT 1 FROM replication_scopes WHERE scope_id = ? AND status = 'active' LIMIT 1") + .get(scopeId); + if (!row) throw new Error(`scope_id ${scopeId} is not an active Sharing domain`); +} + +export function listProjectScopeCandidates( + db: Database, + options: { limit?: number } = {}, +): ProjectScopeCandidate[] { + ensureScopeBackfillScopes(db); + const limit = Math.max(1, Math.min(options.limit ?? 250, 1000)); + const mappings = listProjectScopeSettingsMappings(db); + const rows = db + .prepare( + `SELECT + s.id, + s.started_at, + s.cwd, + s.project, + s.git_remote, + s.git_branch, + ( + SELECT mi.workspace_id + FROM memory_items mi + WHERE mi.session_id = s.id + AND mi.workspace_id IS NOT NULL + AND TRIM(mi.workspace_id) <> '' + ORDER BY mi.id DESC + LIMIT 1 + ) AS workspace_id + FROM sessions s + WHERE COALESCE(TRIM(s.git_remote), TRIM(s.cwd), TRIM(s.project), '') <> '' + OR EXISTS (SELECT 1 FROM memory_items mi_candidate WHERE mi_candidate.session_id = s.id) + ORDER BY s.started_at DESC, s.id DESC + LIMIT ?`, + ) + .all(limit) as ProjectScopeCandidateRow[]; + + const seen = new Set(); + const candidates: ProjectScopeCandidate[] = []; + for (const row of rows) { + const identity = canonicalWorkspaceIdentity({ + gitRemote: row.git_remote, + gitBranch: row.git_branch, + cwd: row.cwd, + project: row.project, + workspaceId: row.workspace_id, + }); + if (seen.has(identity.value)) continue; + seen.add(identity.value); + const resolution = resolveProjectScope({ + gitRemote: row.git_remote, + gitBranch: row.git_branch, + cwd: row.cwd, + project: row.project, + workspaceId: row.workspace_id, + mappings, + }); + candidates.push({ + workspace_identity: identity.value, + identity_source: identity.source, + display_project: + identity.displayProject ?? clean(row.project) ?? clean(row.cwd) ?? identity.value, + project: clean(row.project), + cwd: clean(row.cwd), + git_remote: clean(row.git_remote), + git_branch: clean(row.git_branch), + latest_session_at: row.started_at, + resolved_scope_id: resolution.scopeId, + resolution_reason: resolution.reason, + mapping_id: resolution.mapping?.id ?? null, + matched_pattern: resolution.matchedPattern, + }); + } + + return candidates.toSorted( + (left, right) => + left.display_project.localeCompare(right.display_project) || + left.workspace_identity.localeCompare(right.workspace_identity), + ); +} + +export function upsertProjectScopeSettingsMapping( + db: Database, + input: UpsertProjectScopeMappingInput, +): ProjectScopeSettingsMapping { + ensureScopeBackfillScopes(db); + const scopeId = clean(input.scope_id); + if (!scopeId) throw new Error("scope_id must be a non-empty string"); + assertActiveScope(db, scopeId); + + const id = input.id == null ? null : Number(input.id); + const byId = id && Number.isInteger(id) ? getProjectScopeSettingsMappingById(db, id) : null; + const workspaceIdentity = clean(input.workspace_identity) ?? byId?.workspace_identity ?? null; + const byWorkspace = workspaceIdentity + ? getProjectScopeSettingsMappingByWorkspaceIdentity(db, workspaceIdentity) + : null; + const existing = byId ?? byWorkspace; + const projectPattern = + clean(input.project_pattern) ?? existing?.project_pattern ?? workspaceIdentity; + if (!projectPattern) throw new Error("project_pattern or workspace_identity is required"); + assertCanonicalPattern(workspaceIdentity, projectPattern); + + const priority = input.priority == null ? (existing?.priority ?? 0) : Number(input.priority); + if (!Number.isFinite(priority) || !Number.isInteger(priority)) { + throw new Error("priority must be an integer"); + } + + const source = clean(input.source) ?? "user"; + const now = new Date().toISOString(); + if (existing) { + db.prepare( + `UPDATE project_scope_mappings + SET workspace_identity = ?, project_pattern = ?, scope_id = ?, priority = ?, source = ?, updated_at = ? + WHERE id = ?`, + ).run(workspaceIdentity, projectPattern, scopeId, priority, source, now, existing.id); + const saved = getProjectScopeSettingsMappingById(db, existing.id); + if (!saved) throw new Error("project_scope_mapping update returned no row"); + return saved; + } + + const result = db + .prepare( + `INSERT INTO project_scope_mappings( + workspace_identity, project_pattern, scope_id, priority, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(workspaceIdentity, projectPattern, scopeId, priority, source, now, now); + const saved = getProjectScopeSettingsMappingById(db, Number(result.lastInsertRowid)); + if (!saved) throw new Error("project_scope_mapping insert returned no row"); + return saved; +} + +export function deleteProjectScopeSettingsMapping(db: Database, id: number): boolean { + if (!Number.isInteger(id) || id <= 0) throw new Error("id must be a positive integer"); + const result = db.prepare("DELETE FROM project_scope_mappings WHERE id = ?").run(id); + return Number(result.changes ?? 0) > 0; +} diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 512ab1b3..76781c6e 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -48,16 +48,19 @@ export { createActor, deactivateActor, deletePeer, + deleteSharingDomainProjectMapping, enrollPeer, importCoordinatorInvite, loadCoordinatorGroupPreferences, loadPairing, + loadSharingDomainSettings, loadSyncActors, loadSyncStatus, mergeActor, renameActor, renamePeer, saveCoordinatorGroupPreferences, + saveSharingDomainProjectMapping, triggerSync, updatePeerIdentity, updatePeerScope, diff --git a/packages/ui/src/lib/api/sync.ts b/packages/ui/src/lib/api/sync.ts index 4786ab6f..1443f82b 100644 --- a/packages/ui/src/lib/api/sync.ts +++ b/packages/ui/src/lib/api/sync.ts @@ -155,6 +155,83 @@ export interface CoordinatorGroupPreferences { updated_at?: string | null; } +export interface SharingDomainScope { + scope_id: string; + label: string; + kind: string; + authority_type: string; + coordinator_id?: string | null; + group_id?: string | null; + membership_epoch?: number; + status: string; + updated_at?: string | null; +} + +export interface ProjectScopeMapping { + id: number; + workspace_identity: string | null; + project_pattern: string; + scope_id: string; + priority: number; + source: string; + created_at?: string | null; + updated_at?: string | null; +} + +export interface ProjectScopeCandidate { + workspace_identity: string; + identity_source: string; + display_project: string; + project: string | null; + cwd: string | null; + git_remote: string | null; + git_branch: string | null; + latest_session_at: string | null; + resolved_scope_id: string; + resolution_reason: string; + mapping_id: number | null; + matched_pattern: string | null; +} + +export interface SharingDomainSettings { + scopes: SharingDomainScope[]; + mappings: ProjectScopeMapping[]; + projects: ProjectScopeCandidate[]; + local_default_scope_id: string; +} + +export async function loadSharingDomainSettings(): Promise { + return fetchJson("/api/sync/sharing-domains/settings"); +} + +export async function saveSharingDomainProjectMapping(input: { + id?: number | null; + workspace_identity?: string | null; + project_pattern?: string | null; + scope_id: string; + priority?: number | null; +}): Promise { + const resp = await fetch("/api/sync/sharing-domains/project-mappings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const { text, payload } = await readJsonPayload<{ mapping?: ProjectScopeMapping }>(resp); + if (!resp.ok) throw new Error(payloadError(payload) || text || "request failed"); + const mapping = payload?.mapping; + if (!mapping) throw new Error("response missing mapping"); + return mapping; +} + +export async function deleteSharingDomainProjectMapping(id: number): Promise { + const resp = await fetch(`/api/sync/sharing-domains/project-mappings/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + const { text, payload } = await readJsonPayload<{ deleted?: boolean }>(resp); + if (!resp.ok) throw new Error(payloadError(payload) || text || "request failed"); + return Boolean(payload?.deleted); +} + export async function loadCoordinatorGroupPreferences( groupId: string, ): Promise { diff --git a/packages/ui/src/tabs/settings/components/SharingDomainsPanel.test.tsx b/packages/ui/src/tabs/settings/components/SharingDomainsPanel.test.tsx new file mode 100644 index 00000000..780a7bc0 --- /dev/null +++ b/packages/ui/src/tabs/settings/components/SharingDomainsPanel.test.tsx @@ -0,0 +1,191 @@ +import { type ComponentChild, render } from "preact"; +import { act } from "preact/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../components/primitives/radix-select", () => ({ + RadixSelect: ({ + ariaLabel, + disabled, + id, + onValueChange, + options, + value, + }: { + ariaLabel?: string; + disabled?: boolean; + id?: string; + onValueChange: (value: string) => void; + options: Array<{ label: string; value: string }>; + value: string; + }) => ( + + ), +})); + +vi.mock("../../../lib/api", () => ({ + deleteSharingDomainProjectMapping: vi.fn(), + loadSharingDomainSettings: vi.fn(), + saveSharingDomainProjectMapping: vi.fn(), +})); + +vi.mock("../../../lib/notice", () => ({ showGlobalNotice: vi.fn() })); + +import * as api from "../../../lib/api"; +import { SharingDomainsPanel } from "./SharingDomainsPanel"; + +let mount: HTMLDivElement | null = null; + +function renderIntoDocument(content: ComponentChild) { + mount = document.createElement("div"); + document.body.appendChild(mount); + act(() => { + render(content, mount as HTMLDivElement); + }); + return mount; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +afterEach(() => { + if (mount) { + act(() => { + render(null, mount as HTMLDivElement); + }); + mount.remove(); + mount = null; + } + document.body.innerHTML = ""; + vi.clearAllMocks(); +}); + +describe("SharingDomainsPanel", () => { + it("shows local-only fallback and saves an explicit project Sharing domain", async () => { + vi.mocked(api.loadSharingDomainSettings) + .mockResolvedValueOnce({ + local_default_scope_id: "local-default", + mappings: [], + projects: [ + { + cwd: "/work/acme/api", + display_project: "api", + git_branch: "main", + git_remote: "https://example.test/acme/api.git", + identity_source: "git_remote", + latest_session_at: "2026-05-06T00:00:00Z", + mapping_id: null, + matched_pattern: null, + project: "api", + resolution_reason: "local_default", + resolved_scope_id: "local-default", + workspace_identity: "https://example.test/acme/api.git", + }, + ], + scopes: [ + { + authority_type: "local", + kind: "system", + label: "Local only", + scope_id: "local-default", + status: "active", + }, + { + authority_type: "coordinator", + kind: "team", + label: "Acme Work", + scope_id: "acme-work", + status: "active", + }, + ], + }) + .mockResolvedValueOnce({ + local_default_scope_id: "local-default", + mappings: [], + projects: [ + { + cwd: "/work/acme/api", + display_project: "api", + git_branch: "main", + git_remote: "https://example.test/acme/api.git", + identity_source: "git_remote", + latest_session_at: "2026-05-06T00:00:00Z", + mapping_id: 42, + matched_pattern: null, + project: "api", + resolution_reason: "exact_mapping", + resolved_scope_id: "acme-work", + workspace_identity: "https://example.test/acme/api.git", + }, + ], + scopes: [ + { + authority_type: "local", + kind: "system", + label: "Local only", + scope_id: "local-default", + status: "active", + }, + { + authority_type: "coordinator", + kind: "team", + label: "Acme Work", + scope_id: "acme-work", + status: "active", + }, + ], + }); + vi.mocked(api.saveSharingDomainProjectMapping).mockResolvedValue({ + id: 42, + priority: 0, + project_pattern: "api", + scope_id: "acme-work", + source: "user", + workspace_identity: "https://example.test/acme/api.git", + }); + + const root = renderIntoDocument(); + await flushEffects(); + + expect(root.textContent).toContain("Unmapped and unknown projects stay on Local only"); + expect(root.textContent).toContain("Current default: Local only · local-only fallback"); + + const select = root.querySelector("select"); + act(() => { + if (!select) throw new Error("select missing"); + select.value = "acme-work"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const saveButton = Array.from(root.querySelectorAll("button")).find( + (button) => button.textContent === "Save Sharing domain", + ); + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + await flushEffects(); + + expect(api.saveSharingDomainProjectMapping).toHaveBeenCalledWith({ + project_pattern: "api", + scope_id: "acme-work", + workspace_identity: "https://example.test/acme/api.git", + }); + expect(root.textContent).toContain("Current default: Acme Work · explicit project mapping"); + }); +}); diff --git a/packages/ui/src/tabs/settings/components/SharingDomainsPanel.tsx b/packages/ui/src/tabs/settings/components/SharingDomainsPanel.tsx new file mode 100644 index 00000000..4d07d66d --- /dev/null +++ b/packages/ui/src/tabs/settings/components/SharingDomainsPanel.tsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; +import { RadixSelect } from "../../../components/primitives/radix-select"; +import * as api from "../../../lib/api"; +import type { + ProjectScopeCandidate, + SharingDomainScope, + SharingDomainSettings, +} from "../../../lib/api/sync"; +import { showGlobalNotice } from "../../../lib/notice"; + +type Drafts = Record; + +function domainLabel(scope: SharingDomainScope): string { + const qualifier = scope.authority_type === "local" ? "local" : scope.authority_type; + return `${scope.label || scope.scope_id} · ${qualifier}`; +} + +function projectSubtitle(project: ProjectScopeCandidate): string { + if (project.git_remote) return `Matched by git remote · ${project.git_remote}`; + if (project.cwd) return `Matched by path · ${project.cwd}`; + return `Matched by ${project.identity_source} · ${project.workspace_identity}`; +} + +function resolutionLabel(reason: string): string { + switch (reason) { + case "exact_mapping": + return "explicit project mapping"; + case "pattern_mapping": + return "pattern mapping"; + case "explicit_override": + return "explicit override"; + default: + return "local-only fallback"; + } +} + +function scopeName(scopes: SharingDomainScope[], scopeId: string): string { + return scopes.find((scope) => scope.scope_id === scopeId)?.label || scopeId; +} + +function resetDrafts(settings: SharingDomainSettings): Drafts { + return Object.fromEntries( + settings.projects.map((project) => [project.workspace_identity, project.resolved_scope_id]), + ); +} + +export function SharingDomainsPanel() { + const [settings, setSettings] = useState(null); + const [drafts, setDrafts] = useState({}); + const [loading, setLoading] = useState(false); + const [savingKey, setSavingKey] = useState(null); + const [error, setError] = useState(null); + + const reload = async () => { + setLoading(true); + setError(null); + try { + const next = await api.loadSharingDomainSettings(); + setSettings(next); + setDrafts(resetDrafts(next)); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load Sharing domains"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void reload(); + }, []); + + const scopeOptions = useMemo( + () => + (settings?.scopes ?? []).map((scope) => ({ + value: scope.scope_id, + label: domainLabel(scope), + })), + [settings?.scopes], + ); + + const saveProject = async (project: ProjectScopeCandidate) => { + const scopeId = drafts[project.workspace_identity] ?? project.resolved_scope_id; + setSavingKey(project.workspace_identity); + setError(null); + try { + await api.saveSharingDomainProjectMapping({ + workspace_identity: project.workspace_identity, + project_pattern: project.display_project, + scope_id: scopeId, + }); + showGlobalNotice("Project Sharing domain updated. Device access grants are unchanged."); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to save Sharing domain mapping"); + } finally { + setSavingKey(null); + } + }; + + const resetProject = async (project: ProjectScopeCandidate) => { + if (!project.mapping_id) return; + setSavingKey(project.workspace_identity); + setError(null); + try { + await api.deleteSharingDomainProjectMapping(project.mapping_id); + showGlobalNotice("Project Sharing domain mapping removed. The next fallback now applies."); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to reset Sharing domain mapping"); + } finally { + setSavingKey(null); + } + }; + + return ( +
+

Sharing domains

+
+ Map known projects to their default Sharing domain. This changes local scope resolution for + future writes; it does not grant any peer or coordinator member access by itself. +
+
+ Unmapped and unknown projects stay on Local only until you assign a domain. +
+ {loading && !settings ?
Loading Sharing domains…
: null} + {error ?
{error}
: null} + {settings && settings.projects.length === 0 ? ( +
No projects with memories are available yet.
+ ) : null} + {settings?.projects.map((project, index) => { + const fieldId = `sharingDomainProject-${index}`; + const currentValue = drafts[project.workspace_identity] ?? project.resolved_scope_id; + const saving = savingKey === project.workspace_identity; + const unchanged = currentValue === project.resolved_scope_id; + const currentScopeName = scopeName(settings.scopes, project.resolved_scope_id); + const canRemoveProjectMapping = + project.mapping_id != null && project.resolution_reason === "exact_mapping"; + return ( +
+ +
{projectSubtitle(project)}
+ + setDrafts((prev) => ({ ...prev, [project.workspace_identity]: value })) + } + options={scopeOptions} + placeholder="Choose Sharing domain" + triggerClassName="settings-select-trigger" + value={currentValue} + viewportClassName="settings-select-viewport" + /> +
+ Current default: {currentScopeName} · {resolutionLabel(project.resolution_reason)} +
+
+ + +
+
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/tabs/settings/components/SyncPanel.tsx b/packages/ui/src/tabs/settings/components/SyncPanel.tsx index b57ed52d..597ba1b5 100644 --- a/packages/ui/src/tabs/settings/components/SyncPanel.tsx +++ b/packages/ui/src/tabs/settings/components/SyncPanel.tsx @@ -3,6 +3,7 @@ import type { SettingsPanelProps } from "../data/types"; import { SettingsHint } from "./SettingsHint"; import { SettingsSectionIntro } from "./SettingsSectionIntro"; import { SettingsSwitchRow } from "./SettingsSwitchRow"; +import { SharingDomainsPanel } from "./SharingDomainsPanel"; export function SyncPanel({ values, @@ -113,6 +114,7 @@ export function SyncPanel({ /> + ); } diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 605d8e44..129df7e0 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -5377,6 +5377,121 @@ describe("viewer-server", () => { } }); + it("updates local Sharing domain project mappings without granting membership", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + const sessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "project domain candidate", + metadata: {}, + }); + 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 ('acme-work', 'Acme Work', 'team', 'coordinator', 1, 'active', ?, ?)`, + ) + .run(now, now); + + const settingsRes = await app.request("/api/sync/sharing-domains/settings"); + expect(settingsRes.status).toBe(200); + const settings = (await settingsRes.json()) as { + scopes: Array<{ scope_id: string }>; + projects: Array<{ + workspace_identity: string; + display_project: string; + resolved_scope_id: string; + }>; + }; + expect(settings.scopes.map((scope) => scope.scope_id)).toEqual( + expect.arrayContaining(["local-default", "acme-work"]), + ); + const project = settings.projects.find((item) => item.display_project === "test-project"); + expect(project).toMatchObject({ resolved_scope_id: "local-default" }); + + const malformedRes = await app.request("/api/sync/sharing-domains/project-mappings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspace_identity: project?.workspace_identity, + project_pattern: project?.display_project, + scope_id: ["acme-work"], + }), + }); + expect(malformedRes.status).toBe(400); + + const emptyScopeRes = await app.request("/api/sync/sharing-domains/project-mappings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspace_identity: project?.workspace_identity, + project_pattern: project?.display_project, + scope_id: "", + }), + }); + expect(emptyScopeRes.status).toBe(400); + + const invalidScopeRes = await app.request("/api/sync/sharing-domains/project-mappings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspace_identity: project?.workspace_identity, + project_pattern: project?.display_project, + scope_id: "missing-work-domain", + }), + }); + expect(invalidScopeRes.status).toBe(400); + + const saveRes = await app.request("/api/sync/sharing-domains/project-mappings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspace_identity: project?.workspace_identity, + project_pattern: project?.display_project, + scope_id: "acme-work", + }), + }); + expect(saveRes.status).toBe(200); + const saveBody = (await saveRes.json()) as { mapping: { id: number; scope_id: string } }; + expect(saveBody.mapping.scope_id).toBe("acme-work"); + + const updatedRes = await app.request("/api/sync/sharing-domains/settings"); + const updated = (await updatedRes.json()) as { + projects: Array<{ + display_project: string; + resolved_scope_id: string; + mapping_id: number; + }>; + }; + expect( + updated.projects.find((item) => item.display_project === "test-project"), + ).toMatchObject({ + resolved_scope_id: "acme-work", + mapping_id: saveBody.mapping.id, + }); + const memberships = store.db + .prepare("SELECT COUNT(*) AS n FROM scope_memberships") + .get() as { + n: number; + }; + expect(memberships.n).toBe(0); + + const deleteRes = await app.request( + `/api/sync/sharing-domains/project-mappings/${saveBody.mapping.id}`, + { method: "DELETE" }, + ); + expect(deleteRes.status).toBe(200); + } finally { + cleanup(); + } + }); + it("runs sync for all peers through the compatibility sync route", async () => { const configPath = join(mkdtempSync(join(tmpdir(), "codemem-config-test-")), "config.json"); const prevConfig = process.env.CODEMEM_CONFIG; diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index 1c6e3b26..483d8414 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -43,6 +43,7 @@ import { coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, DEFAULT_TIME_WINDOW_S, + deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, type FilterReplicationSkipped, @@ -52,10 +53,14 @@ import { getSemanticIndexDiagnostics, getSyncResetState, type InboundScopeRejectionPeerSummary, + LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, + listProjectScopeCandidates, + listProjectScopeSettingsMappings, + listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, @@ -75,6 +80,7 @@ import { syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, + upsertProjectScopeSettingsMapping, verifySignature, } from "@codemem/core"; import { and, count, desc, eq, max, ne } from "drizzle-orm"; @@ -260,6 +266,13 @@ function optionalViewerString(body: Record, key: string): strin return String(value).trim() || null; } +function optionalViewerStrictString(body: Record, key: string): string | null { + const value = body[key]; + if (value == null) return null; + if (typeof value !== "string") throw new Error(`${key} must be string`); + return value.trim() || null; +} + function optionalViewerNumber(body: Record, key: string): number | null { const value = body[key]; if (value == null || value === "") return null; @@ -2194,6 +2207,51 @@ export function syncRoutes( }); }); + app.get("/api/sync/sharing-domains/settings", (c) => { + const store = getStore(); + const limit = Math.max(1, queryInt(c.req.query("limit"), 250)); + return c.json({ + scopes: listSharingDomainSettingsScopes(store.db), + mappings: listProjectScopeSettingsMappings(store.db), + projects: listProjectScopeCandidates(store.db, { limit }), + local_default_scope_id: LOCAL_DEFAULT_SCOPE_ID, + }); + }); + + app.put("/api/sync/sharing-domains/project-mappings", async (c) => { + const store = getStore(); + const body = await parseViewerJsonBody(c); + if (!body) return c.json({ error: "invalid json" }, 400); + const id = optionalViewerNumber(body, "id"); + const priority = optionalViewerNumber(body, "priority"); + if (Number.isNaN(id)) return c.json({ error: "id must be number" }, 400); + if (Number.isNaN(priority)) return c.json({ error: "priority must be number" }, 400); + try { + const mapping = upsertProjectScopeSettingsMapping(store.db, { + id, + workspace_identity: optionalViewerStrictString(body, "workspace_identity"), + project_pattern: optionalViewerStrictString(body, "project_pattern"), + scope_id: optionalViewerStrictString(body, "scope_id") ?? "", + priority, + source: optionalViewerStrictString(body, "source") ?? "user", + }); + return c.json({ ok: true, mapping }); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); + } + }); + + app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => { + const store = getStore(); + const id = Number(c.req.param("id")); + try { + const deleted = deleteProjectScopeSettingsMapping(store.db, id); + return c.json({ ok: true, deleted }); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); + } + }); + app.post("/api/sync/peers/identity", async (c) => { const store = getStore(); ensureLocalActorRecord(store);