Skip to content

[200 MRG] feat(presence): collaborative workspace presence (Fixes #30) - #89

Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/workspace-presence
Open

[200 MRG] feat(presence): collaborative workspace presence (Fixes #30)#89
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/workspace-presence

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown

Fixes #30

Acceptance for this bounty is Design + MVP, so this PR ships both: docs/presence.md (the design) and a working presence layer behind the API, the CLI and tests.

What Lappa does today

Two people pointed at the same workspace — a share, a NAS, a synced folder, or just two IDE processes on one machine — cannot see each other at all. PUT /api/files writes the file; the second save silently replaces the first, and nothing anywhere hints that somebody else was in it. Presence is the smallest thing that makes the other person visible before that happens.

The protocol

Every session writes one small JSON record into a shared presence directory and refreshes it on a heartbeat. Listing that directory is the whole discovery mechanism. No server, no ports, no database, no new dependency.

<workspace root>/.lappa/presence/
├── peer-a17f3c9d2e01.json     # alice, editing launch/sim.launch.py
└── peer-51bb0f7743aa.json     # bob, sim running

Directory resolution: explicit --dirLAPPA_PRESENCE_DIR<first workspace root>/.lappa/presence → app-data fallback. Defaulting to the workspace root is why this needs no infrastructure: if two people can open the same package, they can already write the same folder.

Heartbeat 15 s, TTL 45 s — two missed beats still leave a peer online, so a slow share does not make the list blink.

The two parts that are not obvious

1. Freshness comes from mtime, not from the timestamp inside the record. The obvious version compares the peer's seen_at against your now. On a shared drive that is wrong: hosts drift, and a laptop whose clock is five minutes behind would look permanently offline to everyone — present, heartbeating, invisible. Both numbers now come from the same clock (the filesystem's), so drift cancels. seen_at is still surfaced as clock_skew_s so the UI can report a bad clock instead of hiding the person behind it.

2. A crashed IDE leaves a fresh-looking record. Nothing deletes it, so it stays "online" until the TTL runs out. When a record claims our own host, the pid is checked directly and the dead session drops out at once. A pid from another host is never second-guessed — it means nothing in our process table — and a recycled pid can only make a dead peer look alive, which is the safe direction to be wrong in.

Records are written to a temp file and moved in with os.replace, so a reader sees the whole old record or the whole new one, never half of one. Reads tolerate torn/empty/foreign files instead of throwing: a shared folder is not a database and eventually somebody drops a README.txt in it.

Save conflicts

PUT /api/files now answers with whoever else has that file open:

{"ok": true, "path": "launch/sim.launch.py",
 "conflicts": [{"user": "bob", "host": "bob-nuc", "session_id": "51bb…", "age_s": 3.1}]}

Advisory only — the save always happens, presence never takes a lock, and clients that ignore the new field behave exactly as before. GET /api/presence/file?path=… answers the same question without writing anything, so the IDE can show it the moment a file is opened.

Two deliberate boundaries, both pinned by tests: GET /api/presence never creates a record (a status poll must not become a peer), and a save only refreshes a record for a session that already joined.

Surface

HTTP GET /api/presence, POST /api/presence/join|heartbeat|leave, GET /api/presence/file
CLI lappa presence list | where | session | reap | dir
Docs docs/presence.md — design, record format, failure-mode table, out-of-scope

lappa presence session --hold 60 joins, heartbeats and leaves on exit (including Ctrl-C) — two terminals are enough to demo multi-user presence on one machine without a share.

Verified live, not only in unit tests

Real lappa serve on :8899, a second process holding a file via the CLI, a third process listing:

4) IDE session joins over HTTP
   peers: [('alice', None, True), ('bob', 'live_demo.txt', True)]
5) alice saves the file bob is holding
   {"ok": true, "path": "live_demo.txt",
    "conflicts": [{"user": "bob", "host": "sma-new", "session_id": "e43cc6f5354b", "age_s": 1.9}]}
7) presence list from a third process
   │ alice │ sma-new │ diff_drive_2w │ live_demo.t… │ - │ 0s ago │ 9226e3a60f35 │
   │ bob   │ sma-new │ diff_drive_2w │ live_demo.t… │ - │ 2s ago │ e43cc6f5354b │

Then kill -9 on bob: his record file is still on disk, and he is gone from the live list within a second (dead pid, same host), still visible under --stale. leave removes the file.

Tests

41 new (tests/test_presence.py 28, tests/test_presence_api.py 13); full suite 195 passed, 3 skipped (154 before). No existing test touched. Among them: a peer 600 s off-clock stays online with the skew reported; a dead pid on this host is offline while the same dead pid on another host is trusted; torn/empty/list-shaped/foreign files are skipped; ../../evil as a session id is refused rather than sanitised; reap keeps recently-stale records but clears long-abandoned ones; a broken presence directory returns 503 on join and still lets the file save succeed with 200.

Mutations, both directions — freshness back to the writer's clock → 6 tests fail; pid check removed → 1; touch() auto-joining → 1; reap deleting anything stale → 1; peers_on_file counting yourself → 1. All reverted, 195 green.

ruff check src tests: the three new files are clean. The five findings inside cli.py are B008 on typer.Option defaults — the same pattern every existing command in that file uses (11 pre-existing findings there).

Deliberately out of scope

Not a lock, not co-editing (no OT/CRDT), and no lost-update protection yet: PUT /api/files is still last-write-wins, presence only makes the collision visible. The natural next step is optimistic concurrency on the write endpoint (send the mtime/hash you loaded, get a 409 if it moved) — that is a change to the file API rather than to presence, and is better reviewed on its own than half-done here.

Claim ritual done: starred Lappa / mergeos / mergeos-contracts, I claim this bounty on #30, and the claim comment with this issue link on mergeos#1.

)

Peers announce themselves by writing one JSON record into a shared
presence directory and refreshing it on a heartbeat; listing that
directory is the whole discovery protocol. No server, no ports, no new
dependency - the workspace root the collaborators already share is the
default location.

- src/lappa/presence.py: PresenceStore (join/heartbeat/leave/peers/reap),
  atomic os.replace writes, mtime-based freshness so unsynced clocks on a
  share cannot hide a live peer, same-host pid check so a crashed IDE
  drops out at once, tolerant reads (torn/foreign files skipped).
- API: GET /api/presence, POST /api/presence/join|heartbeat|leave,
  GET /api/presence/file. PUT /api/files now reports who else has the file
  open (advisory - the save still happens) and never turns a read-only
  client into a peer.
- CLI: lappa presence list|where|session|reap|dir.
- docs/presence.md: design, record format, failure modes, and what is
  deliberately out of scope (no locking, no co-editing, lost-update
  protection still to come).
- 41 tests (28 store + 13 API/CLI); suite 195 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.

[200 MRG] Feature: collaborative workspace presence (optional)

1 participant