Skip to content

feat(sync): P2P session sync via Syncthing — v5 revival of #45 with 11 root-cause fixes + UX layer#85

Open
the-non-expert wants to merge 397 commits into
mainfrom
sync-v5-ux
Open

feat(sync): P2P session sync via Syncthing — v5 revival of #45 with 11 root-cause fixes + UX layer#85
the-non-expert wants to merge 397 commits into
mainfrom
sync-v5-ux

Conversation

@the-non-expert

Copy link
Copy Markdown
Collaborator

Summary

Revives and ships the peer-to-peer session sync that #45 built on Syncthing. Jayant's model is kept fully intact — leader-gated teams, per-project subscriptions with directions — this PR fixes the 11 root-cause bugs that made v4 feel broken (invites never arrived, receivers got nothing) and layers a proper UX on top. Supersedes #45.

Full narrative record of the debugging and design decisions: docs/sync-v5-overnight-report.md.

Root-cause fixes (each committed with tests)

  • No reconciliation loop ran on fresh machines — added sync_bootstrap.py, called from app lifespan + /sync/init
  • Polling replaced with event-driven updates via Syncthing /rest/events (invite latency 60–120s → 6–18s)
  • Missing peer subscription rows; Phase 3 created wrong folder types; receive-only folders never received
  • Removal/dissolution signals severed their own transport before delivery — grace sweeps added
  • PUBLIC_API_URL was dead code everywhere — now $env/dynamic/public with :8020 fallback
  • Share now normalizes raw git URLs; packaging uses resolve_encoded_name instead of the leader's encoded name
  • /sync/package blocked the event loop — moved to asyncio.to_thread
  • Frontend project matching normalizes remote URLs (fixed mangled, unclickable project names)
  • Phantom invitation skeleton could freeze forever on a hung fetch; all frontend API fetches now bounded by 10s timeouts (a hung API also froze navigation via unbounded server-load fetches)

UX layer

  • Per-team new-project policy (ask / auto-accept / receive-only), local-only, schema v24
  • One-click invitation accept with a Customize disclosure; accept-all; sweep-with-confirm on policy change
  • /sync/inbox + header badge; relay/last-seen chips; Live-vs-Polling indicator
  • Per-team transfer status endpoint + progress bars; 10s live polling on /team and /members

Verification

  • scripts/sync_sim.py — two-peer 18/18, four-peer 28/28 (includes regression guards for different paths per peer and raw-URL share)
  • scripts/sync_real_test.py — 16/16 across two real machines (Mac mini ↔ MacBook Air over LAN)
  • Manual two-machine walkthrough: team creation, pairing, invitation accept, project share, session transfer

Notes for review

  • Schema chain: main ends at v19, sync v4 added 20–23, team prefs adds 24
  • History carries the full v4 development plus 20 revival commits; squash-merge lands it as one commit
  • Known open items (follow-ups, not blockers): cross-internet relay test, reset→rejoin flow, manual "link remote project to local", Members count excludes self

🤖 Generated with Claude Code

JayantDevkar and others added 30 commits March 18, 2026 02:27
…embers/Projects/Activity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix critical bug: unwrap API response in TeamActivityTab period switcher (data.stats)
- Fix hex color consistency: use light-mode hex values for new palette entries
- Use LOCAL_USER_HEX constant instead of hardcoded #7c3aed in ProjectMemberBar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… team page tabs

