Skip to content

feat(peers): P2P sync phases 3a + 3b + 4 + 5 — full Hub removal#132

Merged
ParkerM2 merged 39 commits into
developfrom
feature/p2p-sync-phase3
Apr 25, 2026
Merged

feat(peers): P2P sync phases 3a + 3b + 4 + 5 — full Hub removal#132
ParkerM2 merged 39 commits into
developfrom
feature/p2p-sync-phase3

Conversation

@ParkerM2
Copy link
Copy Markdown
Owner

Summary

Completes the P2P sync rollout in four tagged phases (39 commits) and retires the standalone Hub server entirely.

  • Phase 3a (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.
  • Phase 3b (p2p-phase3b-done): IPC channels + Zod contract, PeersService facade, IPC handlers, service-registry refactor, React Query + event hooks, PeerListPanel + PIN dialogs, Settings → Peers tab.
  • Phase 4 (p2p-phase4-done): SYNC_TABLES extended (notes + ideas), workflow_runs_summary dual-write on terminal state, daily op-log GC with min(lastSeenHlc) watermark.
  • Phase 5 (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 — clean
  • npx vitest run --config vitest.integration.config.ts tests/integration/peers tests/integration/workflow tests/integration/assistant tests/integration/projects144/144 pass in 3.47s
  • npx electron-vite build — succeeds in 12.78s
  • Tree-wide grep for Hub residuals — only legacy-migrator.ts (preserved per Task 5.7, manages adc.db on-disk paths) and hub-discovery-flag.ts (no Hub deps) remain

Test plan

  • CI passes on develop
  • Pair two devices via Settings → Peers using mDNS discovery + PIN ritual
  • Verify a progress_tasks write on device A appears on device B over wss://
  • Verify a notes or ideas write replicates the same way
  • Run a workflow on device A; confirm workflow_runs_summary row replicates to device B with correct ran_on_peer_id
  • Revoke a peer in Settings; confirm trust list updates and op_log rows below the new watermark are GC'd on next 24h tick
  • Confirm the app boots straight to dashboard (no login gate post-auth removal)
  • Spot-check Webhook Settings copy reads "relay unavailable — coming in a future release"

Known follow-ups (not blocking)

  • Webhook ingestion needs a local receiver to replace the deleted Hub relay (TODO(p2p-future) in useWebhookSettings.ts)
  • IncomingPinDialog is mounted inside SettingsPage; promote to RootLayout for global visibility (TODO(p2p-phase4) already in code)
  • projectPath is used as a surrogate project_id in workflow_runs_summary until projects join SYNC_TABLES (TODO(p2p-future) already in code)

🤖 Generated with Claude Code

ParkerES and others added 30 commits April 24, 2026 18:29
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>
ParkerES and others added 9 commits April 25, 2026 14:30
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>
@ParkerM2 ParkerM2 merged commit 53fc7b4 into develop Apr 25, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants