Documentation
¶
Overview ¶
Package collab implements the first slice of ADR-0140: live collaborative modeling sessions. A collaboration session is an ephemeral, design-time coordination object attached to a draft (by process id). It tracks who is present, which BPMN element each participant has locked, and fans out presence / lock / change events to every participant over Server-Sent Events so several people (and an AI agent joined over MCP) can co-edit one draft in real time.
It is NOT event-sourced, NOT durable across a restart, and never touches the engine — the durable artifact remains the draft (ADR-0021); a session is only the live coordination around editing it. Like the login session store (auth.go) it is reached from concurrent HTTP handler goroutines rather than the run loop, so it guards itself with a mutex and sits entirely outside the six invariants.
Concurrency model (ADR-0140, first cut): per-element soft locks. An editor acquires an element before mutating it; a second acquire of a held element is refused. Edits to different elements proceed in parallel. This is always correct — there is no merge algorithm to get wrong — and leaves the transport and session API unchanged when a later slice upgrades to an operation log / CRDT.
Index ¶
- Constants
- type Event
- type Lock
- type Participant
- type Presence
- type Registry
- func (reg *Registry) AcquireLock(draftID, participantID, elementID string) (granted, ok bool)
- func (reg *Registry) CanEdit(draftID, participantID string) (canEdit, ok bool)
- func (reg *Registry) Change(draftID, participantID, elementID, xml string) bool
- func (reg *Registry) Join(draftID, userID, name string) (*Participant, []byte, func())
- func (reg *Registry) JoinDetachedAs(draftID, userID, name string, canEdit bool) (*Participant, []byte)
- func (reg *Registry) JoinStream(draftID, userID, name string, canEdit bool) (*Participant, []byte, func())
- func (reg *Registry) Leave(draftID, participantID string)
- func (reg *Registry) Poll(draftID, participantID string) ([]byte, bool)
- func (reg *Registry) Presence(draftID, participantID, selection string) bool
- func (reg *Registry) Reap() int
- func (reg *Registry) ReleaseLock(draftID, participantID, elementID string) bool
Constants ¶
const ( LockAcquire = "acquire" LockRelease = "release" )
Lock actions a participant may request on an element.
const ( ParticipantTTL = 90 * time.Second ReapInterval = 30 * time.Second )
ParticipantTTL is how long any participant may go without a sign of life before the reaper evicts it and releases its locks. An MCP agent refreshes it by polling; a browser refreshes it on every action and on a periodic heartbeat (the client re-announces presence well inside this window). A crashed agent or a forgotten tab falls silent and is cleaned up so its locks never wedge an element forever. ReapInterval is how often the reaper sweeps.
const (
EventSync = "sync"
)
Collaboration event types fanned out on a session's stream. Mirrors ADR-0140: sync is the full snapshot a newcomer receives on join; presence, lock, and change are the incremental frames. presence and lock frames each carry the *entire* current roster / lock set, so a dropped frame self-heals on the next one (reconnect/replay is an ADR-0140 follow-up, not needed for correctness).
const KeepaliveInterval = 15 * time.Second
KeepaliveInterval is how often an idle SSE session stream writes a keepalive comment. A failed write reveals a dropped browser connection and reaps it at once (via the deferred leave()); the TTL reaper above is the slower backstop for a half-open connection whose keepalive write still succeeds into a dead socket.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Event ¶
Event is one frame delivered to a participant's SSE stream. Data is the already-marshaled JSON payload; Seq is a per-session monotonic counter carried as the SSE id so a future reconnect can resume (ADR-0140 follow-up).
type Lock ¶
type Lock struct {
ElementID string `json:"elementId"`
HolderID string `json:"holderId"`
HolderName string `json:"holderName"`
}
Lock is a soft, per-element edit lock held by one participant.
type Participant ¶
type Participant struct {
ID string
UserID string // resolved principal (ADR-0044); empty when auth is off
Name string // display name
// contains filtered or unexported fields
}
Participant is one connected editor — a person in the Modeler or an AI agent joined over MCP. ch is its SSE outbox; selection is the element it currently has selected (presence).
detached marks a participant with no live SSE stream (an agent that joined over MCP and reads by polling) versus a streaming browser. Every participant carries lastSeen — refreshed on each action, and for a browser also on a periodic client heartbeat — and the TTL reaper evicts any that falls silent past the TTL. A browser stream is normally reaped the instant its connection drops (a failed keepalive write); the TTL is the backstop for a half-open connection whose write keeps succeeding into a dead socket, so a forgotten tab can never wedge a lock.
func (*Participant) Events ¶
func (p *Participant) Events() <-chan Event
Events is the participant's outgoing frame stream, consumed by the SSE handler that owns this participant. It is closed when the participant is reaped or the session torn down, which is how that handler learns to end the response.
type Presence ¶
type Presence struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Name string `json:"name"`
Selection string `json:"selection,omitempty"`
}
Presence is one participant as seen by others in a presence snapshot.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds every live draft session in memory. It is mutex-guarded because concurrent HTTP handlers (SSE streams and POSTs) reach it directly; it is not engine state and never persists (ADR-0140).
func NewRegistry ¶
func NewRegistry() *Registry
NewRegistry builds an empty registry whose participant ids are random hex (unguessable, unique without a counter), matching the session-token style.
func (*Registry) AcquireLock ¶
AcquireLock grants participantID the edit lock on elementID. granted is false when another participant already holds it (the conflict the first-cut model forbids); ok is false when the session or participant is unknown. Re-acquiring a lock you already hold succeeds idempotently.
func (*Registry) CanEdit ¶
CanEdit reports whether a participant may mutate the session (lock/change/ presence), snapshotted from its project role at join time (ADR-0140/0071). ok is false when the session or participant is unknown. Like ADR-0044's role snapshot, a role change takes effect on the participant's next join.
func (*Registry) Change ¶
Change fans a participant's element edit out to the session. The registry does not persist it — the draft's durable save path (ADR-0021) is separate — it only relays the change so every open canvas updates live. ok is false when the session or participant is unknown.
func (*Registry) Join ¶
func (reg *Registry) Join(draftID, userID, name string) (*Participant, []byte, func())
Join adds a streaming (browser SSE) participant that may edit — the default for an open (auth-off) session. JoinStream is the scope-aware variant the HTTP handler uses to pass the draft's project role (ADR-0140/0071).
func (*Registry) JoinDetachedAs ¶
func (reg *Registry) JoinDetachedAs(draftID, userID, name string, canEdit bool) (*Participant, []byte)
JoinDetachedAs adds a participant with no live stream (an AI agent over MCP, ADR-0140 M2), carrying its edit capability. It returns the participant and its sync snapshot but no leave closure: such a participant leaves explicitly, or the reaper evicts it once it falls silent past the TTL.
func (*Registry) JoinStream ¶
func (reg *Registry) JoinStream(draftID, userID, name string, canEdit bool) (*Participant, []byte, func())
JoinStream adds a streaming (browser SSE) participant and returns the participant, the marshaled sync snapshot it should receive first (self id, roster, locks), and a leave function the caller defers to tear the participant down on disconnect. canEdit records whether the participant's project role lets it change the model (editor/owner) or only watch (viewer). Other participants receive a presence frame reflecting the arrival.
func (*Registry) Leave ¶
Leave removes a participant, releasing any locks it held, and notifies the rest. When the last participant leaves, the session is discarded so an idle draft holds no memory.
func (*Registry) Poll ¶
Poll drains a participant's buffered frames and returns them together with the current roster and lock set, marshaled as JSON. It is the non-streaming counterpart of the SSE endpoint: an agent that cannot hold an event stream calls this to see what changed (and it doubles as the agent's liveness signal). The full roster/lock snapshot means a poll is self-correcting even if buffered change frames were dropped under load. ok is false for an unknown session or participant (a stale agent that should rejoin).
func (*Registry) Presence ¶
Presence records a participant's new selection and broadcasts the roster. It reports ok=false when the session or participant is unknown (a stale client).
func (*Registry) Reap ¶
Reap evicts every participant that has gone silent past the TTL, releasing its locks so a crashed MCP agent or a forgotten browser tab never holds an element forever. A browser is normally reaped the instant its stream drops; this is the backstop for a half-open connection — its lastSeen is kept fresh by the client's periodic heartbeat, so a live but idle editor is never evicted. It broadcasts an updated roster (and lock set, if any changed) to each affected session, discards any that empties, and returns how many participants it removed.
func (*Registry) ReleaseLock ¶
ReleaseLock drops participantID's lock on elementID. It is idempotent: it succeeds whether or not the lock was held, but only touches a lock the caller actually owns. ok is false when the session or participant is unknown.