Bug fixes:
- Fix SSR crash: initialize $state directly from data props instead of $effect
  (which doesn't run during SSR), preventing "Team not found" on page load
- Fix 500 error in ProjectMemberBar: add null safety for received_counts/local_count
- Fix Chart.js "can't acquire context" error: move chart creation from onMount
  to $effect with canvas existence guard
- Fix empty activity chart: session_received events were logged without team_name
  in indexer.py, making them invisible to per-team queries
- Fix Members tab always showing "No transfer data": look up real Syncthing
  device stats from devices array matched by device_id

API improvements:
- Add comma-separated event_type filter support (e.g. session_packaged,session_received)
- Add member_name query parameter to activity endpoint for per-member filtering
- Resolve team_name from sync_team_projects before logging session_received events

UX improvements:
- Redesign TeamActivityFeed with card layout, type filter pills, and
  color-coded member filter pills
- Change Overview stat from "Projects in sync" to "Unsynced Sessions" count
- Center-align tab navigation
- Add empty state message for activity chart when no data exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… and cache remote session scans

The /projects/{encoded_name} endpoint was significantly slower than /sessions/all due to three issues:

1. A single try/except wrapped the entire SQLite fast path (143 lines). Any error in
   chain info, remote session merge, or title enrichment would discard the successful
   DB result and trigger the catastrophic JSONL fallback (parses every session file
   before paginating).

2. list_remote_sessions_for_project() performed a full filesystem walk (~4s for 942
   remote sessions) on every request with no caching.

3. Chain info opened a redundant second DB connection.

Fixes:
- Isolate try/excepts: core DB query, chain info, and remote merge each have independent
  error handling. Only a DB query failure triggers JSONL fallback. Chain info degrades
  gracefully to empty dict.
- Add thread-safe TTLCache (cachetools, 30s, maxsize=128) with double-checked locking
  for remote session scans. Repeated requests go from ~4.1s to ~0.02ms.
- Reuse single sqlite_read() connection for both query_project_sessions and
  query_chain_info_for_project.
- JSONL fallback path also uses cached remote sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… remote plugin skills

Three interrelated bugs caused incorrect remote/local skill classification
and prevented the "Inherit Skill" feature from working for remote plugin skills.

Fixes:

1. is_remote_only logic flaw — Skills installed locally (e.g. superpowers:executing-plans)
   were incorrectly tagged as remote-only when all session usage came from remote users.
   Now checks local file/directory existence before marking remote-only, covering:
   - Plugin skills (directory check via is_plugin_installed_locally)
   - Bundled skills/commands (always local)
   - Custom skills (SKILL.md file check)
   - User commands (.md file check)
   Fixed in both listing (get_skill_usage) and detail (get_skill_detail) endpoints.

2. plugin field null for remote skills — When skill_info is None (plugin not
   installed locally), the plugin field was always null. Now derives plugin name
   from skill_name.split(":")[0] as fallback.

3. Plugin skill definitions never extracted — _extract_skill_definitions_from_session
   skipped all plugin_skill categories, so remote plugin skills never got their
   definitions saved to skill_definitions table. Added Pass 3 fallback for
   unclassified colon-containing skills and allowed remote plugin_skill definitions
   through the filter. This enables "Inherit Skill" for remote plugin skills.

Also added public API functions is_plugin_installed_locally() and
is_custom_skill_local() to command_helpers to avoid private imports.

Verified against all cases:
- Case 1: Both have, both use → is_remote_only=false, local content shown
- Case 2: Plugin only remote has → is_remote_only=true, Inherit Skill available
- Case 3: Both have, only remote used → is_remote_only=false (was the primary bug)
- Case 4a: Plugin only remote has → is_remote_only=true, Inherit Skill available
- Case 4b: Custom skill only remote has → is_remote_only=true, Inherit Skill available
- Bundled edge case → is_remote_only=false (always local)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…user_id]

New read-only member page with color-themed profile header and 4 tabs:
- Overview: stats grid, daily session chart, project contribution list
- Sessions: expandable project/session list from remote sessions API
- Teams: team cards with project contribution badges
- Activity: type-filtered cross-team activity feed with load-more

Backend adds /sync/members/{member_name} aggregated profile endpoint and
/sync/members/{member_name}/activity for cross-team event pagination.
TeamMembersTab cards now link to the member detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user inherits a remote plugin skill (e.g., oh-my-claudecode:deepsearch),
it is now saved with a dash-form name (oh-my-claudecode-deepsearch) in
~/.claude/skills/ so Claude Code discovers and can invoke it as /oh-my-claudecode-deepsearch.

Backend changes:
- Add `inherited_skill` category to InvocationCategory and _SKILL_CATEGORIES
- Add `_is_inherited_skill()` with TTL cache that detects inherited skills
  via `inherited_from:` key in YAML frontmatter (reads only first 512 bytes)
- Rewrite `inherit_skill()` endpoint: colon→dash naming, YAML frontmatter
  with provenance tracking (inherited_from, source_user_id), idempotent
  re-inherit (returns already_exists), collision detection, cache eviction
- Update `_resolve_skill_info()` with dash-form fallback for plugin skills
  and `inherited_from` extraction from frontmatter
- Add `inherited_from` field to SkillInfo and SkillDetailResponse schemas

