From 6e10f2e5d3fbd2d2fdd017b19ab96ae3e0649c31 Mon Sep 17 00:00:00 2001 From: Adam Kunicki Date: Thu, 23 Jul 2026 14:52:37 +0200 Subject: [PATCH] fix(sync): show truthful primary status --- packages/ui/src/lib/state.ts | 11 +- .../sync/components/team-sync-panel.test.tsx | 160 ++++++ .../tabs/sync/components/team-sync-panel.tsx | 27 +- packages/ui/src/tabs/sync/helpers.test.ts | 9 + packages/ui/src/tabs/sync/index.test.ts | 19 +- packages/ui/src/tabs/sync/index.ts | 12 +- .../team-sync/render/render-team-sync.test.ts | 116 +++++ .../sync/team-sync/render/render-team-sync.ts | 85 ++-- packages/ui/src/tabs/sync/view-model.test.ts | 468 ++++++++++++++++++ packages/ui/src/tabs/sync/view-model.ts | 10 + .../tabs/sync/view-model/primary-status.ts | 292 +++++++++++ .../tabs/sync/view-model/sync-view-model.ts | 29 +- packages/ui/src/tabs/sync/view-model/types.ts | 75 +++ packages/viewer-server/src/index.test.ts | 15 + packages/viewer-server/src/routes/sync.ts | 4 + 15 files changed, 1281 insertions(+), 51 deletions(-) create mode 100644 packages/ui/src/tabs/sync/components/team-sync-panel.test.tsx create mode 100644 packages/ui/src/tabs/sync/team-sync/render/render-team-sync.test.ts create mode 100644 packages/ui/src/tabs/sync/view-model/primary-status.ts diff --git a/packages/ui/src/lib/state.ts b/packages/ui/src/lib/state.ts index 9e92bba2..f5f92a1d 100644 --- a/packages/ui/src/lib/state.ts +++ b/packages/ui/src/lib/state.ts @@ -1,6 +1,10 @@ /* Global application state — shared across tabs. */ -import type { UiSyncViewModel } from "../tabs/sync/view-model"; +import type { + TeamSyncDaemonState, + TeamSyncPresenceState, + UiSyncViewModel, +} from "../tabs/sync/view-model"; import type { ShareOperationReadModel } from "./api/sync"; export type RefreshState = "idle" | "refreshing" | "paused" | "error"; @@ -79,7 +83,8 @@ export interface CachedRawEventsPayload { } export interface CachedSyncStatus { - daemon_state?: string; + daemon_state?: TeamSyncDaemonState; + daemon_running?: boolean; enabled?: boolean; last_sync_at?: string; last_sync_at_utc?: string; @@ -158,7 +163,7 @@ export interface CachedSyncCoordinator { groups?: unknown[]; coordinator_url?: string | null; discovered_devices?: DiscoveredDevice[]; - presence_status?: string; + presence_status?: TeamSyncPresenceState; paired_peer_count?: number; } diff --git a/packages/ui/src/tabs/sync/components/team-sync-panel.test.tsx b/packages/ui/src/tabs/sync/components/team-sync-panel.test.tsx new file mode 100644 index 00000000..5652c107 --- /dev/null +++ b/packages/ui/src/tabs/sync/components/team-sync-panel.test.tsx @@ -0,0 +1,160 @@ +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import { deriveTeamSyncPrimaryStatus } from "../view-model"; +import { TeamSyncPanel } from "./team-sync-panel"; + +let mount: HTMLDivElement | null = null; + +function renderPrimaryStatus(primaryStatus: ReturnType) { + mount = document.createElement("div"); + document.body.appendChild(mount); + act(() => { + render( + null} + onAttentionAction={async () => {}} + onDenyJoinRequest={async () => null} + onInspectConflict={() => {}} + onRemoveConflict={async () => null} + onReviewDiscoveredDevice={async () => null} + pendingJoinRequests={[]} + presenceStatus="posted" + primaryStatus={primaryStatus} + />, + mount as HTMLDivElement, + ); + }); + return mount; +} + +afterEach(() => { + if (mount) { + act(() => render(null, mount as HTMLDivElement)); + mount.remove(); + mount = null; + } + document.body.innerHTML = ""; +}); + +describe("TeamSyncPanel primary status", () => { + const coordinator: NonNullable[0]["coordinator"]> = + { + configured: true, + sync_enabled: true, + groups: ["Acme"], + presence_status: "posted", + }; + const healthyPeer = { + peer_device_id: "peer-healthy", + status: { peer_state: "online", sync_status: "ok" }, + }; + const cases: Array<[string, Parameters[0], string, string]> = + [ + [ + "posted presence with sync disabled", + { + status: { enabled: false, daemon_state: "disabled" }, + coordinator, + peers: [healthyPeer], + }, + "Sync off", + "turn on sync", + ], + [ + "pending_setup", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator, + shareOperations: [ + { projects: [{ display_name: "Roadmap" }], lifecycle: { state: "pending_setup" } }, + ], + }, + "Setup pending", + "Roadmap", + ], + [ + "owner needs_attention", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator, + shareOperations: [ + { projects: [{ display_name: "Roadmap" }], lifecycle: { state: "needs_attention" } }, + ], + }, + "Needs attention", + "retry setup", + ], + [ + "trust pending", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator, + peers: [{ peer_device_id: "peer-pending", status: { peer_state: "waiting" } }], + }, + "Pairing needed", + "both devices", + ], + [ + "enrolled and reachable only", + { status: { enabled: true, daemon_state: "ok" }, coordinator }, + "Reachable", + "Pair and approve", + ], + [ + "not enrolled", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator: { ...coordinator, presence_status: "not_enrolled" }, + }, + "Not enrolled", + "Paste a Team invite", + ], + [ + "configured unreachable", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator: { ...coordinator, presence_status: "unknown" }, + }, + "Unreachable", + "Check the coordinator connection", + ], + [ + "unconfigured setup", + { + status: { enabled: true, daemon_state: "ok" }, + coordinator: { configured: false, sync_enabled: true, groups: [] }, + }, + "Setup needed", + "configure a coordinator", + ], + ]; + + it.each(cases)("renders one concrete next action for %s", (_name, input, badge, action) => { + const root = renderPrimaryStatus(deriveTeamSyncPrimaryStatus(input)); + + expect(root.textContent).toContain(badge); + expect(root.textContent).toContain(action); + expect(root.querySelectorAll("[data-primary-sync-state]")).toHaveLength(1); + expect(root.textContent).not.toContain("No urgent team work"); + expect(root.textContent).not.toContain(deriveTeamSyncPrimaryStatus(input).meta); + }); + + it("reserves no-urgent copy for healthy enabled data-plane sync", () => { + const root = renderPrimaryStatus( + deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator, + peers: [healthyPeer], + }), + ); + + expect(root.textContent).toContain("No urgent team work right now."); + expect(root.querySelector("[data-primary-sync-state]")).toBeNull(); + }); +}); diff --git a/packages/ui/src/tabs/sync/components/team-sync-panel.tsx b/packages/ui/src/tabs/sync/components/team-sync-panel.tsx index f73d425c..6b05edbd 100644 --- a/packages/ui/src/tabs/sync/components/team-sync-panel.tsx +++ b/packages/ui/src/tabs/sync/components/team-sync-panel.tsx @@ -2,7 +2,7 @@ import type { ComponentChildren } from "preact"; import { createPortal } from "preact/compat"; import { useState } from "preact/hooks"; import { state } from "../../../lib/state"; -import type { UiSyncAttentionItem } from "../view-model"; +import type { UiSyncAttentionItem, UiTeamSyncPrimaryStatus } from "../view-model"; import type { SyncActionFeedback } from "./sync-inline-feedback"; import { SyncInlineFeedback } from "./sync-inline-feedback"; @@ -49,6 +49,7 @@ type TeamSyncPanelProps = { onReviewDiscoveredDevice: (row: TeamSyncDiscoveredRow) => Promise; pendingJoinRequests: TeamSyncPendingJoinRequest[]; presenceStatus: string; + primaryStatus: UiTeamSyncPrimaryStatus; }; function SectionHeading({ count, label }: { count?: number; label: string }) { @@ -280,7 +281,7 @@ function ActionContent(props: TeamSyncPanelProps) { const hasAttentionItems = props.actionItems.length > 0; const hasOtherActionableWork = props.actionableCount > props.actionItems.length; const showNextStepsLabel = - hasAttentionItems || hasOtherActionableWork || props.presenceStatus !== "posted"; + hasAttentionItems || hasOtherActionableWork || props.primaryStatus.state !== "healthy"; return ( <> @@ -288,33 +289,31 @@ function ActionContent(props: TeamSyncPanelProps) { {showNextStepsLabel ? ( Start here when something needs review, re-pairing, or approval. ) : null} + {props.primaryStatus.nextAction ? ( +
+
+ {props.primaryStatus.badgeLabel} + {props.primaryStatus.nextAction} +
+
+ ) : null} {hasAttentionItems ? props.actionItems.map((item) => ( )) : null} - {!hasAttentionItems && !hasOtherActionableWork && props.presenceStatus === "posted" ? ( + {!hasAttentionItems && !hasOtherActionableWork && props.primaryStatus.state === "healthy" ? (
No urgent team work right now.
) : null} - {!hasAttentionItems && hasOtherActionableWork && props.presenceStatus === "posted" ? ( + {!hasAttentionItems && hasOtherActionableWork ? (
More team follow-up is listed below when you are ready.
) : null} - {!hasAttentionItems && props.presenceStatus === "not_enrolled" ? ( -
-
- This device needs team enrollment - - Import an invite or ask an admin to enroll it. - -
-
- ) : null} ); } diff --git a/packages/ui/src/tabs/sync/helpers.test.ts b/packages/ui/src/tabs/sync/helpers.test.ts index 0d65c620..16547c26 100644 --- a/packages/ui/src/tabs/sync/helpers.test.ts +++ b/packages/ui/src/tabs/sync/helpers.test.ts @@ -11,6 +11,12 @@ import { const originalActors = state.lastSyncActors; const originalPeers = state.lastSyncPeers; const originalViewModel = state.lastSyncViewModel; +const healthyPrimaryStatus = { + state: "healthy" as const, + badgeLabel: "Healthy", + meta: "Sync is healthy.", + nextAction: null, +}; afterEach(() => { state.lastSyncActors = originalActors; @@ -43,6 +49,7 @@ describe("buildActorSelectOptions", () => { ]; state.lastSyncPeers = []; state.lastSyncViewModel = { + primaryStatus: healthyPrimaryStatus, summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 }, attentionItems: [], duplicatePeople: [ @@ -68,6 +75,7 @@ describe("buildActorSelectOptions", () => { ]; state.lastSyncPeers = []; state.lastSyncViewModel = { + primaryStatus: healthyPrimaryStatus, summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 }, attentionItems: [], duplicatePeople: [ @@ -90,6 +98,7 @@ describe("buildActorSelectOptions", () => { state.lastSyncActors = [{ actor_id: "actor-local", display_name: "Adam", is_local: true }]; state.lastSyncPeers = []; state.lastSyncViewModel = { + primaryStatus: healthyPrimaryStatus, summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 }, attentionItems: [], duplicatePeople: [], diff --git a/packages/ui/src/tabs/sync/index.test.ts b/packages/ui/src/tabs/sync/index.test.ts index 0d529919..e495c4c9 100644 --- a/packages/ui/src/tabs/sync/index.test.ts +++ b/packages/ui/src/tabs/sync/index.test.ts @@ -163,7 +163,19 @@ describe("loadSyncData", () => { const { state } = await import("../../lib/state"); const { loadSyncData } = await import("./index"); vi.mocked(api.loadSyncStatus).mockResolvedValue({ - peers: [{ peer_device_id: "peer-still-visible" }], + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: { + configured: true, + sync_enabled: true, + groups: ["Acme"], + presence_status: "posted", + }, + peers: [ + { + peer_device_id: "peer-still-visible", + status: { peer_state: "online", sync_status: "ok" }, + }, + ], sharing_review: [], attempts: [], legacy_devices: [], @@ -175,5 +187,10 @@ describe("loadSyncData", () => { expect(state.lastSyncPeers.map((peer) => peer.peer_device_id)).toEqual(["peer-still-visible"]); expect(state.shareOperationsLoadError).toBe(true); + expect(state.lastSyncViewModel?.primaryStatus).toMatchObject({ + state: "needs-attention", + badgeLabel: "Refresh needed", + nextAction: expect.stringMatching(/Refresh.*retry/), + }); }); }); diff --git a/packages/ui/src/tabs/sync/index.ts b/packages/ui/src/tabs/sync/index.ts index c1cde384..bea37978 100644 --- a/packages/ui/src/tabs/sync/index.ts +++ b/packages/ui/src/tabs/sync/index.ts @@ -32,7 +32,7 @@ import { renderTeamSync, setLoadSyncData as setTeamSyncLoadData, } from "./team-sync"; -import { deriveSyncViewModel } from "./view-model"; +import { deriveSyncViewModel, type TeamSyncReconciliationState } from "./view-model"; /* ── Re-exports consumed by app.ts ───────────────────────── */ @@ -51,6 +51,12 @@ type SyncStatusResponseLike = { legacy_shared_review?: Record | null; attempts?: unknown[]; legacy_devices?: unknown[]; + recipient_policy_reconciliation?: { + items?: Array<{ + canonicalProjectIdentity?: string; + state?: TeamSyncReconciliationState; + }>; + } | null; }; type SyncActorListResponseLike = { @@ -218,6 +224,10 @@ export async function loadSyncData() { actors: state.lastSyncActors, peers: state.lastSyncPeers, coordinator: state.lastSyncCoordinator, + status: statusPayload, + shareOperations: state.lastShareOperations, + shareOperationsLoadError, + reconciliation: payload.recipient_policy_reconciliation, duplicatePersonDecisions: state.lastSyncDuplicatePersonDecisions, }); renderSyncStatus(); diff --git a/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.test.ts b/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.test.ts new file mode 100644 index 00000000..1255cebc --- /dev/null +++ b/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { UiTeamSyncPrimaryStatus } from "../../view-model"; +import { renderTeamSyncPrimaryStatus } from "./render-team-sync"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderStatus(primaryStatus: UiTeamSyncPrimaryStatus) { + const badge = document.createElement("span"); + const meta = document.createElement("div"); + renderTeamSyncPrimaryStatus(badge, meta, primaryStatus); + return { badge, meta }; +} + +describe("renderTeamSyncPrimaryStatus", () => { + it.each([ + [ + "disabled", + { + state: "disabled", + badgeLabel: "Sync off", + meta: "Team: Acme. Coordinator presence does not move Project data while sync is off.", + nextAction: "Open Settings and turn on sync.", + }, + "sync-online-badge sync-online-offline", + ], + [ + "reachable", + { + state: "reachable", + badgeLabel: "Reachable", + meta: "Team: Acme. The coordinator is reachable, but healthy sync is not confirmed.", + nextAction: "Pair and approve a device.", + }, + "sync-online-badge sync-online-offline", + ], + [ + "healthy", + { + state: "healthy", + badgeLabel: "Healthy", + meta: "Team: Acme. Sync is healthy.", + nextAction: null, + }, + "sync-online-badge", + ], + [ + "needs attention", + { + state: "needs-attention", + badgeLabel: "Needs attention", + meta: "Team: Acme. Exact-Project setup has not converged.", + nextAction: "Open Project sharing below and retry setup for Roadmap.", + }, + "sync-online-badge sync-online-error", + ], + [ + "pending setup", + { + state: "pending-setup", + badgeLabel: "Setup pending", + meta: "Team: Acme. Exact-Project setup is still pending.", + nextAction: "Keep both devices online, then sync again.", + }, + "sync-online-badge sync-online-offline", + ], + [ + "trust blocked", + { + state: "trust-blocked", + badgeLabel: "Pairing needed", + meta: "Team: Acme. A device still needs two-way trust.", + nextAction: "Finish pairing or approval on both devices.", + }, + "sync-online-badge sync-online-error", + ], + [ + "not enrolled", + { + state: "not-enrolled", + badgeLabel: "Not enrolled", + meta: "Team: Acme. This device is not enrolled with the coordinator.", + nextAction: "Paste a Team invite below.", + }, + "sync-online-badge sync-online-offline", + ], + [ + "configured unreachable", + { + state: "unreachable", + badgeLabel: "Unreachable", + meta: "Team: Acme. The coordinator is not currently reachable.", + nextAction: "Check the coordinator connection.", + }, + "sync-online-badge sync-online-error", + ], + [ + "unconfigured setup needed", + { + state: "unreachable", + badgeLabel: "Setup needed", + meta: "Configure or join a Team before expecting Project data to sync.", + nextAction: "Configure a coordinator in Advanced settings.", + }, + "sync-online-badge sync-online-error", + ], + ] as const)("renders %s badge and metadata", (_name, status, expectedClass) => { + const { badge, meta } = renderStatus(status); + + expect(badge.textContent).toBe(status.badgeLabel); + expect(badge.className).toBe(expectedClass); + expect(meta.textContent).toBe(status.meta); + expect(meta.textContent).not.toContain(status.nextAction ?? "__no_action__"); + }); +}); diff --git a/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.ts b/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.ts index 41922e92..a6912954 100644 --- a/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.ts +++ b/packages/ui/src/tabs/sync/team-sync/render/render-team-sync.ts @@ -37,9 +37,11 @@ import { import { deriveCoordinatorApprovalSummary, deriveCoordinatorSetupBlocker, + deriveTeamSyncPrimaryStatus, resolveFriendlyDeviceName, SYNC_TERMINOLOGY, shouldShowCoordinatorReviewAction, + type UiTeamSyncPrimaryStatus, } from "../../view-model"; import { teamSyncState } from "../data/state"; import { clearContent, pulseAttentionTarget, syncScrollBehavior } from "../helpers/dom"; @@ -54,29 +56,53 @@ import { const TEAM_SYNC_ACTIONS_MOUNT_ID = "syncTeamActionsMount"; -// Top-of-card online badge. Hidden when the coordinator isn't configured yet, -// otherwise mirrors the coordinator presence_status so operators can see at a -// glance whether this device is reaching the coordinator. -function updateSyncOnlineBadge(badge: HTMLElement, configured: boolean, presenceStatus: string) { - if (!configured) { - badge.hidden = true; - badge.textContent = ""; - badge.className = "sync-online-badge"; - return; - } +export function renderTeamSyncPrimaryStatus( + badge: HTMLElement, + meta: HTMLElement, + primaryStatus: UiTeamSyncPrimaryStatus, +) { badge.hidden = false; - if (presenceStatus === "posted") { + badge.textContent = primaryStatus.badgeLabel; + meta.textContent = primaryStatus.meta; + if (primaryStatus.state === "healthy") { badge.className = "sync-online-badge"; - badge.textContent = "Online"; - } else if (presenceStatus === "not_enrolled") { + } else if ( + primaryStatus.state === "disabled" || + primaryStatus.state === "pending-setup" || + primaryStatus.state === "not-enrolled" || + primaryStatus.state === "reachable" + ) { badge.className = "sync-online-badge sync-online-offline"; - badge.textContent = "Not enrolled"; } else { badge.className = "sync-online-badge sync-online-error"; - badge.textContent = "Offline"; } } +function renderPrimaryActionOnly(actions: HTMLElement, primaryStatus: UiTeamSyncPrimaryStatus) { + const actionMount = document.createElement("div"); + actionMount.id = TEAM_SYNC_ACTIONS_MOUNT_ID; + actions.appendChild(actionMount); + renderIntoSyncMount( + actionMount, + h(TeamSyncPanel, { + actionItems: [], + actionableCount: 0, + discoveredListMount: null, + discoveredRows: [], + joinRequestsMount: null, + onApproveJoinRequest: async () => null, + onAttentionAction: async () => {}, + onDenyJoinRequest: async () => null, + onInspectConflict: () => {}, + onRemoveConflict: async () => null, + onReviewDiscoveredDevice: async () => null, + pendingJoinRequests: [], + presenceStatus: "unknown", + primaryStatus, + }), + ); +} + function teardownTeamSyncRender(actions: HTMLElement | null, targets: Array) { const mount = document.getElementById(TEAM_SYNC_ACTIONS_MOUNT_ID) as HTMLElement | null; if (mount) { @@ -115,6 +141,13 @@ export function renderTeamSync() { const coordinator = state.lastSyncCoordinator; const syncView = state.lastSyncViewModel || { + primaryStatus: deriveTeamSyncPrimaryStatus({ + status: state.lastSyncStatus, + coordinator, + peers: state.lastSyncPeers, + shareOperations: state.lastShareOperations, + shareOperationsLoadError: state.shareOperationsLoadError, + }), summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 }, duplicatePeople: [], attentionItems: [], @@ -229,21 +262,20 @@ export function renderTeamSync() { const configured = Boolean(coordinator?.configured); const setupBlocker = deriveCoordinatorSetupBlocker(coordinator); - meta.textContent = configured - ? `Team: ${(coordinator.groups || []).join(", ") || "none"}` - : setupBlocker?.message || - "Start by joining an existing team or creating one, then connect people and devices."; meta.title = configured ? String(coordinator.coordinator_url || "").trim() : ""; const onlineBadge = document.getElementById("syncOnlineBadge"); if (onlineBadge) { - updateSyncOnlineBadge(onlineBadge, configured, String(coordinator?.presence_status || "")); + renderTeamSyncPrimaryStatus(onlineBadge, meta, syncView.primaryStatus); + } else { + meta.textContent = syncView.primaryStatus.meta; } if (!configured) { teardownTeamSyncRender(actions, [joinRequests, discoveredList]); setupPanel.hidden = false; - actions.hidden = true; + actions.hidden = false; + renderPrimaryActionOnly(actions, syncView.primaryStatus); if (joinRequests) joinRequests.hidden = true; if (discoveredPanel) discoveredPanel.hidden = true; return; @@ -387,16 +419,6 @@ export function renderTeamSync() { ).length; const actionableCount = attentionItems.length + pendingJoinRequests.length + discoveredActionableCount; - const teamLabel = (coordinator.groups || []).join(", ") || "none"; - meta.textContent = - presenceStatus === "posted" - ? actionableCount > 0 - ? `Team: ${teamLabel}. Start with the next step below, then scan the current team status.` - : `Team: ${teamLabel}. Team status and device details are below.` - : presenceStatus === "not_enrolled" - ? `Team: ${teamLabel}. Enroll this device first, then return here to review the rest of the team.` - : `Team: ${teamLabel}. Fix the current sync issue first, then use the rest of this card to verify the team state.`; - if (discoveredPanel) { discoveredPanel.hidden = visibleDiscoveredRows.length === 0 && !state.syncDiscoveredFeedback; } @@ -574,6 +596,7 @@ export function renderTeamSync() { }, pendingJoinRequests, presenceStatus, + primaryStatus: syncView.primaryStatus, }), ); } diff --git a/packages/ui/src/tabs/sync/view-model.test.ts b/packages/ui/src/tabs/sync/view-model.test.ts index 8d3c577d..890c44d5 100644 --- a/packages/ui/src/tabs/sync/view-model.test.ts +++ b/packages/ui/src/tabs/sync/view-model.test.ts @@ -12,6 +12,7 @@ import { derivePeerTrustSummary, derivePeerUiStatus, deriveSyncViewModel, + deriveTeamSyncPrimaryStatus, deriveVisiblePeopleActors, deviceNeedsFriendlyName, resolveFriendlyDeviceName, @@ -111,6 +112,473 @@ describe("deriveCoordinatorSetupBlocker", () => { }); }); +describe("deriveTeamSyncPrimaryStatus", () => { + const healthyPeer = { + peer_device_id: "peer-healthy", + status: { peer_state: "online", sync_status: "ok" }, + }; + const postedCoordinator: NonNullable< + Parameters[0]["coordinator"] + > = { + configured: true, + sync_enabled: true, + groups: ["Acme"], + presence_status: "posted", + }; + + it("keeps sync disabled above posted presence and every lower-priority signal", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: false, daemon_state: "disabled" }, + coordinator: postedCoordinator, + peers: [healthyPeer], + shareOperations: [ + { + projects: [{ display_name: "Roadmap" }], + lifecycle: { state: "needs_attention" }, + }, + ], + }); + + expect(view).toMatchObject({ + state: "disabled", + badgeLabel: "Sync off", + nextAction: expect.stringContaining("turn on sync"), + }); + expect(view.badgeLabel).not.toBe("Online"); + }); + + it("keeps pending_setup above trust, coordinator presence, and healthy peers", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok" }, + coordinator: postedCoordinator, + peers: [healthyPeer], + shareOperations: [ + { + projects: [{ display_name: "Roadmap" }], + lifecycle: { state: "pending_setup" }, + }, + ], + }); + + expect(view).toMatchObject({ + state: "pending-setup", + badgeLabel: "Setup pending", + nextAction: expect.stringContaining("Roadmap"), + }); + }); + + it("keeps owner needs_attention above pending reconciliation and healthy sync", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok" }, + coordinator: postedCoordinator, + peers: [ + healthyPeer, + { peer_device_id: "peer-trust-pending", status: { peer_state: "waiting" } }, + ], + shareOperations: [ + { + person: { display_name: "Project owner" }, + projects: [{ display_name: "Roadmap" }], + lifecycle: { state: "needs_attention", primary_action: { kind: "retry_setup" } }, + }, + ], + reconciliation: { + items: [{ canonicalProjectIdentity: "Roadmap", state: "pending" }], + }, + }); + + expect(view).toMatchObject({ + state: "needs-attention", + badgeLabel: "Needs attention", + nextAction: "Open Project sharing below and retry setup for Roadmap.", + }); + }); + + it("routes reconciliation-only failures to Sharing management", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + reconciliation: { + items: [{ canonicalProjectIdentity: "git:roadmap", state: "needs_attention" }], + }, + }); + + expect(view).toMatchObject({ + state: "needs-attention", + badgeLabel: "Needs attention", + nextAction: + "Open Sharing and use Manage projects for the affected recipient, then run Sync now again.", + }); + expect(view.nextAction).not.toContain("Project sharing below"); + expect(view.nextAction).not.toContain("git:roadmap"); + }); + + it("keeps pending setup above trust blockers, healthy peers, and posted presence", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok" }, + coordinator: postedCoordinator, + peers: [healthyPeer, { peer_device_id: "peer-pending", status: { peer_state: "waiting" } }], + reconciliation: { + items: [{ canonicalProjectIdentity: "Roadmap", state: "pending" }], + }, + }); + + expect(view).toMatchObject({ + state: "pending-setup", + badgeLabel: "Setup pending", + }); + }); + + it("keeps a revoking Project operation above healthy sync", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + shareOperations: [ + { + projects: [{ display_name: "Roadmap" }], + lifecycle: { state: "revoking" }, + }, + ], + }); + + expect(view).toMatchObject({ + state: "pending-setup", + badgeLabel: "Removal pending", + nextAction: expect.stringContaining("removing future access for Roadmap"), + }); + }); + + it("keeps a trust blocker above a separate otherwise-healthy peer and posted presence", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer, { peer_device_id: "peer-pending", status: { peer_state: "waiting" } }], + }); + + expect(view).toMatchObject({ + state: "trust-blocked", + badgeLabel: "Pairing needed", + nextAction: expect.stringContaining("both devices"), + }); + }); + + it("labels enrolled and reachable coordinator presence without claiming healthy sync", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok" }, + coordinator: postedCoordinator, + }); + + expect(view).toMatchObject({ + state: "reachable", + badgeLabel: "Reachable", + nextAction: expect.stringContaining("Pair and approve"), + }); + expect(`${view.badgeLabel} ${view.meta}`).not.toContain("Online"); + }); + + it("reports healthy only for an enabled trusted data plane without higher blockers", () => { + expect( + deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + reconciliation: { + items: [{ canonicalProjectIdentity: "Roadmap", state: "active" }], + }, + }), + ).toMatchObject({ state: "healthy", badgeLabel: "Healthy", nextAction: null }); + }); + + it("keeps a trusted peer with pending Space delivery out of Healthy", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [ + { + ...healthyPeer, + per_scope_sync: [ + { + bootstrapped: false, + label: "Roadmap", + last_applied_cursor: null, + scope_id: "roadmap", + }, + ], + }, + ], + }); + + expect(view).toMatchObject({ state: "reachable", badgeLabel: "Reachable" }); + expect(view.nextAction).toContain("Run Sync now"); + }); + + it.each([ + ["not enrolled", "not_enrolled", "not-enrolled", "Not enrolled"], + ["coordinator error", "error", "unreachable", "Unreachable"], + ["unknown coordinator presence", "unknown", "unreachable", "Unreachable"], + ] as const)("prioritizes %s over peer-connectivity guidance", (_label, presenceStatus, expectedState, expectedBadge) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: { ...postedCoordinator, presence_status: presenceStatus }, + peers: [{ peer_device_id: "peer-offline", status: { peer_state: "offline" } }], + }); + + expect(view).toMatchObject({ state: expectedState, badgeLabel: expectedBadge }); + expect(view.nextAction).not.toBeNull(); + expect(view.badgeLabel).not.toBe("Check devices"); + }); + + it.each([ + ["fresh ping", { peer_state: "online", ping_status: "ok", fresh: true }], + ["online presence", { peer_state: "online", fresh: true }], + ] as const)("directs %s without a successful sync to run sync", (_name, peerStatus) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [{ peer_device_id: "peer-no-sync", status: peerStatus }], + }); + + expect(view).toMatchObject({ state: "reachable", badgeLabel: "Reachable" }); + expect(view.nextAction).toContain("Run Sync now"); + expect(view.nextAction).not.toContain("Pair and approve"); + }); + + it("fails closed when Project sharing lifecycle status could not be loaded", () => { + const view = deriveSyncViewModel({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + shareOperationsLoadError: true, + }); + + expect(view.primaryStatus).toMatchObject({ + state: "needs-attention", + badgeLabel: "Refresh needed", + nextAction: expect.stringMatching(/Refresh.*retry/), + }); + }); + + it("tells the owner to send a pending invitation", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + shareOperations: [ + { + projects: [{ display_name: "Roadmap" }], + lifecycle: { + state: "waiting_for_acceptance", + primary_action: { kind: "copy_invite" }, + }, + }, + ], + }); + + expect(view).toMatchObject({ + state: "pending-setup", + nextAction: "Copy the invitation for Roadmap and send it to the recipient.", + }); + }); + + it.each([ + "offline-peers", + "stale", + "degraded", + ] as const)("directs users to check paired devices when daemon_state is %s", (daemonState) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: daemonState, daemon_running: true }, + coordinator: postedCoordinator, + peers: [healthyPeer], + }); + + expect(view).toMatchObject({ + state: "reachable", + badgeLabel: "Check devices", + nextAction: expect.stringContaining("Bring the paired devices online"), + }); + expect(view.nextAction).not.toContain("Pair and approve"); + }); + + it("directs users to check an offline peer before suggesting another pairing", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [{ peer_device_id: "peer-offline", status: { peer_state: "offline" } }], + }); + + expect(view).toMatchObject({ + state: "reachable", + badgeLabel: "Check devices", + nextAction: expect.stringContaining("Bring the paired devices online"), + }); + }); + + it.each([ + ["degraded", { peer_state: "degraded" }, false], + ["errored", { peer_state: "online" }, true], + ] as const)("directs users to check a %s peer", (_name, peerStatus, hasError) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [{ peer_device_id: "peer-problem", has_error: hasError, status: peerStatus }], + }); + + expect(view).toMatchObject({ + state: "reachable", + badgeLabel: "Check devices", + nextAction: expect.stringContaining("check their sync errors"), + }); + expect(view.nextAction).not.toContain("Pair and approve"); + }); + + it("routes unauthorized peer errors to re-pairing guidance", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: postedCoordinator, + peers: [ + { + peer_device_id: "peer-unauthorized", + has_error: true, + last_error: "peer status failed (401: unauthorized)", + status: { peer_state: "online" }, + }, + ], + }); + + expect(view).toMatchObject({ + state: "trust-blocked", + badgeLabel: "Pairing needed", + nextAction: expect.stringContaining("finish pairing"), + }); + }); + + it("prioritizes pairing blockers when another paired device is offline", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator: { + ...postedCoordinator, + discovered_devices: [{ device_id: "device-pending", needs_local_approval: true }], + }, + peers: [{ peer_device_id: "peer-offline", status: { peer_state: "offline" } }], + }); + + expect(view).toMatchObject({ + state: "trust-blocked", + badgeLabel: "Pairing needed", + nextAction: expect.stringContaining("finish pairing"), + }); + }); + + it.each([ + "needs_attention", + "error", + "stopped", + "stopping", + "starting", + "rebootstrapping", + "disabled", + ] as const)("directs users to sync-service guidance when daemon_state is %s", (daemonState) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: daemonState }, + coordinator: postedCoordinator, + peers: [healthyPeer], + }); + + expect(view).toMatchObject({ + state: "needs-attention", + badgeLabel: "Sync needs attention", + nextAction: expect.stringContaining("Review the sync status"), + }); + expect(view.nextAction).not.toContain("Pair and approve"); + }); + + it("fails closed when daemon_state is unavailable", () => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: undefined }, + coordinator: postedCoordinator, + peers: [healthyPeer], + }); + + expect(view.state).toBe("reachable"); + }); + + it("fails closed when the daemon explicitly is not running", () => { + expect( + deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: false }, + coordinator: postedCoordinator, + peers: [healthyPeer], + }), + ).toMatchObject({ state: "needs-attention", badgeLabel: "Sync needs attention" }); + }); + + it.each([ + [ + "status.enabled is missing", + { daemon_state: "ok" as const, daemon_running: true }, + postedCoordinator, + ], + [ + "coordinator.sync_enabled is missing", + { enabled: true, daemon_state: "ok" as const, daemon_running: true }, + { ...postedCoordinator, sync_enabled: undefined }, + ], + ])("fails closed when %s despite daemon health and mutual trust", (_name, status, coordinator) => { + const view = deriveTeamSyncPrimaryStatus({ status, coordinator, peers: [healthyPeer] }); + + expect(view).toMatchObject({ state: "reachable", badgeLabel: "Reachable" }); + }); + + const coordinatorCases: Array< + [ + string, + NonNullable[0]["coordinator"]>, + string, + string, + string, + string, + ] + > = [ + [ + "not enrolled", + { ...postedCoordinator, presence_status: "not_enrolled" }, + "not-enrolled", + "Not enrolled", + "Team: Acme. This device is not enrolled with the coordinator.", + "Paste a Team invite below, or ask a Team admin to enroll this device.", + ], + [ + "configured unreachable", + { ...postedCoordinator, presence_status: "unknown" }, + "unreachable", + "Unreachable", + "Team: Acme. The coordinator is not currently reachable and no healthy data-plane sync is confirmed.", + "Check the coordinator connection, then refresh Team sync.", + ], + [ + "unconfigured", + { configured: false, sync_enabled: true, groups: [] }, + "unreachable", + "Setup needed", + "Configure or join a Team before expecting Project data to sync.", + "Paste a Team invite below, or configure a coordinator in Advanced settings.", + ], + ]; + + it.each( + coordinatorCases, + )("models %s with one exact directive", (_name, coordinator, state, badgeLabel, meta, nextAction) => { + const view = deriveTeamSyncPrimaryStatus({ + status: { enabled: true, daemon_state: "ok", daemon_running: true }, + coordinator, + }); + + expect(view).toEqual({ state, badgeLabel, meta, nextAction }); + }); +}); + describe("derivePeerUiStatus", () => { it("flags unauthorized peers as needing re-pairing attention", () => { expect( diff --git a/packages/ui/src/tabs/sync/view-model.ts b/packages/ui/src/tabs/sync/view-model.ts index daa7c476..5710e610 100644 --- a/packages/ui/src/tabs/sync/view-model.ts +++ b/packages/ui/src/tabs/sync/view-model.ts @@ -33,6 +33,7 @@ export { deriveDuplicatePeople, deriveVisiblePeopleActors, } from "./view-model/people-derivations"; +export { deriveTeamSyncPrimaryStatus } from "./view-model/primary-status"; export { deriveCoordinatorSetupBlocker, deriveSyncViewModel, @@ -51,7 +52,14 @@ export { type PeerRecentOps, type PeerScopeRejectionReason, type PeerScopeRejectionsSummary, + type ProjectShareOperationLike, + type RecipientPolicyReconciliationLike, SYNC_TERMINOLOGY, + type TeamSyncDaemonState, + type TeamSyncPresenceState, + type TeamSyncProjectOperationActionKind, + type TeamSyncProjectOperationState, + type TeamSyncReconciliationState, type UiCoordinatorApprovalState, type UiCoordinatorApprovalSummary, type UiDuplicatePersonCandidate, @@ -61,6 +69,8 @@ export { type UiSyncRunResponse, type UiSyncStatus, type UiSyncViewModel, + type UiTeamSyncPrimaryState, + type UiTeamSyncPrimaryStatus, type UiTrustState, type VisiblePeopleResult, } from "./view-model/types"; diff --git a/packages/ui/src/tabs/sync/view-model/primary-status.ts b/packages/ui/src/tabs/sync/view-model/primary-status.ts new file mode 100644 index 00000000..a43346fd --- /dev/null +++ b/packages/ui/src/tabs/sync/view-model/primary-status.ts @@ -0,0 +1,292 @@ +import { cleanText } from "./internal"; +import { derivePeerScopeSyncView, derivePeerTrustSummary } from "./peer-status"; +import type { + DiscoveredDeviceLike, + PeerLike, + ProjectShareOperationLike, + RecipientPolicyReconciliationLike, + TeamSyncDaemonState, + TeamSyncPresenceState, + TeamSyncProjectOperationState, + TeamSyncReconciliationState, + UiTeamSyncPrimaryStatus, +} from "./types"; + +type CoordinatorLike = { + configured?: boolean; + sync_enabled?: boolean; + presence_status?: TeamSyncPresenceState; + groups?: unknown[]; + discovered_devices?: DiscoveredDeviceLike[]; +}; + +type SyncStatusLike = { + enabled?: boolean; + daemon_state?: TeamSyncDaemonState; + daemon_running?: boolean; +}; + +const PENDING_OPERATION_STATES: ReadonlySet = new Set([ + "pending_setup", + "waiting_for_acceptance", + "provisioning", + "initial_sync", + "waiting_for_device", + "revoking", +]); +const PENDING_RECONCILIATION_STATES: ReadonlySet = new Set([ + "pending", + "verifying", + "waiting", +]); + +function teamLabel(coordinator?: CoordinatorLike | null): string { + const groups = Array.isArray(coordinator?.groups) + ? coordinator.groups.map((group) => cleanText(group)).filter(Boolean) + : []; + return groups.join(", ") || "none"; +} + +function projectLabel(operation?: ProjectShareOperationLike): string { + return cleanText(operation?.projects?.[0]?.display_name) || "the shared Project"; +} + +function reconciliationProjectLabel(reconciliation?: RecipientPolicyReconciliationLike): string { + const blocked = reconciliation?.items?.find((item) => item.state !== "active"); + return cleanText(blocked?.canonicalProjectIdentity) || "the shared Project"; +} + +function hasTrustBlocker(peers: PeerLike[], coordinator?: CoordinatorLike | null): boolean { + if ( + coordinator?.discovered_devices?.some( + (device) => device.needs_local_approval || device.waiting_for_peer_approval, + ) + ) { + return true; + } + return peers.some((peer) => { + const trust = derivePeerTrustSummary(peer).state; + return trust === "trusted-by-you" || trust === "needs-repairing"; + }); +} + +function hasHealthyDataPlane(peers: PeerLike[], status?: SyncStatusLike | null): boolean { + if (status?.enabled !== true || status.daemon_state !== "ok" || status.daemon_running === false) { + return false; + } + return peers.some( + (peer) => + peer.status?.sync_status === "ok" && + derivePeerTrustSummary(peer).state === "mutual-trust" && + !derivePeerScopeSyncView(peer).rows.some((scope) => scope.status === "pending"), + ); +} + +function hasReachableTrustedPeer(peers: PeerLike[]): boolean { + return peers.some( + (peer) => + cleanText(peer.status?.peer_state) === "online" && + derivePeerTrustSummary(peer).state === "mutual-trust", + ); +} + +function hasDeviceConnectivityProblem(peers: PeerLike[], status?: SyncStatusLike | null): boolean { + if ( + status?.daemon_state === "offline-peers" || + status?.daemon_state === "stale" || + status?.daemon_state === "degraded" + ) { + return true; + } + return peers.some((peer) => { + const peerState = cleanText(peer.status?.peer_state); + const trustState = derivePeerTrustSummary(peer).state; + return ( + (peer.has_error === true && trustState !== "needs-repairing") || + trustState === "offline" || + trustState === "needs-review" || + peerState === "offline" || + peerState === "stale" || + peerState === "degraded" + ); + }); +} + +function hasDaemonAttention(status?: SyncStatusLike | null): boolean { + if (status?.daemon_running === false) return true; + const state = status?.daemon_state; + return ( + state !== undefined && + state !== "ok" && + state !== "offline-peers" && + state !== "stale" && + state !== "degraded" + ); +} + +export function deriveTeamSyncPrimaryStatus(input: { + status?: SyncStatusLike | null; + coordinator?: CoordinatorLike | null; + peers?: PeerLike[]; + shareOperations?: ProjectShareOperationLike[]; + shareOperationsLoadError?: boolean; + reconciliation?: RecipientPolicyReconciliationLike | null; +}): UiTeamSyncPrimaryStatus { + const coordinator = input.coordinator; + const peers = Array.isArray(input.peers) ? input.peers : []; + const operations = Array.isArray(input.shareOperations) ? input.shareOperations : []; + const reconciliationItems = Array.isArray(input.reconciliation?.items) + ? input.reconciliation.items + : []; + const label = teamLabel(coordinator); + const syncDisabled = input.status?.enabled === false || coordinator?.sync_enabled === false; + + if (syncDisabled) { + return { + state: "disabled", + badgeLabel: "Sync off", + meta: `Team: ${label}. Coordinator presence does not move Project data while sync is off.`, + nextAction: "Open Settings and turn on sync before expecting Team or Project data to update.", + }; + } + + if (input.shareOperationsLoadError) { + return { + state: "needs-attention", + badgeLabel: "Refresh needed", + meta: `Team: ${label}. Device diagnostics are available, but Project sharing status could not be refreshed.`, + nextAction: "Refresh Team sync to retry loading Project sharing status.", + }; + } + + const operationAttention = operations.find( + (operation) => operation.lifecycle?.state === "needs_attention", + ); + const reconciliationAttention = reconciliationItems.find( + (item) => item.state === "needs_attention", + ); + if (operationAttention) { + const project = projectLabel(operationAttention); + return { + state: "needs-attention", + badgeLabel: "Needs attention", + meta: `Team: ${label}. Exact-Project setup has not converged, so coordinator presence is not a healthy sync signal.`, + nextAction: `Open Project sharing below and retry setup for ${project}.`, + }; + } + if (reconciliationAttention) { + return { + state: "needs-attention", + badgeLabel: "Needs attention", + meta: `Team: ${label}. Recipient access has not converged, so coordinator presence is not a healthy sync signal.`, + nextAction: + "Open Sharing and use Manage projects for the affected recipient, then run Sync now again.", + }; + } + + const pendingOperation = operations.find((operation) => { + const state = operation.lifecycle?.state; + return state !== undefined && PENDING_OPERATION_STATES.has(state); + }); + const pendingReconciliation = reconciliationItems.find((item) => { + const state = item.state; + return state !== undefined && PENDING_RECONCILIATION_STATES.has(state); + }); + if (pendingOperation || pendingReconciliation) { + const project = pendingOperation + ? projectLabel(pendingOperation) + : reconciliationProjectLabel(input.reconciliation ?? undefined); + const primaryAction = pendingOperation?.lifecycle?.primary_action?.kind; + const revoking = pendingOperation?.lifecycle?.state === "revoking"; + return { + state: "pending-setup", + badgeLabel: revoking ? "Removal pending" : "Setup pending", + meta: revoking + ? `Team: ${label}. Future access removal for ${project} is still pending.` + : `Team: ${label}. Exact-Project setup is still pending and data delivery is not confirmed.`, + nextAction: revoking + ? `Keep both devices online, then sync again to finish removing future access for ${project}.` + : primaryAction === "retry_setup" + ? `Open Project sharing below and retry setup for ${project}.` + : primaryAction === "copy_invite" + ? `Copy the invitation for ${project} and send it to the recipient.` + : `Keep both devices online, then sync again to finish setup for ${project}.`, + }; + } + + if (hasDaemonAttention(input.status)) { + return { + state: "needs-attention", + badgeLabel: "Sync needs attention", + meta: `Team: ${label}. The local sync service is not healthy, so Project data delivery is not confirmed.`, + nextAction: + "Review the sync status below, restart codemem if needed, then run Sync now again.", + }; + } + + if (hasTrustBlocker(peers, coordinator)) { + return { + state: "trust-blocked", + badgeLabel: "Pairing needed", + meta: `Team: ${label}. A device still needs two-way trust before Project data can sync.`, + nextAction: "Review the device below and finish pairing or approval on both devices.", + }; + } + + const presence = cleanText(coordinator?.presence_status); + if (presence === "not_enrolled") { + return { + state: "not-enrolled", + badgeLabel: "Not enrolled", + meta: `Team: ${label}. This device is not enrolled with the coordinator.`, + nextAction: "Paste a Team invite below, or ask a Team admin to enroll this device.", + }; + } + if (presence !== "posted") { + return { + state: "unreachable", + badgeLabel: coordinator?.configured ? "Unreachable" : "Setup needed", + meta: coordinator?.configured + ? `Team: ${label}. The coordinator is not currently reachable and no healthy data-plane sync is confirmed.` + : "Configure or join a Team before expecting Project data to sync.", + nextAction: coordinator?.configured + ? "Check the coordinator connection, then refresh Team sync." + : "Paste a Team invite below, or configure a coordinator in Advanced settings.", + }; + } + + if (hasDeviceConnectivityProblem(peers, input.status)) { + return { + state: "reachable", + badgeLabel: "Check devices", + meta: `Team: ${label}. The coordinator is reachable, but one or more paired devices are offline or degraded.`, + nextAction: + "Bring the paired devices online, check their sync errors, then run Sync now again.", + }; + } + + if (coordinator?.sync_enabled === true && hasHealthyDataPlane(peers, input.status)) { + return { + state: "healthy", + badgeLabel: "Healthy", + meta: `Team: ${label}. Sync is enabled and a trusted device has a healthy data-plane connection.`, + nextAction: null, + }; + } + if (hasReachableTrustedPeer(peers)) { + return { + state: "reachable", + badgeLabel: "Reachable", + meta: `Team: ${label}. The coordinator and a paired device are reachable, but successful Project sync is not confirmed.`, + nextAction: + "Run Sync now, then review the device sync status below if delivery is still pending.", + }; + } + + return { + state: "reachable", + badgeLabel: "Reachable", + meta: `Team: ${label}. The coordinator is reachable, but healthy Project data sync is not confirmed.`, + nextAction: "Pair and approve a device, then run Sync now to confirm data delivery.", + }; +} diff --git a/packages/ui/src/tabs/sync/view-model/sync-view-model.ts b/packages/ui/src/tabs/sync/view-model/sync-view-model.ts index 6a6a0fbf..3ce7715b 100644 --- a/packages/ui/src/tabs/sync/view-model/sync-view-model.ts +++ b/packages/ui/src/tabs/sync/view-model/sync-view-model.ts @@ -8,11 +8,16 @@ import { deviceNeedsFriendlyName, resolveFriendlyDeviceName } from "./device-nam import { cleanText } from "./internal"; import { derivePeerTrustSummary, derivePeerUiStatus } from "./peer-status"; import { deriveDuplicatePeople } from "./people-derivations"; +import { deriveTeamSyncPrimaryStatus } from "./primary-status"; import type { ActorLike, CoordinatorSetupBlocker, DiscoveredDeviceLike, PeerLike, + ProjectShareOperationLike, + RecipientPolicyReconciliationLike, + TeamSyncDaemonState, + TeamSyncPresenceState, UiSyncAttentionItem, UiSyncViewModel, } from "./types"; @@ -153,7 +158,21 @@ function mergeDevices( export function deriveSyncViewModel(input: { actors?: ActorLike[]; peers?: PeerLike[]; - coordinator?: { discovered_devices?: DiscoveredDeviceLike[] }; + coordinator?: { + configured?: boolean; + sync_enabled?: boolean; + presence_status?: TeamSyncPresenceState; + groups?: unknown[]; + discovered_devices?: DiscoveredDeviceLike[]; + }; + status?: { + enabled?: boolean; + daemon_state?: TeamSyncDaemonState; + daemon_running?: boolean; + } | null; + shareOperations?: ProjectShareOperationLike[]; + shareOperationsLoadError?: boolean; + reconciliation?: RecipientPolicyReconciliationLike | null; duplicatePersonDecisions?: Record; }): UiSyncViewModel { const actors = Array.isArray(input.actors) ? input.actors : []; @@ -291,6 +310,14 @@ export function deriveSyncViewModel(input: { }); return { + primaryStatus: deriveTeamSyncPrimaryStatus({ + status: input.status, + coordinator: input.coordinator, + peers, + shareOperations: input.shareOperations, + shareOperationsLoadError: input.shareOperationsLoadError, + reconciliation: input.reconciliation, + }), summary: { connectedDeviceCount: peers.filter((peer) => derivePeerUiStatus(peer) === "connected").length, seenOnTeamCount: discoveredDevices.length, diff --git a/packages/ui/src/tabs/sync/view-model/types.ts b/packages/ui/src/tabs/sync/view-model/types.ts index 3e04f25e..9a279193 100644 --- a/packages/ui/src/tabs/sync/view-model/types.ts +++ b/packages/ui/src/tabs/sync/view-model/types.ts @@ -89,6 +89,8 @@ export interface UiDuplicatePersonCandidate { } export interface UiSyncViewModel { + /** Single Project/Team directive; attentionItems remain device-level follow-ups. */ + primaryStatus: UiTeamSyncPrimaryStatus; summary: { connectedDeviceCount: number; seenOnTeamCount: number; @@ -98,6 +100,79 @@ export interface UiSyncViewModel { attentionItems: UiSyncAttentionItem[]; } +export type UiTeamSyncPrimaryState = + | "disabled" + | "needs-attention" + | "pending-setup" + | "trust-blocked" + | "not-enrolled" + | "reachable" + | "unreachable" + | "healthy"; + +export interface UiTeamSyncPrimaryStatus { + state: UiTeamSyncPrimaryState; + badgeLabel: string; + meta: string; + nextAction: string | null; +} + +export interface ProjectShareOperationLike { + person?: { display_name?: string }; + projects?: Array<{ display_name?: string }>; + lifecycle?: { + state?: TeamSyncProjectOperationState; + primary_action?: { kind?: TeamSyncProjectOperationActionKind } | null; + }; +} + +export interface RecipientPolicyReconciliationLike { + items?: Array<{ + canonicalProjectIdentity?: string; + state?: TeamSyncReconciliationState; + }>; +} + +export type TeamSyncProjectOperationState = + | "pending_setup" + | "waiting_for_acceptance" + | "provisioning" + | "initial_sync" + | "waiting_for_device" + | "active" + | "needs_attention" + | "revoking" + | "revoked" + | "cancelled"; + +export type TeamSyncProjectOperationActionKind = + | "copy_invite" + | "retry_setup" + | "share_again" + | "create_new_invite"; + +export type TeamSyncReconciliationState = + | "active" + | "needs_attention" + | "pending" + | "verifying" + | "waiting"; + +export type TeamSyncDaemonState = + | "ok" + | "disabled" + | "error" + | "stopped" + | "stopping" + | "starting" + | "needs_attention" + | "rebootstrapping" + | "degraded" + | "offline-peers" + | "stale"; + +export type TeamSyncPresenceState = "posted" | "not_enrolled" | "unknown" | "error"; + export type CoordinatorSetupBlockerReason = | "coordinator_groups_empty" | "coordinator_url_missing" diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 7d878ac6..b9960749 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -7981,6 +7981,19 @@ describe("viewer-server", () => { now, now, ); + const syncStatusResponse = await app.request("/api/sync/status"); + const syncStatusPayload = (await syncStatusResponse.json()) as { + recipient_policy_reconciliation?: { + version: number; + items: Array<{ + canonicalProjectIdentity: string; + state: string; + deliveredCopiesMayRemain: boolean; + revocationWarning: string; + }>; + }; + }; + expect(syncStatusResponse.status).toBe(200); store.db.pragma("query_only = ON"); const response = await app.request("/api/sync/recipient-policy/v1/reconciliation-status"); @@ -8010,6 +8023,8 @@ describe("viewer-server", () => { }), ]); expect(payload.items.every((item) => item.revocationWarning.length > 0)).toBe(true); + + expect(syncStatusPayload.recipient_policy_reconciliation?.items).toEqual(payload.items); for (const privateValue of [ "private-revision", "private-desired-digest", diff --git a/packages/viewer-server/src/routes/sync.ts b/packages/viewer-server/src/routes/sync.ts index 833e3115..380306d4 100644 --- a/packages/viewer-server/src/routes/sync.ts +++ b/packages/viewer-server/src/routes/sync.ts @@ -4335,6 +4335,9 @@ export function syncRoutes( const legacyDevices = traceSync("legacyDevices", () => store.claimableLegacyDeviceIds()); const legacyReview = traceSync("legacySharedReview", () => legacySharedReviewSummary(store)); const sharingReview = traceSync("sharingReview", () => store.sharingReviewSummary(project)); + const recipientPolicyReconciliation = traceSync("recipientPolicyReconciliation", () => + listRecipientPolicyReconciliationStatus(store), + ); let joinRequests: Record[] = []; if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) { try { @@ -4393,6 +4396,7 @@ export function syncRoutes( legacy_devices: legacyDevices, legacy_shared_review: legacyReview, sharing_review: sharingReview, + recipient_policy_reconciliation: recipientPolicyReconciliation, coordinator, }; if (includeJoinRequests && showDiag) {