feat(peers): P2P sync phases 3a + 3b + 4 + 5 — full Hub removal#132
Merged
Conversation
Phase 3a Task 7. Built on node:https (no Fastify dep) with the same
TLS material as the WS transport. Endpoints:
- POST /pair/init: { peerId, pubkey, fingerprint, displayName? }
→ { sessionId, challenge }; PIN delivered to caller via onPinIssued
- POST /pair/confirm: { sessionId, pinHmac }
→ { peerId, pubkey, fingerprint } of the receiving peer; on success the
initiator is upserted into peer-store with their advertised fingerprint
Threaded fingerprint through PairInitArgs / PairConfirmResult so the
receiving side can persist the initiator's cert pin alongside their pubkey.
Also fixed migration-tags test which still pinned 0027_workflow_runs_summary
as the latest tag — bumped to 0028_peer_state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3a Task 8. ws-transport gains optional TLS:
- New `tls: { cert, key }` dep — when set, the inbound WebSocketServer
attaches to a node:https server using that material instead of plain HTTP
- New `remotePeer: { peerId, fingerprint }` dep — when set alongside a
wss:// remoteUrl, the outbound socket pins the server's SHA-256 cert
fingerprint. On mismatch the socket closes with code 4002 before HELLO.
- Plain ws:// stays the default when no tls/remotePeer is provided so the
existing roundtrip tests + Phase 2 callers keep working.
Fingerprint check uses `getPeerCertificate(true)` on the underlying
TLSSocket, hashed with SHA-256, matched against the pinned value.
Tests: +2 TLS-pinned cases — happy path (correct pin, write replicates)
and refusal (wrong pin, write does not replicate). 109/109 peer suite green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…S/mDNS Phase 3a Task 9. Boot flow when ADC_PEER_PORT > 0: identity (Ed25519, safeStorage) → TLS (self-signed cert, SHA-256 fingerprint) → peer-server (one TLS https.Server hosting both /pair/* AND wss:// sync) → mDNS advertise (when preferMdns, default true) + browse + log discoveries New unified entry point `createPeerServer` shares one https.Server between `createPairServer` and `createWsTransport` — both modules now accept an `existingServer` / `existingHttpsServer` param so they can attach handlers without owning the listen lifecycle. Existing standalone tests keep passing because the param is optional. peer-config.ts gains preferMdns, pairingEnabled, displayName flags (env: ADC_PEER_PREFER_MDNS, ADC_PEER_PAIRING_ENABLED, ADC_PEER_DISPLAY_NAME). Renamed loadPhase1PeerConfig → loadPeerConfig with a back-compat alias. ADC_PEER_REMOTE remains as a manual override for tests; identity-derived peerId now overrides ADC_PEER_ID_* env vars when both are present. Phase 3a logs PIN issuance + mDNS discoveries to the appLogger; Phase 3b will surface PINs to the renderer and auto-dial known mDNS peers. Tests: full peer suite 109/109 (no new tests; wiring is exercised by manual main-process boot — Task 10 adds the end-to-end pair-flow test). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests/integration/peers/pair-flow.test.ts covering the full two-instance pair-and-sync round trip without mDNS: spins up two in-process peer-servers (each on its own tmpdir, identity, TLS cert) and exercises the unified TLS https.Server hosting both /pair/* and wss:// sync. Instance B dials A over wss with A's pinned certificate fingerprint, then drives the pair handshake (POST /pair/init -> capture PIN via onPinIssued -> compute HMAC -> POST /pair/confirm). Test verifies the pair-server upserted the initiator into A's peer_state, B mirrors A into its own peer_state from the confirm response, and a write to progress_tasks on A is replicated to B over the existing TLS-pinned WebSocket transport. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the peers domain with 6 operations (list paired/discovered, pair init/confirm, revoke peer, get identity) and 3 event types (PIN issued, discovery changed, trust changed). All op inputs/outputs and event payloads have Zod schemas + inferred TypeScript types. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add lastSeenHlc to PairedPeerSchema (mirrors peer-store)
- Use .nullable() consistently for displayName + initiatorDisplayName
- Trust-changed event now { peerId, action } not full peer list
- Constrain port to int 1..65535
- Comment on certFingerprint vs fingerprint naming split
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oke ops Facade for the renderer-driven pairing UI. Wraps peer-identity, peer-tls, peer-store, peer-server, and peer-mdns into a single IPC-friendly service: - listPaired / listDiscovered / getIdentity reads - pairInit / pairConfirm initiator-side (HTTPS with manual fingerprint pin) - revoke marks peer revoked + emits trust-changed - onPinIssued forwards receiver-side PIN events - onDiscoveryChanged enriches mDNS ads with isPaired flag - onTrustChanged fires on pair-confirm success and revoke Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Initiator knows the remote's mDNS displayName from listDiscovered() and already shows it in the pair dialog. Threading it into pairConfirm and the resulting peer-store upsert avoids a follow-up bug where paired rows show a null displayName. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires PEERS.LIST.PAIRED/DISCOVERED, PEERS.IDENTITY.GET, PEERS.PAIR.INIT/ CONFIRM, PEERS.REVOKE.PEER to PeersService methods. Forwards onPinIssued, onDiscoveryChanged, onTrustChanged to router.emit on the corresponding PEERS_EVENTS channels. peersService is added as an optional field on the Services interface temporarily — Task 4 (service-registry wiring) makes it required. Also extends src/shared/ipc/peers/contract.ts with peersInvoke and peersEvents contract maps (required by IpcRouter Zod validation) and merges them into the global ipcInvokeContract / ipcEventContract. 3b.1 only published the standalone *Schema exports; the contract maps were deferred to keep this task typecheck-clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Schema declared initiatorDisplayName on PinIssuedEvent but the path peer-server -> peers-service -> IPC forwarder all dropped it. Renderer would have shown undefined where the spec said the receiver UI should display the initiator's friendly name. Fixed end-to-end. Also drops a redundant re-export in peers/index.ts and tightens the TODO marker on Services.peersService for grep consistency. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… wiring Replaces the inline peer-server/peer-mdns/peer-store async IIFE with a single createPeersService call. peersService is now a required field on Services (3b.3 left it optional with a TODO marker — that's gone). Removes the legacy plain-WS transport fallback path; PeersService is the single bootstrap surface for the peer subsystem when ADC_PEER_PORT > 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds usePairedPeers / useDiscoveredPeers / useSelfIdentity queries and usePairInit / usePairConfirm / useRevokePeer mutations. Uses the same ipc() helper as the rest of the renderer. usePairConfirm and useRevokePeer invalidate the paired list on success; usePairInit does not (caller holds the session+challenge for the next step). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…log hook EventBridge gains two entries: DISCOVERY.CHANGED writes the fresh peer list straight into the discovered query cache (avoids a refetch), TRUST.CHANGED invalidates the paired-peers query. useIncomingPin is a standalone hook for the receiver-side PIN dialog — subscribes to PEERS_EVENTS.PIN.ISSUED and exposes the latest unseen PIN plus a dismiss callback. Renderer dialog (Task 3b.8) consumes this. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PeerListPanel: paired list (with Revoke), nearby/mDNS list (with Invite), self-device summary. Auto-updates via EventBridge subscriptions. IncomingPinDialog: receiver-side modal showing the PIN issued to a remote initiator, driven by useIncomingPin. OutgoingPairDialog: initiator-side three-step flow — Send invite, Enter PIN, Confirm — driven by usePairInit + usePairConfirm. Holds session+challenge between mutations as the renderer state. All UI uses @ui design-system primitives. No raw HTML. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts duplicated truncate() helper into src/renderer/features/peers/ lib/truncate.ts. Adds role="alert" to mutation-error displays so screen readers announce pair failures. Resets pairInit + pairConfirm mutations on dialog close so reopen against a different target starts clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PeerListPanel renders in the new Peers tab (between Hub and Integrations). IncomingPinDialog mounts page-level inside SettingsPage so the receiver PIN modal appears whenever Settings is open. Future task may promote it to RootLayout for global visibility. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates SettingsPage JSDoc to list current tabs (was stale), adds an inline TODO above IncomingPinDialog mount tracking the planned RootLayout promotion, and marks the Phase 1 dev-harness doc as historical now that PeersService owns the boot path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both tables have UUID id PKs and no foreign-key constraints, so they satisfy the cross-peer-write safety requirements. Two new sync round- trip tests confirm a notes/ideas insert on instance A propagates to instance B over the replication transport. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Workflow engine now writes a workflow_runs_summary row and records a replication op when a run transitions to DONE (passed) or ERROR (failed). The row carries the local peerId via getLocalPeerId() so peers can attribute which device ran each workflow. ReplicationEngine gains getLocalPeerId() to expose the captured peerIdFull for callers like workflow-engine that need to stamp records. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ogate writeRunSummary accepted a 'cancelled' status that no caller produces — the engine's stop() path writes 'failed' via the ERROR transition. Drop the dead branch from the union. Adds a TODO above the project_id assignment noting that projectPath is a surrogate for the still-missing project UUID — two peers with different absolute paths to the same logical project will appear distinct in the summary view until projects join SYNC_TABLES. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds OpLogService.gc(watermarkHlc) which deletes op_log rows whose hlc is strictly less than the watermark. PeersService runs this daily via setInterval, computing the watermark as the minimum lastSeenHlc across all non-revoked peers in the trust store. Skips if any active peer has lastSeenHlc === null (refuses to discard ops that an unseen peer hasn't yet acknowledged). ReplicationEngine exposes gcOpLog(watermark) as a thin pass-through. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The setInterval handle now calls .unref() so it doesn't keep the Node event loop alive when tests create a PeersService without calling dispose(). Adds a one-shot setTimeout(...,0) initial GC so freshly installed builds with old op_log rows don't wait a full day before their first cleanup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the HubApiClient dep entirely. Device list comes from peer-store, online/sleeping/offline state derives from lastConnectedAt with the same 2-min/30-min thresholds, and per-device tasks are looked up via op_log (progress_tasks rows whose originPeerId matches the device). Closes the first Hub-import in Phase 5. Cross-device queries now work fully offline against locally-replicated state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds projects + sub_projects tables (migration 0029) backing a local SQLite-only ProjectService. All 7 CRUD methods rewritten against Drizzle. The in-memory projectCache stays for sync access by dependent services (getProjectPath, listProjectsSync). Hub API proxy is gone. Future Phase 6 may add projects to SYNC_TABLES for cross-device replication. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cache hydration now logs unexpected errors via serviceLogger and only silently tolerates "no such table" (the test-skip-migrations case). Backfills three semantic tests deleted with the old Hub-mocked test file: name defaulting to basename(path), update against unknown id throws, remove against unknown id throws. Implementation throws on both not-found cases for parity with the prior Hub-side behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ine) The progress-syncer wrapped a Hub HTTP push that's now redundant — progress_tasks already replicates over the peer transport via recordLocalWrite. Removes the file plus its only caller in workflow-handlers, which simplifies that handler to local emits only. If hubApiClient is no longer needed in registerWorkflowHandlers after this change, the param is also dropped (verified by grep over the file). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Hub server provided login/register/session for a multi-tenant deployment. With the desktop app pivoting to peer-to-peer single-device + paired-peer model, server auth is removed entirely: - Delete src/main/features/auth/auth-handlers.ts (all 6 handlers) - Delete src/shared/ipc/auth/* (channels + contract + Zod schemas) - Delete src/renderer/features/auth/* (LoginPage, RegisterPage, AuthGuard, useAuth, useTokenRefresh, useSavedLogins, etc.) - Delete src/shared/types/auth.ts (User/AuthTokens interfaces) - Delete renderer auth.routes.tsx + UserMenu sidebar header - Drop AUTH from contract maps + services bag (hubAuthService removed from Services interface; internal lazyService kept until task 5.7 deletes hub/ entirely) - App boots directly into the main UI (no login gate) userSessionManager (local-only, tracks user-scoped storage paths) stays — the createUserSessionManager signature drops its router param since AUTH_EVENTS no longer exists; in-process onSessionChange callbacks remain for data-management consumers. test-suite's separate auth-handlers (browser storage state) is unrelated to user auth and stays. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- src/renderer/features/hub/* (22 files) — entire feature deleted - HubSettings panel + useHub hook + HubConnectionIndicator + HubNotification + HubStatus - Settings page: Hub tab + Server icon import removed - RootLayout: HubNotification + RevocationModal + useHubStatus + disconnected banner removed - TitleBar: HubStatus removed from utility buttons - Renderer hooks (useProjectEvents, useTaskEvents, useIntegrationsEvents, useWorkspaceEvents) — Hub/HUB_TASKS event branches removed; non-Hub subscriptions preserved - useDeviceEvents.ts deleted (hub-only) - useWorkspaceEvents.ts + useIntegrationsEvents.ts deleted (hub-only) - IntegrationsPage no longer subscribes to integrations events - WebhookSettings: hubUrl wiring stubbed out (URLs unavailable until a local webhook receiver replaces the Hub relay) - Stale ROUTES.LOGIN/REGISTER/HUB_SETUP constants removed - routes/FEATURE.md AuthGuard reference cleaned; settings/FEATURE.md updated - tests/unit/renderer/features/hub/* — obsolete unit tests deleted Renderer no longer imports anything from @features/hub or HUB.* channels. EventBridge.tsx still imports HUB_EVENTS / HUB_TASKS_EVENTS — Task 5.6 removes those entries; channel module deletion is Task 5.8. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Webhooks panel previously told users to "Connect to a Hub server" which no longer exists. Updates the copy to indicate the relay is unavailable until a future release. Adds a TODO marker on the hard- coded hasHubUrl=false so future cleanup can find it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
With the renderer hub UI gone (Task 5.5) and shared/ipc/hub + hub-tasks slated for deletion (Task 5.8), the EventBridge no longer subscribes to HUB_EVENTS or HUB_TASKS_EVENTS. Drops the registry entries plus the now-unused imports + key constants (TASKS). Non-hub event entries (PROGRESS, AGENT_DASHBOARD, WORKFLOW, BUS, PEERS) are preserved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Move db-migrator.ts -> src/main/db/legacy-migrator.ts (preserved because it manages adc.db path resolution, not Hub server logic). - Delete entire src/main/features/hub/ (23 files): api client, auth service, connection manager, discovery, sync, webhook relay, device service, fingerprint agent, hub-pair, network-watcher, etc. - Drop all Hub-related services from service-registry: hubConnectionManager, hubAuthService, hubApiClient, hubSyncService, hubDiscovery, deviceService, webhookRelay, plus the device-heartbeat block and the hub-discovery+ network-watcher block. - Drop Hub-related fields from Services interface in ipc/index.ts. - Strip Hub references from bootstrap (event-wiring, lifecycle, index). - Delete tests/unit/main/features/hub/, tests/unit/shared/ipc/hub/, and tests/e2e/hub-discovery.spec.ts (referenced deleted modules). shared/ipc/hub + hub-tasks deletion lands in Task 5.8. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the last shared IPC traces of the Hub server:
- src/shared/ipc/hub/ + hub-tasks/ — channels + Zod contracts gone
- ipcInvokeContract / ipcEventContract no longer merge those maps
- ipc-contract back-compat re-export drops Hub schema names
- shared/types/hub/ + hub-protocol/hub-events/hub-connection deleted
(TaskStatus + TaskPriority inlined into shared/types/task.ts; all
other types had no consumers outside the deleted hub layer)
- workflow-handlers TASKS_EVENTS.PROGRESS.UPDATED emit removed; the
legacy progress watcher now starts/stops without renderer notification
since the channel and its hooks are gone
- Renderer hub-routed task layer removed:
* useTasks / useTaskMutations (HUB_TASKS) — backend gone
* useTaskEvents / useWorkflowEvents (TASKS_EVENTS) — channel gone
* useHubEvent shared hook — no remaining users
* Hub-shaped task UI (TaskFiltersToolbar, TaskDetailRow, TaskControls,
ActionsCell, CreateTaskDialog, TaskStatusBadge, cells/, plus
PlanViewer/SubtaskList/TaskResultView) — superseded by ProgressTaskGrid
- ProjectListPage.useAllTasks now sources from @features/my-work
(PROGRESS-backed) and filters by progress-status enum
After this commit, no source file outside the standalone hub/ Fastify
server directory references any Hub IPC channel or schema.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Final removal step. Drops: - hub/ (entire Fastify Hub server, ~50+ files) - docker-compose.yml (only services were hub + nginx) - nginx/ (only fronted the Hub) - certs/ (Hub TLS material; peer-tls uses safeStorage in user-data dir) - scripts/generate-certs.sh (Hub cert generator) - README.md scrubbed of Hub references; multi-device sync section rewritten to reflect the P2P architecture. After this commit there is no Hub server in the repo. Sync is fully peer-to-peer: paired devices connect directly over TLS-pinned WSS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Completes the P2P sync rollout in four tagged phases (39 commits) and retires the standalone Hub server entirely.
p2p-phase3a-done): peer_state schema, Ed25519 identity, self-signed TLS, peer-store, mDNS, PIN pairing ritual, pair-server (HTTPS), TLS-pinned ws-transport, unified peer-server in service-registry, end-to-end pair-flow integration test.p2p-phase3b-done): IPC channels + Zod contract, PeersService facade, IPC handlers, service-registry refactor, React Query + event hooks, PeerListPanel + PIN dialogs, Settings → Peers tab.p2p-phase4-done): SYNC_TABLES extended (notes + ideas), workflow_runs_summary dual-write on terminal state, daily op-log GC with min(lastSeenHlc) watermark.p2p-phase5-done): Migrated cross-device-query + project-service off Hub, deleted progress-syncer + auth-handlers + renderer hub UI + EventBridge HUB entries +src/main/features/hub+src/shared/ipc/hub+hub-tasks+hub/Fastify server +docker-compose.yml+nginx/+certs/. README scrubbed.Verification
npx tsc --noEmit— cleannpx vitest run --config vitest.integration.config.ts tests/integration/peers tests/integration/workflow tests/integration/assistant tests/integration/projects— 144/144 pass in 3.47snpx electron-vite build— succeeds in 12.78slegacy-migrator.ts(preserved per Task 5.7, manages adc.db on-disk paths) andhub-discovery-flag.ts(no Hub deps) remainTest plan
developprogress_taskswrite on device A appears on device B over wss://notesorideaswrite replicates the same wayworkflow_runs_summaryrow replicates to device B with correctran_on_peer_idop_logrows below the new watermark are GC'd on next 24h tickKnown follow-ups (not blocking)
TODO(p2p-future)inuseWebhookSettings.ts)TODO(p2p-phase4)already in code)projectPathis used as a surrogateproject_idinworkflow_runs_summaryuntil projects join SYNC_TABLES (TODO(p2p-future)already in code)🤖 Generated with Claude Code