Frontend changes:
- Add `inherited_skill` to SkillCategory type and `inherited_name` to InheritResult
- Add "Inherited" label (amber color) to category label/color helpers
- InheritModal navigates to /skills/{inherited_name} after successful inherit
- Skill detail page shows amber "Inherited from" banner with invocation hint
- Skills listing page adds "Inherited" filter, "Inherited Skills" group
- SkillUsageTable uses getSkillCategoryLabel for proper category display
- Replace O(n) excludeFn with O(1) skillCategoryMap for chart filtering
- Enlarge InheritModal preview area (max-w-xl, max-h-60vh) for large skill files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…overflow

Sessions page list view used xl:grid-cols-4 while every other page maxed at
lg:grid-cols-3, making GlobalSessionCard narrower despite having more footer
content. Standardize to 3-col max and allow footer badges to wrap gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sion titles

Two bugs fixed in remote session handling:

1. **Member name display**: remote_user_id was derived from the filesystem
   directory name (e.g. 'Jayants-Mac-mini.local') instead of the canonical
   user_id from the manifest ('jay-mac-mini'). Added _resolve_user_id()
   helper that reads manifest.json user_id with 60s TTL cache, falling back
   to dir name. Applied at all 4 scan sites (find, list, iter, index) and
   added a one-time fixup pass to correct stale hostname-based values in
   existing DB rows. Also properly separates remote_user_id (clean name)
   from remote_machine_id (hostname) which were previously identical.

2. **Session titles not indexed**: titles.json was written to the outbox on
   title generation and read by the non-indexed metadata path, but the DB
   indexer never loaded it — so remote sessions in SQLite always had NULL
   session_titles. Added session_titles_override parameter to _index_session,
   loaded titles_map per (user, project) in the indexer loop, and added
   titles.json to the force-reindex mtime check alongside manifest.json.

Verified: 38/39 tests pass (1 pre-existing schema version assertion).
Live DB: 23 sessions corrected from 'Jayants-Mac-mini.local' → 'jay-mac-mini',
3/23 sessions now show titles from titles.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The project Team tab previously showed flat SessionCard lists per member
with no cost, duration, models, or tools data — because the remote-sessions
endpoint only scanned first/last lines of JSONL files for performance.

API changes:
- Rewrite /projects/{name}/remote-sessions to query SQLite instead of
  lightweight JSONL scanning. All rich fields (duration, cost, models,
  tools, initial_prompt, tokens) are already indexed by the periodic
  indexer — this endpoint now returns them.
- Bulk-fetch session_tools in one query for efficiency.
- Add total_cost, total_input_tokens, total_output_tokens, tools_used
  fields to SessionSummary Pydantic schema (were silently dropped).

Frontend changes:
- Redesign ProjectTeamTab from repetitive SessionCard lists to
  information-dense member contribution cards:
  - StatsGrid at top: total sessions, members, time, cost
  - Per-member cards with contribution bar, models, top tools, last prompt
  - Click to expand shows inline SessionCard list (stays in project context)
  - Removed broken external /members/ link in favor of inline expand
- Client-side aggregation of cost, duration, models, tools from SessionSummary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ution

- Rename user_id → dir_name in get_project_mapping() for consistency
  with all other scan sites (mapping keys use filesystem dir name)
- Fold fixup loop into main indexer loop (single iteration instead of two)
- Move _load_remote_titles import out of loop body to module-level import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ouping, and cleanup

- Fix remote sessions API to resolve user_id to filesystem directory via
  cached manifest lookup (jay-mac-mini -> Jayants-Mac-mini.local dir)
- Add _resolve_user_dir() using cached _resolve_user_id() from services
- Fix list_remote_users to return resolved user_id, not raw hostname
- Fix list_user_projects to use resolved dir name for manifest loading
- Fix watcher_manager session_packaged events missing member_name
- Fix indexer hostname-to-member resolution via Syncthing device lookup
- Revert activity feed event grouping back to flat chronological timeline
- Remove duplicate formatBytes (import from $lib/utils)
- Remove duplicate formatEventTime (use formatRelativeTime from $lib/utils)
- Remove redundant API calls from member page server load
- Add project name resolution from profile teams in MemberSessionsTab
- UI polish: team tabs overview, members, projects, activity redesign

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Reorder conditional flow on agents and tools pages so members/analytics
  views render before empty-state checks (fixes member view never showing)
- Hide category/source sub-filter when By Member view is active
- Reset sub-filter to 'all' when switching to members view
- Add URL ?view= state persistence for shareable view links
- Extract shared createUrlViewState utility to deduplicate URL sync logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… URLs

- Add GET /sync/members API endpoint listing all members across teams
- Update GET /sync/members/{identifier} to accept both name and device_id
- Update GET /sync/members/{identifier}/activity for same dual-lookup
- Rename frontend route from [user_id] to [device_id] param
- Create /members index page with member grid and online status
- Update breadcrumbs: Dashboard → Members → {name}
- Add "Members" entry to header nav dropdown
- TeamMembersTab links now use device_id instead of member name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ation, remove dead code

- Extract _resolve_member() helper to deduplicate name/device_id lookup
- Restore input validation (ALLOWED_MEMBER_NAME + ALLOWED_DEVICE_ID) on member endpoints
- Activity endpoint now returns 404 for unknown identifiers (was silent empty)
- Remove redundant DISTINCT from GROUP BY query
- Remove unused getTeamMemberColor import and @const in members index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…file

- Load local device_id via _load_identity() in /sync/members and /sync/members/{id}
- Add is_you flag to both API responses by comparing device_ids
- Render "You" badge on members index page (matching TeamMembersTab style)
- Render "You" badge on member detail profile header
- Show local user as always online (connected || is_you)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ters

Replaces the basic expand-by-project tree view on the members/{device_id}
sessions tab with the same UI/UX pattern used by the skills history tab:

Backend:
- Add `user` query parameter to GET /sessions/all to filter by remote_user_id
- Plumbed through SQLite (db/queries.py) and JSONL fallback paths
- Include `user` in cache-busting logic

Frontend (MemberSessionsTab.svelte):
- Fetch rich SessionWithContext data from /sessions/all?source=remote&user={id}
  instead of basic file metadata from /remote/users/... endpoints
- GlobalSessionCard for rich session cards (model, title, duration, messages)
- TokenSearchInput for multi-token AND search
- FiltersDropdown (desktop) / FiltersBottomSheet (mobile) for scope, status,
  date range filters
- ActiveFilterChips with remove buttons
- Project filter pills keyed by project_encoded_name to deduplicate worktrees
  and subdirectories (fixes bug where same project appeared as 3 pills)
- View mode toggle (List/Grid) with localStorage persistence
- Date-based grouping (Today, Yesterday, This Week, This Month, Older)
- Empty state with filter-aware messaging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…on loaders

- Merge redundant dual headers into single unified profile header matching
  PageHeader pattern (icon box, title size, spacing, border)
- Use member's color for icon tint, "You" badge, and inline stat chips
- Move stats from StatsGrid into compact header chips (sessions, projects,
  teams, last active)
- Fix breadcrumb navigation: preserve history.state in replaceState calls
  to prevent SvelteKit router state corruption (members + team pages)
- Add MembersPageSkeleton and MemberDetailSkeleton for loading states
- Register skeleton mappings for /members, /members/*, /team, /team/* routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… grid

Replace About nav card with Members using Contact icon and new rose color.
Reorder home grid: row3=Tools/Plugins/Commands, row4=Teams/Sync/Members,
row5=Hooks/Archived/Settings. Update members page header to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Session stats were only fetched on SSR load but not during client-side
polling, causing the Overview chart, Members sparklines, and Sessions
Shared stat to go stale. Added session-stats to the fetchTeamData
polling batch.

Also aligned the SSR pendingFolders filter with the polling filter to
only include 'sessions' and 'outbox' folder types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…complete data

The SessionPackager was blindly globbing all *.jsonl files, including
sessions actively being written to by Claude Code. This caused three
problems: incomplete sessions synced to team members, constant
re-packaging on every tool use (watcher fires on each JSONL write),
and potential file corruption from copying mid-append.

Added _get_live_session_uuids() which cross-references live session
state files (~/.claude_karma/live-sessions/) to identify active UUIDs.
discover_sessions() now filters these out by default (exclude_live=True).

Includes a 30-minute staleness timeout: sessions idle longer than that
are considered crashed (SessionEnd hook never fired) and are packaged
normally, preventing them from being excluded forever.

Backward compatible — when hooks aren't configured (no live-sessions
dir), returns empty set so all sessions pass through as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Batch per-project session counts into a single query instead of N
separate COUNT(*) queries per project. Scope session stats query to
the specific member (new member_name param) to avoid aggregating all
members then filtering client-side.

Split total_sessions into sessions_sent/sessions_received on the
member profile header. Add "Load more" pagination to MemberSessionsTab
for members with 200+ sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The session_packaged and session_received events are both logged on the
LOCAL machine with member_name set to the session's author. This means:
- session_packaged (member=local_user): local user packaged their sessions
- session_received (member=sender_id): we received sessions FROM sender

Both represent "sessions contributed by this member" — they are mutually
exclusive (local user gets packaged events, remote members get received
events). The old code treated them as separate sent/received counts,
causing sent=0 for remote members and received=0 for the local user.

Fix: combine both event types into a single "sessions" count in the
query layer and simplify the frontend to show total sessions instead
of a broken sent/received split.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TeamOverviewTab had the same broken sent/received split as
MemberOverviewTab — remote members showed sent=0 because
session_packaged events only exist on their machine. Simplified
to a single "Sessions" bar per member using the combined count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Team overview chart now shows two bars per member:
- Out (solid): sessions they contributed to the team
- In (faded): sessions they received from other members
  (computed as total team sessions minus their own out)

Member profile chart now shows two bars per day:
- Out: sessions this member contributed
- In: sessions from other members (new incoming_stats API field)

Also fixes label cutoff on team chart by allowing 45° rotation for
long member names, and uses full names instead of 8-char truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…simplified charts

Session event logging:
- sync_now and watcher now log one session_packaged event per unique
  session UUID with deduplication (previously watcher logged one per
  trigger regardless of session count, sync_now logged none)
- Migration v13 backfills session_packaged events from actual session
  data with correct start_time timestamps (auto-runs on API startup)

Date grouping:
- Switch DATE(created_at) to DATE(created_at, 'localtime') in
  query_session_stats_by_member and incoming_stats queries so charts
  group by user's local date instead of UTC

Charts:
- Simplify team overview to single "Contributed" bar per member
  (removed redundant "In" bar which was just total minus out)
- Switch member profile from bar chart to line chart with filled area
- Hide legend on both charts (single dataset, title is sufficient)
- Fix name truncation in activity chart (removed slice(0,8) fallback)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix UTC/localtime mismatch: date range filters now use
  datetime('now', 'localtime', ...) to match DATE(created_at, 'localtime')
  grouping in all 3 query locations
- Fix v13 migration data loss: only delete session_packaged events when
  backfill can proceed (local_user exists); log warning when skipped
- Extract log_session_packaged_events() helper in sync_queries.py to
  eliminate duplicated dedup SQL between sync_status.py and watcher_manager.py
- Batch SQLite commits: helper inserts all events then commits once
  instead of N individual commits per watcher trigger
- Replace bare except pass with logger.debug in watcher event logging
- Move json/Path imports to module level in schema.py
- Add ellipsis truncation (16 chars) for unknown user IDs in chart labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ping

The "Sessions Received" stat on the sync page showed 0 because the
received_counts logic iterated DB member names as directory paths, but
Syncthing creates inbox directories using hostnames (e.g.
Jayants-Mac-mini.local), not the friendly member name (jay-mac-mini).

Changes:
- Add device_id field to SyncManifest (cli + api models)
- Pass device_id from config through SessionPackager at all 3 callsites
  (CLI watch, API watcher, API sync-now)
- Update _resolve_user_id in services/remote_sessions.py with 3-tier
  resolution: manifest.device_id → sync_members DB → user_id → dir name
- Replace DB-member-based received_counts with filesystem scan that
  skips only the local user's outbox
- Remove duplicate resolver from router (single cached resolver in
  service layer)

No migration needed — device_id is in manifest JSON, not SQLite.
Existing manifests without device_id fall back to user_id path.
Re-packaging sessions (Sync All Now) writes the new device_id field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract shared cleanPromptText() to utils.ts, eliminating duplicate
implementations across ExpandablePrompt, SessionChainView, and adding
prompt cleaning to SubagentCard, CommandPalette, ProjectTeamTab, and
getSessionDisplayPrompt. Use shared copyToClipboard in ArchivedPromptCard.
Normalize expand/collapse button text to "Show more/Show less". Fix
cleanAndTruncate offset bug in SessionChainView and command-args content
leak in cleanPromptText regex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… counts

Race condition between reindex_all (_indexing_lock) and
trigger_remote_reindex (_reindex_lock) caused duplicate session_received
events — both load db_mtimes before either commits, so both log events
for the same sessions.

- Use COUNT(DISTINCT session_uuid) in all session stats queries
- Add dedup check in indexer before logging session_received
- Migration v14 removes existing duplicate events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
JayantDevkar and others added 30 commits March 22, 2026 21:21
The share_project endpoint stored encoded_name as null when not provided
in the request body. The packager then couldn't find the project directory
to package sessions. Now auto-resolves from git_identity by looking up
the sessions table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace fragile LIKE query with exact suffix match + shortest candidate
selection. Prevents matching subdirectories or similarly-named projects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Fix list_all → list_active in reconciler — dissolved teams were
   being reconciled every 60s (bug from soft-delete change in c5c4b73)

2. Extract build_folder_config() — single source of truth replacing
   3 copy-pasted 10-key Syncthing folder config dicts in reconciler,
   sync_pending router, and folder_manager

3. Consolidate identity resolution — new resolve_encoded_name() in
   db/queries.py replaces ad-hoc SQL in sync_projects router and
   duplicated inline closure in main.py. Uses projects table first,
   falls back to sessions table with exact suffix match

4. Add team incarnation UUID — team_id field on Team domain model,
   written to team.json and removal signals. Stale signal detection
   now uses team_id match instead of fragile 60s timestamp heuristic.
   Schema v22 migration with backfill for existing teams

Also: dissolve_team() now explicitly cleans child rows (members,
projects, subscriptions) since soft-delete means CASCADE no longer
fires.

Adds docs/sync-v4-status-report.md documenting unaddressed issues,
testing timeline, UI coverage matrix, and remaining work to ship.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a structured timeline section at the top of the status report
with completed milestones (Mar 7-23), in-progress items, remaining
work to ship (3 blocking + 3 optional test sessions), and a summary
with key metrics (81 bugs fixed, 153 cross-machine steps executed,
1991 tests passing, 0 new bugs in last 2 days).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New 48-step cross-machine scenario covering last 3 blocking test gaps:
team detail (all tabs), members list, sync overview stats accuracy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The v19 migration drops and recreates sync_teams but was missing the
team_id column added by the architectural review. The v22 migration
patches it with ALTER TABLE, but if the API starts between v19 and v22
(or sync tables are created by /sync/init bypassing migrations), the
team_repo._row_to_team() crashes with IndexError: No item with key 'team_id'.

Found during cross-machine testing scenario 5 (48-step page verification).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. v22 backfill misses NULL values — SQLite ALTER TABLE ADD COLUMN gives
   existing rows NULL (not ''), so WHERE team_id = '' skips them. Fixed
   to: WHERE team_id = '' OR team_id IS NULL

2. _row_to_team() generated random uuid4() on every read when team_id
   was empty — defeating incarnation tracking since the same team gets a
   different team_id on each read. Fixed to use deterministic uuid5
   derived from team name, so reads are stable.

Found by oh-my-claudecode:analyst during post-test review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scenario 5 (48 steps) verified all 3 remaining blocking test gaps:
- Team detail (all 5 tabs, leader+member perspectives)
- Members list (search, dedup, online/offline)
- Sync overview (stats accuracy, project sync status)

84 total bugs found and fixed. 201 cross-machine steps executed.
UI completeness at ~95%. 0 blocking test sessions remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…chine_tag to /status

- /sync/members now deduplicates by device_id instead of member_tag,
  fixing duplicate entries when same device has different member_tags
  across teams. Falls back to member_tag when device_id is empty.
- Filter out dissolved teams from member aggregation — dissolved teams
  no longer contribute ghost members to the global list.
- Add machine_tag to /sync/status response alongside machine_id,
  enabling scenario YAML captures to resolve correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. received_counts always {}: indexer fallback now queries
   sync_projects.encoded_name when git_identity resolution fails

2. Stale activity on team name reuse: dissolve_team() now deletes
   sync_events and sync_removed_members (soft-delete bypassed CASCADE)

3. Subscriptions not offered to ADDED members: share_project() guard
   changed from is_active to status != REMOVED

4. Stale invitation banner after dissolution: PendingInvitationCard
   fetches teams with include_dissolved=true for knownTeams filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…npair, created_at

1. PRAGMA foreign_keys = ON per connection: ensures CASCADE actually
   fires on all sync endpoints, not just during migration

2. sync_events cleanup in _auto_leave() and leave_team(): both now
   DELETE sync_events before hard-deleting the team row

3. Device unpairing in dissolve_team(): non-leader devices are now
   unpaired if not shared with other teams (matching leave/auto-leave)

4. created_at updated on team name reuse: ON CONFLICT upsert now
   includes created_at so recreated teams get fresh timestamps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	api/db/indexer.py
#	api/db/schema.py
#	api/main.py
#	api/requirements.txt
#	api/routers/projects.py
#	api/tests/test_git_identity.py
#	frontend/src/app.css
#	frontend/src/lib/api-types.ts
#	frontend/src/lib/components/GlobalSessionCard.svelte
#	frontend/src/lib/components/Header.svelte
#	frontend/src/lib/components/NavigationCard.svelte
#	frontend/src/lib/components/SessionCard.svelte
#	frontend/src/lib/components/StatsCard.svelte
#	frontend/src/lib/components/command-palette/CommandPalette.svelte
#	frontend/src/lib/components/commands/CommandsPanel.svelte
#	frontend/src/lib/components/conversation/ConversationHeader.svelte
#	frontend/src/routes/+page.svelte
#	frontend/src/routes/about/+page.svelte
#	frontend/src/routes/plans/[slug]/+page.svelte
#	frontend/src/routes/projects/[project_id]/+page.svelte
…ration guard, subagent_types NameError

- schema.py: remove duplicated git_identity in CREATE TABLE projects (union artifact)
- schema.py: guard v21 remote_user_id normalization for minimal-fixture DBs
- subagent_types.py: Phase 2.5 referenced old 'result' dict name; main renamed to 'types'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om main, --radius alias

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng-poll

Replaces the poll-only 60s timer with a triggerable worker:
- SyncthingEventListener long-polls /rest/events (PendingDevicesChanged,
  PendingFoldersChanged, DeviceConnected, ConfigSaved, and ItemFinished/
  FolderCompletion on karma-meta--* folders) and triggers reconciliation
  within seconds instead of the next sweep
- ReconciliationTimer becomes a worker thread: trigger(reason) coalesces
  event bursts (2s debounce); 60s interval remains as fallback sweep
- WatcherManager wires the listener and exposes reconciliation/event
  stream health in status() for the UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…box badge

Fine-grained control stays; the friction moves behind defaults:
- schema v24: sync_team_prefs — local per-team policy (ask / auto_accept /
  receive_only + default_direction), never synced
- reconciliation applies the policy when a new offer is discovered, running
  the same ProjectService.accept path the UI uses (folders, metadata, events)
- GET/PUT /sync/teams/{name}/prefs, POST /sync/subscriptions/{team}/accept-all,
  GET /sync/inbox (pending devices + folders + offered subs in one list)
- invitation card: single Accept using the team default; direction picker
  collapsed behind Customize; Accept-all button for bursts
- policy row on Projects tab; SyncInboxBadge in header; Collaborate nav group
  and homepage section restored (teams/members/sync icons, --nav-rose tokens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/polling indicator

- /sync/members: connection_type (direct|relay|offline) from Syncthing
  connection type + last_seen from /rest/stats/device
- members page: relay chip with explanation tooltip, 'last seen Xh ago'
  for offline members — hours-late invites now have a visible cause
- /sync/status: watcher block (reconciliation worker stats + event stream
  health) via new WatcherManager singleton
- sync overview: 'Live — reacting instantly' vs 'Polling — every 60s'
- lifespan: sync worker now starts even with zero teams — fresh joiners
  previously had NO reconciliation loop, the root cause of invites never
  arriving on new machines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or multi-peer testing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…harness

Simulation (scripts/sync_sim.py) runs N isolated karma+Syncthing stacks on
one machine and drives the full lifecycle over real P2P transfer. 17/17
steps pass: pair 6s -> team discovery 9s -> policy auto-accept 18s ->
session transfer+index both directions ~15s.

Root-cause fixes it surfaced:
1. /sync/init never started the sync worker — a freshly initialized machine
   had NO reconciliation loop until API restart (invites never arrived).
   Extracted services/sync_bootstrap.py, called from lifespan AND init.
2. Peer subscriptions without a local row (e.g. the leader's own accepted
   sub) were skipped during metadata sync, so Phase 3 device lists excluded
   the sender — folders never synced. Now created from peer metadata.
3. Phase 3 'recovery' created sendonly OUTBOX folders for every member's
   tag on every machine, blocking receiveonly inbox creation (folder ID
   taken) — receivers silently never got data. Now: own tag -> outbox;
   teammates' tags -> inbox (when my sub direction allows receive).
Also: missing app_settings import crashed the event listener at startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-only never received

Found by the 4-peer mesh simulation (28/28 steps now pass):

1. Phase 3 device lists were computed from SENDERS only, so receive-only
   members were never added to anyone's outbox and could not receive at
   all. Now: each outbox syncs to its owner + every receive|both member.
2. remove_member unpaired the device in the same breath as writing the
   removal signal — deleting the device from Syncthing config severs the
   metadata channel, so the removed machine never learned it was removed.
   Unpair is now deferred to a reconciliation sweep after a grace period
   (sync_removed_unpair_grace_seconds, default 15 min).
3. dissolve_team deleted the metadata folder + unpaired all members
   immediately, so dissolution signals never synced and members never
   auto-left. The metadata folder now survives dissolution until the
   grace sweep (cleanup_dissolved_metadata) removes it.

Verified: 4-peer sim (3-member mesh both directions, receive-only no-leak,
overlapping teams, cross-team isolation, removal auto-leave 15s, dissolve
auto-leave 12s, sibling team survives) + 2-peer regression 17/17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ses, demo instructions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, 16/16 pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blic

import.meta.env only exposes VITE_-prefixed vars; the documented
PUBLIC_API_URL override was silently dead and every setup leaned on the
hardcoded fallback (which this branch had stale at :8000). Fallback now
matches main (:8020).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Add Projects dialog sends the raw remote URL; used verbatim it poisons
the Syncthing folder suffix (':' and '/') and never matches the indexer's
normalized owner/repo identities on other machines — receivers couldn't
resolve the project and packaging failed with the LEADER's encoded_name.
Found live in the manual two-machine walkthrough.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing + UI freeze

1. resolve_packagable_projects used sync_projects.encoded_name (the
   SHARER's machine-specific path from metadata) instead of resolving the
   git identity against the local project index — non-leader machines
   packaged nothing ('Project dir not found: <leader's path>').
2. POST /sync/package ran file copying synchronously on the event loop,
   freezing every API request (and the whole UI) while packaging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sim peers now use DIFFERENT local paths for the same project (identical
paths masked the leader-encoded_name packaging bug) and alice shares via
the raw git URL (guards normalization). Plus unit tests for both. Sim
ports are env-overridable so it can run beside a live stack. 18/18 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…olicy sweep, display fix

- GET /sync/teams/{name}/transfer-status: per-folder completion from
  Syncthing; Projects tab polls it (3s) and renders progress bars
- /team and /members pages refresh every 10s (no more manual reloads)
- Sync Now surfaces per-project errors inline instead of swallowing them
- changing policy to auto offers to accept existing pending invitations
- project display names match by NORMALIZED git identity — receivers
  previously showed a mangled fallback ('code-karma') and no link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etches

PendingInvitationCard showed a skeleton before knowing whether any
invitation existed; when its first fetch hung (API mid-restart, stalled
event loop) the finally block never ran and the skeleton froze on screen
forever. The card now renders nothing until data actually exists, and
every fetch it makes is bounded by AbortSignal.timeout(10s).

safeFetch (used by every page's server load via fetchAllWithFallbacks)
gets the same 10s bound — a hung API request was blocking SvelteKit's
server load indefinitely, which froze all navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the previous fix from PendingInvitationCard to all 41 raw
fetch() calls across sync/team components (OverviewTab, SetupWizard,
TeamProjectsTab, tabs, dialogs, badge, pairing card, pages). New shared
boundedFetch helper in api-fetch.ts enforces a 10s AbortSignal timeout
(composed with any caller signal via AbortSignal.any) so a hung API can
no longer freeze skeletons, spinners, or loading flags anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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