From a17b4e5f22c68b3271fb5d089917faeb3914ef75 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 15 Sep 2026 15:17:50 +0200 Subject: [PATCH 01/86] Add collab protocol and transactional shared storage --- lib/collab/protocol.test.ts | 31 ++++ lib/collab/protocol.ts | 145 +++++++++++++++ lib/collab/types.ts | 113 ++++++++++++ server/collab/service.test.ts | 209 ++++++++++++++++++++++ server/collab/service.ts | 218 +++++++++++++++++++++++ server/collab/storage.test.ts | 300 +++++++++++++++++++++++++++++++ server/collab/storage.ts | 327 ++++++++++++++++++++++++++++++++++ 7 files changed, 1343 insertions(+) create mode 100644 lib/collab/protocol.test.ts create mode 100644 lib/collab/protocol.ts create mode 100644 lib/collab/types.ts create mode 100644 server/collab/service.test.ts create mode 100644 server/collab/service.ts create mode 100644 server/collab/storage.test.ts create mode 100644 server/collab/storage.ts diff --git a/lib/collab/protocol.test.ts b/lib/collab/protocol.test.ts new file mode 100644 index 00000000..8e65eac9 --- /dev/null +++ b/lib/collab/protocol.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'bun:test' + +import { isCollabActor, isCollabClientMessage, isCollabIdentity } from './protocol' + +test('collab validates injected profiles as data and bounds identifiers and presence', () => { + const identity = { id: 'anna', name: 'Anna', color: '#7c3aed' } + expect(isCollabIdentity(identity)).toBe(true) + expect(isCollabClientMessage({ type: 'join', version: 1, identity })).toBe(true) + expect(isCollabClientMessage({ type: 'join', version: 2, identity })).toBe(false) + expect(isCollabIdentity({ ...identity, id: '🙂'.repeat(100) })).toBe(false) + expect(isCollabIdentity({ ...identity, id: 'a\0b' })).toBe(false) + expect(isCollabActor({ id: 'agent', kind: 'agent' })).toBe(true) + expect(isCollabActor({ id: 'agent', kind: 'admin' })).toBe(false) + expect( + isCollabClientMessage({ + type: 'presence:set', + registrationId: 'field', + surface: 'board', + channel: 'focus', + value: 'x'.repeat(5000) + }) + ).toBe(false) + expect( + isCollabClientMessage({ + type: 'mutate', + scope: 'board', + operationId: 'op', + operations: [{ type: 'set', key: 'value', value: NaN }] + }) + ).toBe(false) +}) diff --git a/lib/collab/protocol.ts b/lib/collab/protocol.ts new file mode 100644 index 00000000..687190fe --- /dev/null +++ b/lib/collab/protocol.ts @@ -0,0 +1,145 @@ +import type { + CollabActor, + CollabClientMessage, + CollabCommand, + CollabIdentity, + CollabJsonValue, + CollabLocation, + CollabOperation +} from './types' + +export const COLLAB_PROTOCOL_VERSION = 1 +export const COLLAB_MAX_MESSAGE_BYTES = 256 * 1024 +export const COLLAB_MAX_PRESENCE_BYTES = 4 * 1024 +export const COLLAB_MAX_AVATAR_BYTES = 8 * 1024 + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isCollabString(value: unknown, max = 256): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !value.includes('\0') && + new TextEncoder().encode(value).length <= max + ) +} + +export function isCollabJson(value: unknown, depth = 0): value is CollabJsonValue { + if (depth > 24) return false + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) + if (Array.isArray(value)) return value.every(item => isCollabJson(item, depth + 1)) + if (!isRecord(value)) return false + return Object.values(value).every(item => isCollabJson(item, depth + 1)) +} + +export function isCollabIdentity(value: unknown): value is CollabIdentity { + return ( + isRecord(value) && + isCollabString(value.id, 240) && + isCollabString(value.name) && + isCollabString(value.color, 64) && + (value.avatar === undefined || isCollabString(value.avatar, COLLAB_MAX_AVATAR_BYTES)) + ) +} + +export function isCollabActor(value: unknown): value is CollabActor { + return ( + isRecord(value) && + isCollabString(value.id, 240) && + (value.kind === 'user' || value.kind === 'agent' || value.kind === 'system') && + (value.onBehalfOf === undefined || isCollabString(value.onBehalfOf)) + ) +} + +function isLocation(value: unknown): value is CollabLocation | null { + return ( + value === null || + (isRecord(value) && + isCollabString(value.page, 1024) && + (value.title === undefined || isCollabString(value.title))) + ) +} + +export function isCollabOperations(value: unknown): value is CollabOperation[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 100 && + value.every( + operation => + isRecord(operation) && + isCollabString(operation.key, 1024) && + (operation.type === 'delete' || (operation.type === 'set' && isCollabJson(operation.value))) + ) + ) +} + +function isOperationIds(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= 100 && value.every(id => isCollabString(id)) +} + +export function isCollabClientMessage(value: unknown): value is CollabClientMessage { + if (!isRecord(value)) return false + switch (value.type) { + case 'join': + return ( + value.version === COLLAB_PROTOCOL_VERSION && + (value.identity === null + ? isCollabString(value.anonymousId, 100) + : isCollabIdentity(value.identity) && + (value.location === undefined || isLocation(value.location))) + ) + case 'identity': + return isCollabIdentity(value.identity) + case 'location': + return isLocation(value.location) + case 'presence:set': + return ( + isCollabString(value.registrationId) && + isCollabString(value.surface) && + isCollabString(value.channel) && + isCollabJson(value.value) && + new TextEncoder().encode(JSON.stringify(value.value)).length <= COLLAB_MAX_PRESENCE_BYTES + ) + case 'presence:delete': + return isCollabString(value.registrationId) + case 'subscribe': + case 'unsubscribe': + return isCollabString(value.scope) && isCollabString(value.subscriptionId) + case 'mutate': + return ( + isCollabString(value.scope) && + isCollabString(value.operationId) && + isCollabOperations(value.operations) + ) + case 'receipts': + return isCollabString(value.requestId) && isOperationIds(value.operationIds) + case 'ping': + return true + default: + return false + } +} + +export function isCollabCommand(value: unknown): value is CollabCommand { + if (!isRecord(value)) return false + switch (value.type) { + case 'snapshot': + return isCollabString(value.scope) + case 'mutate': + return ( + isCollabString(value.scope) && + isCollabString(value.operationId) && + isCollabOperations(value.operations) + ) + case 'receipts': + return isOperationIds(value.operationIds) + case 'export': + return isCollabString(value.path, 4096) + default: + return false + } +} diff --git a/lib/collab/types.ts b/lib/collab/types.ts new file mode 100644 index 00000000..99e29a9b --- /dev/null +++ b/lib/collab/types.ts @@ -0,0 +1,113 @@ +export type CollabJsonValue = + | null + | boolean + | number + | string + | CollabJsonValue[] + | { [key: string]: CollabJsonValue } + +export type CollabIdentity = { + id: string + name: string + avatar?: string + color: string +} + +export type CollabCapability = { + enabled: boolean + referencePath?: string +} + +export type CollabLocation = { page: string; title?: string } +export type CollabActor = { id: string; kind: 'user' | 'agent' | 'system'; onBehalfOf?: string } + +export type CollabOperation = + | { type: 'set'; key: string; value: CollabJsonValue } + | { type: 'delete'; key: string } + +export type CollabScopeSnapshot = { + scope: string + revision: number + entries: Record +} + +export type CollabMutationResult = { + scope: string + operationId: string + revision: number + operations: CollabOperation[] + duplicate: boolean +} + +export type CollabReceipt = + | { operationId: string; status: 'unknown' } + | { operationId: string; status: 'committed'; scope: string; revision: number } + +export type CollabPresenceRegistration = { + registrationId: string + surface: string + channel: string + value: CollabJsonValue +} + +export type CollabParticipant = { + connectionId: string + identity: CollabIdentity + location: CollabLocation | null + presence: CollabPresenceRegistration[] +} + +export type CollabClientMessage = + | { type: 'join'; version: 1; identity: CollabIdentity; location?: CollabLocation | null } + | { type: 'join'; version: 1; identity: null; anonymousId: string } + | { type: 'identity'; identity: CollabIdentity } + | { type: 'location'; location: CollabLocation | null } + | ({ type: 'presence:set' } & CollabPresenceRegistration) + | { type: 'presence:delete'; registrationId: string } + | { type: 'subscribe'; scope: string; subscriptionId: string } + | { type: 'unsubscribe'; scope: string; subscriptionId: string } + | { type: 'mutate'; scope: string; operationId: string; operations: CollabOperation[] } + | { type: 'receipts'; requestId: string; operationIds: string[] } + | { type: 'ping' } + +export type CollabServerMessage = + | { + type: 'welcome' + version: 1 + connectionId: string + identity: CollabIdentity | null + participants: CollabParticipant[] + } + | { type: 'participants'; participants: CollabParticipant[] } + | ({ type: 'snapshot'; subscriptionId: string } & CollabScopeSnapshot) + | { + type: 'update' + scope: string + revision: number + operations: CollabOperation[] + operationId: string + subscriptionId: string + } + | { type: 'ack'; scope: string; operationId: string; revision: number; duplicate: boolean } + | { type: 'receipts'; requestId: string; receipts: CollabReceipt[] } + | { + type: 'error' + code: string + message: string + operationId?: string + subscriptionId?: string + requestId?: string + } + | { type: 'pong' } + +export type CollabCommand = + | { type: 'snapshot'; scope: string } + | { type: 'mutate'; scope: string; operationId: string; operations: CollabOperation[] } + | { type: 'receipts'; operationIds: string[] } + | { type: 'export'; path: string } + +export type CollabCommandResult = + | CollabScopeSnapshot + | CollabMutationResult + | CollabReceipt[] + | null diff --git a/server/collab/service.test.ts b/server/collab/service.test.ts new file mode 100644 index 00000000..517fede8 --- /dev/null +++ b/server/collab/service.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import type { CollabServerMessage } from '@/lib/collab/types' + +import { CollabService } from './service' +import { openCollabStorage } from './storage' + +describe('collab service', () => { + let service: CollabService + let messages: { connectionId: string; message: CollabServerMessage }[] + + beforeEach(() => { + messages = [] + service = new CollabService(openCollabStorage(':memory:'), (connectionId, message) => { + messages.push({ connectionId, message: structuredClone(message) }) + }) + }) + afterEach(() => service.close()) + + function join(connectionId: string, id = connectionId) { + service.receive(connectionId, { + type: 'join', + version: 1, + identity: { id, name: id, color: '#336699' }, + location: { page: 'view:board' } + }) + } + + test('anonymous applets share durable state and recover receipts without joining presence', () => { + service.receive('anon-a', { type: 'join', version: 1, identity: null, anonymousId: 'tab-a' }) + service.receive('anon-b', { type: 'join', version: 1, identity: null, anonymousId: 'tab-b' }) + join('person') + for (const { message } of messages) { + if (message.type === 'participants' || message.type === 'welcome') { + expect( + message.participants.every(participant => participant.identity.id === 'person') + ).toBe(true) + } + } + service.receive('anon-a', { type: 'subscribe', scope: 'board', subscriptionId: 'a-board' }) + service.receive('anon-b', { type: 'subscribe', scope: 'board', subscriptionId: 'b-board' }) + service.receive('anon-a', { + type: 'mutate', + scope: 'board', + operationId: 'anonymous-write', + operations: [{ type: 'set', key: 'title', value: 'Shared without a profile' }] + }) + expect( + messages.some( + ({ connectionId, message }) => + connectionId === 'anon-b' && + message.type === 'update' && + message.operations[0]?.type === 'set' && + message.operations[0].value === 'Shared without a profile' + ) + ).toBe(true) + service.leave('anon-a') + service.receive('anon-a-reconnected', { + type: 'join', + version: 1, + identity: null, + anonymousId: 'tab-a' + }) + service.receive('anon-a-reconnected', { + type: 'receipts', + requestId: 'retry', + operationIds: ['anonymous-write'] + }) + expect(messages.at(-1)?.message).toMatchObject({ + type: 'receipts', + receipts: [{ operationId: 'anonymous-write', status: 'committed', revision: 1 }] + }) + service.receive('anon-b', { + type: 'receipts', + requestId: 'other-tab', + operationIds: ['anonymous-write'] + }) + expect(messages.at(-1)?.message).toMatchObject({ + type: 'receipts', + receipts: [{ operationId: 'anonymous-write', status: 'unknown' }] + }) + messages = [] + service.receive('anon-b', { type: 'location', location: { page: 'view:board' } }) + service.receive('anon-b', { + type: 'presence:set', + registrationId: 'cursor', + surface: 'board', + channel: 'cursor', + value: { x: 5 } + }) + expect(messages).toEqual([]) + }) + + test('snapshot precedes updates, and only matching subscribers receive mutations', () => { + join('a') + join('b') + service.receive('a', { type: 'subscribe', scope: 'board', subscriptionId: 'a-board' }) + service.receive('b', { type: 'subscribe', scope: 'other', subscriptionId: 'b-other' }) + service.receive('a', { + type: 'mutate', + scope: 'board', + operationId: 'first', + operations: [{ type: 'set', key: 'title', value: 'Shared' }] + }) + const durable = messages.filter(item => + ['snapshot', 'update', 'ack'].includes(item.message.type) + ) + expect(durable.map(item => [item.connectionId, item.message.type])).toEqual([ + ['a', 'snapshot'], + ['b', 'snapshot'], + ['a', 'update'], + ['a', 'ack'] + ]) + expect(durable[0]?.message).toMatchObject({ revision: 0, entries: {} }) + expect(durable[2]?.message).toMatchObject({ revision: 1, subscriptionId: 'a-board' }) + service.receive('a', { type: 'unsubscribe', scope: 'board', subscriptionId: 'a-board' }) + messages = [] + service.run( + { kind: 'agent', id: 'agent' }, + { + type: 'mutate', + scope: 'board', + operationId: 'second', + operations: [{ type: 'set', key: 'title', value: 'From agent' }] + } + ) + expect(messages).toEqual([]) + }) + + test('duplicate operations return receipts without broadcasting an old edit', () => { + join('a') + service.receive('a', { type: 'subscribe', scope: 'board', subscriptionId: 'sub' }) + const command = { + type: 'mutate' as const, + scope: 'board', + operationId: 'first', + operations: [{ type: 'set' as const, key: 'title', value: 'First' }] + } + service.receive('a', command) + service.run( + { kind: 'agent', id: 'a' }, + { + ...command, + operations: [{ type: 'set', key: 'title', value: 'Newer' }] + } + ) + messages = [] + service.receive('a', command) + expect(messages).toEqual([ + { + connectionId: 'a', + message: { + type: 'ack', + scope: 'board', + operationId: 'first', + revision: 1, + duplicate: true + } + } + ]) + expect( + service.run({ kind: 'system', id: 'test' }, { type: 'snapshot', scope: 'board' }) + ).toMatchObject({ revision: 2, entries: { title: 'Newer' } }) + }) + + test('multiple connections share identity without sharing registration ownership', async () => { + join('tab-one', 'anna') + join('tab-two', 'anna') + for (const [registrationId, channel] of [ + ['field-a', 'title'], + ['field-b', 'done'] + ]) { + service.receive('tab-one', { + type: 'presence:set', + registrationId: registrationId!, + channel: channel!, + surface: 'view:board', + value: { focused: true } + }) + } + service.receive('tab-one', { type: 'presence:delete', registrationId: 'field-a' }) + await Bun.sleep(60) + const presence = messages.findLast(item => item.message.type === 'participants')?.message + expect(presence?.type).toBe('participants') + if (presence?.type !== 'participants') throw new Error('Missing participants') + expect(presence.participants).toHaveLength(2) + expect(presence.participants[0]?.presence.map(item => item.registrationId)).toEqual(['field-b']) + service.leave('tab-one') + const left = messages.at(-1)?.message + if (left?.type !== 'participants') throw new Error('Missing leave event') + expect(left.participants.map(item => item.connectionId)).toEqual(['tab-two']) + expect(left.participants[0]?.presence).toEqual([]) + }) + + test('identity profile updates preserve actor while changing id requires reconnect', () => { + join('one', 'anna') + service.receive('one', { + type: 'identity', + identity: { id: 'anna', name: 'Anna updated', color: 'blue' } + }) + expect(() => + service.receive('one', { + type: 'identity', + identity: { id: 'boris', name: 'Boris', color: 'red' } + }) + ).toThrow('Reconnect') + expect(() => service.receive('missing', { type: 'ping' })).toThrow('Join') + }) +}) diff --git a/server/collab/service.ts b/server/collab/service.ts new file mode 100644 index 00000000..abb23670 --- /dev/null +++ b/server/collab/service.ts @@ -0,0 +1,218 @@ +import type { + CollabActor, + CollabClientMessage, + CollabCommand, + CollabCommandResult, + CollabIdentity, + CollabParticipant, + CollabServerMessage +} from '@/lib/collab/types' + +import type { openCollabStorage } from './storage' + +type Storage = ReturnType +type Emit = (connectionId: string, message: CollabServerMessage) => void +type Client = { + actor: CollabActor + participant: CollabParticipant | null + subscriptions: Map +} + +function cleanIdentity(identity: CollabIdentity): CollabIdentity { + return { + id: identity.id, + name: identity.name, + color: identity.color, + ...(identity.avatar ? { avatar: identity.avatar } : {}) + } +} + +// Include actor kind so an agent id cannot collide with a human id's receipts. +function actorKey(actor: CollabActor): string { + return `${actor.kind}:${actor.id}` +} + +export class CollabService { + private clients = new Map() + private presenceTimer: ReturnType | null = null + + constructor( + private storage: Storage, + private emit: Emit + ) {} + + private participants(): CollabParticipant[] { + return [...this.clients.values()].flatMap(client => + client.participant ? [client.participant] : [] + ) + } + + private publishParticipants() { + if (this.presenceTimer) clearTimeout(this.presenceTimer) + this.presenceTimer = null + const message: CollabServerMessage = { type: 'participants', participants: this.participants() } + for (const id of this.clients.keys()) this.emit(id, message) + } + + private scheduleParticipants() { + this.presenceTimer ??= setTimeout(() => this.publishParticipants(), 50) + } + + receive(connectionId: string, message: CollabClientMessage) { + if (message.type === 'join') { + if (this.clients.has(connectionId)) throw new Error('This connection already joined') + if (this.clients.size >= 32) throw new Error('This workspace has too many connections') + const identity = message.identity ? cleanIdentity(message.identity) : null + this.clients.set(connectionId, { + actor: message.identity + ? { id: message.identity.id, kind: 'user' } + : { id: `anonymous:${message.anonymousId}`, kind: 'system' }, + participant: identity + ? { + connectionId, + identity, + location: 'location' in message ? (message.location ?? null) : null, + presence: [] + } + : null, + subscriptions: new Map() + }) + this.emit(connectionId, { + type: 'welcome', + version: 1, + connectionId, + identity, + participants: this.participants() + }) + this.publishParticipants() + return + } + + const client = this.clients.get(connectionId) + if (!client) throw new Error('Join the workspace before sending collab messages') + const { actor, participant } = client + switch (message.type) { + case 'identity': { + if (!participant || message.identity.id !== actor.id) + throw new Error('Reconnect to change identity') + participant.identity = cleanIdentity(message.identity) + this.publishParticipants() + return + } + case 'location': + if (!participant) return + participant.location = message.location + this.scheduleParticipants() + return + case 'presence:set': { + if (!participant) return + const presence = participant.presence + const index = presence.findIndex(item => item.registrationId === message.registrationId) + if (index < 0 && presence.length >= 64) throw new Error('Too many presence registrations') + const { registrationId, surface, channel, value } = message + const registration = { registrationId, surface, channel, value } + if (index >= 0) presence[index] = registration + else presence.push(registration) + this.scheduleParticipants() + return + } + case 'presence:delete': + if (!participant) return + participant.presence = participant.presence.filter( + item => item.registrationId !== message.registrationId + ) + this.scheduleParticipants() + return + case 'subscribe': { + if (!client.subscriptions.has(message.subscriptionId) && client.subscriptions.size >= 64) { + throw new Error('Too many storage subscriptions') + } + const snapshot = this.storage.snapshot(message.scope) + // No await between snapshot and registration: every subsequent mutation sees this reader. + client.subscriptions.set(message.subscriptionId, message.scope) + this.emit(connectionId, { + type: 'snapshot', + subscriptionId: message.subscriptionId, + ...snapshot + }) + return + } + case 'unsubscribe': + if (client.subscriptions.get(message.subscriptionId) === message.scope) { + client.subscriptions.delete(message.subscriptionId) + } + return + case 'mutate': { + const result = this.mutate(actor, message) + this.emit(connectionId, { + type: 'ack', + scope: result.scope, + operationId: result.operationId, + revision: result.revision, + duplicate: result.duplicate + }) + return + } + case 'receipts': + this.emit(connectionId, { + type: 'receipts', + requestId: message.requestId, + receipts: this.storage.lookupReceipts(actorKey(actor), message.operationIds) + }) + return + case 'ping': + this.emit(connectionId, { type: 'pong' }) + return + } + } + + private mutate(actor: CollabActor, command: Extract) { + const result = this.storage.mutate( + command.scope, + actorKey(actor), + command.operationId, + command.operations + ) + if (!result.duplicate) { + for (const [connectionId, client] of this.clients) { + for (const [subscriptionId, scope] of client.subscriptions) { + if (scope !== command.scope) continue + this.emit(connectionId, { + type: 'update', + scope, + subscriptionId, + revision: result.revision, + operationId: result.operationId, + operations: result.operations + }) + } + } + } + return result + } + + run(actor: CollabActor, command: CollabCommand): CollabCommandResult { + switch (command.type) { + case 'snapshot': + return this.storage.snapshot(command.scope) + case 'mutate': + return this.mutate(actor, command) + case 'receipts': + return this.storage.lookupReceipts(actorKey(actor), command.operationIds) + case 'export': + this.storage.exportTo(command.path) + return null + } + } + + leave(connectionId: string) { + if (this.clients.delete(connectionId)) this.publishParticipants() + } + + close() { + if (this.presenceTimer) clearTimeout(this.presenceTimer) + this.presenceTimer = null + this.clients.clear() + this.storage.close() + } +} diff --git a/server/collab/storage.test.ts b/server/collab/storage.test.ts new file mode 100644 index 00000000..13e636d9 --- /dev/null +++ b/server/collab/storage.test.ts @@ -0,0 +1,300 @@ +import { Database } from 'bun:sqlite' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { CollabJsonValue, CollabOperation } from '@/lib/collab/types' + +import { COLLAB_STORAGE_LIMITS, openCollabStorage, type CollabStorage } from './storage' + +describe('collab storage', () => { + let directory: string + let path: string + let storage: CollabStorage + let timestamp: number + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'moi-collab-storage-')) + path = join(directory, '.moi', 'data', 'collab.sqlite') + timestamp = Date.UTC(2026, 8, 15) + storage = openCollabStorage(path, { now: () => timestamp }) + }) + + afterEach(() => { + storage.close() + rmSync(directory, { recursive: true, force: true }) + }) + + const set = (key: string, value: CollabJsonValue): CollabOperation => ({ + type: 'set', + key, + value + }) + + test('reading an absent scope never initializes values or advances revisions', () => { + expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} }) + expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} }) + storage.mutate('board', 'anna', 'create', [set('title', 'Created deliberately')]) + expect(storage.snapshot('board').revision).toBe(1) + expect(storage.snapshot('other').revision).toBe(0) + }) + + test('interleaved participants preserve distinct fields and converge on the last same-key commit', async () => { + const commits = await Promise.all([ + Promise.resolve().then(() => + storage.mutate('board', 'anna', 'title', [set('task/1/title', 'A')]) + ), + Promise.resolve().then(() => + storage.mutate('board', 'boris', 'done', [set('task/1/done', true)]) + ), + Promise.resolve().then(() => + storage.mutate('board', 'boris', 'rename', [set('task/1/title', 'B')]) + ) + ]) + expect(commits.map(commit => commit.revision)).toEqual([1, 2, 3]) + expect(storage.snapshot('board')).toEqual({ + scope: 'board', + revision: 3, + entries: { 'task/1/title': 'B', 'task/1/done': true } + }) + storage.mutate('other', 'anna', 'other-write', [set('value', 1)]) + expect(storage.snapshot('board').revision).toBe(3) + expect(storage.snapshot('other').revision).toBe(1) + }) + + test('a batch is ordered and receives one revision', () => { + const result = storage.mutate('board', 'anna', 'batch', [ + set('value', 1), + { type: 'delete', key: 'value' }, + set('value', 2), + set('other', null) + ]) + expect(result.revision).toBe(1) + expect(storage.snapshot('board').entries).toEqual({ value: 2, other: null }) + storage.mutate('board', 'anna', 'empty', []) + expect(storage.snapshot('board').revision).toBe(2) + }) + + test('SQLite failure rolls back all entries, revision and receipt', () => { + storage.mutate('board', 'anna', 'original', [set('value', 'before')]) + // Inject a failure on the second write, after the first UPDATE has run. + const fault = new Database(path) + fault.exec(`CREATE TRIGGER fail_insert BEFORE INSERT ON entries + WHEN NEW.key = 'fail' BEGIN SELECT RAISE(ABORT, 'injected write failure'); END`) + fault.close() + expect(() => + storage.mutate('board', 'anna', 'broken', [set('value', 'after'), set('fail', true)]) + ).toThrow('injected write failure') + expect(storage.snapshot('board')).toEqual({ + scope: 'board', + revision: 1, + entries: { value: 'before' } + }) + expect(storage.lookupReceipts('anna', ['broken'])).toEqual([ + { operationId: 'broken', status: 'unknown' } + ]) + }) + + test('a delayed field edit cannot recreate deleted task membership', () => { + storage.mutate('board', 'anna', 'create', [ + set('task/1/exists', true), + set('task/1/title', 'Demo'), + set('task/1/done', false) + ]) + storage.mutate('board', 'anna', 'delete', [ + { type: 'delete', key: 'task/1/exists' }, + { type: 'delete', key: 'task/1/title' }, + { type: 'delete', key: 'task/1/done' } + ]) + storage.mutate('board', 'boris', 'late', [set('task/1/title', 'Delayed edit')]) + expect(storage.snapshot('board').entries).toEqual({ 'task/1/title': 'Delayed edit' }) + storage.close() + storage = openCollabStorage(path, { now: () => timestamp }) + expect(storage.snapshot('board').entries['task/1/exists']).toBeUndefined() + }) + + test('retrying a committed request does not overwrite a newer change or advance revision', () => { + storage.mutate('board', 'anna', 'lost-ack', [set('title', 'A')]) + storage.mutate('board', 'boris', 'newer', [set('title', 'B')]) + expect(storage.mutate('board', 'anna', 'lost-ack', [set('title', 'A')])).toMatchObject({ + duplicate: true, + revision: 1 + }) + expect(storage.snapshot('board')).toEqual({ + scope: 'board', + revision: 2, + entries: { title: 'B' } + }) + expect(storage.lookupReceipts('anna', ['lost-ack'])).toEqual([ + { operationId: 'lost-ack', status: 'committed', scope: 'board', revision: 1 } + ]) + }) + + test('receipt fingerprints canonicalize JSON and reject changed scope or contents', () => { + storage.mutate('board', 'anna', 'same-id', [set('data', { b: 2, a: 1 })]) + expect( + storage.mutate('board', 'anna', 'same-id', [set('data', { a: 1, b: 2 })]).duplicate + ).toBe(true) + expect(() => storage.mutate('board', 'anna', 'same-id', [set('data', { a: 2 })])).toThrow( + 'Operation id was already used' + ) + expect(() => storage.mutate('other', 'anna', 'same-id', [set('data', { a: 1, b: 2 })])).toThrow( + 'Operation id was already used' + ) + expect(storage.snapshot('other').revision).toBe(0) + expect(storage.lookupReceipts('boris', ['same-id'])).toEqual([ + { operationId: 'same-id', status: 'unknown' } + ]) + expect(storage.mutate('board', 'boris', 'same-id', [set('other', true)]).duplicate).toBe(false) + }) + + test('data and retained receipts survive reopening the database', () => { + storage.mutate('board', 'anna', 'saved', [set('data', { title: 'Demo', done: false })]) + storage.close() + storage = openCollabStorage(path, { now: () => timestamp }) + expect(storage.lookupReceipts('anna', ['saved'])).toEqual([ + { operationId: 'saved', status: 'committed', scope: 'board', revision: 1 } + ]) + expect( + storage.mutate('board', 'anna', 'saved', [set('data', { done: false, title: 'Demo' })]) + .duplicate + ).toBe(true) + expect(storage.snapshot('board').revision).toBe(1) + }) + + test('expired or absent receipts are unknown and expiry never removes data', () => { + storage.mutate('board', 'anna', 'old', [set('value', 'durable')]) + timestamp += COLLAB_STORAGE_LIMITS.receiptTtlMs - 1 + expect(storage.lookupReceipts('anna', ['old'])[0]?.status).toBe('committed') + timestamp += 1 + expect(storage.lookupReceipts('anna', ['old', 'never-arrived'])).toEqual([ + { operationId: 'old', status: 'unknown' }, + { operationId: 'never-arrived', status: 'unknown' } + ]) + storage.pruneReceipts() + storage.close() + storage = openCollabStorage(path, { now: () => timestamp }) + expect(storage.lookupReceipts('anna', ['old'])[0]?.status).toBe('unknown') + expect(storage.snapshot('board').entries).toEqual({ value: 'durable' }) + const inspection = new Database(path, { readonly: true }) + expect( + inspection.query<{ count: number }, []>('SELECT COUNT(*) AS count FROM receipts').get()?.count + ).toBe(0) + inspection.close() + }) + + test('live export is consistent, independently restorable and never overwrites a destination', () => { + storage.mutate('board', 'anna', 'saved', [set('title', 'Before export')]) + const destination = join(directory, 'export', 'collab.sqlite') + storage.exportTo(destination) + storage.mutate('board', 'boris', 'later', [set('title', 'After export')]) + const restored = openCollabStorage(destination, { now: () => timestamp }) + try { + expect(restored.snapshot('board')).toEqual({ + scope: 'board', + revision: 1, + entries: { title: 'Before export' } + }) + expect(restored.lookupReceipts('anna', ['saved'])[0]?.status).toBe('committed') + expect(restored.lookupReceipts('boris', ['later'])[0]?.status).toBe('unknown') + expect(() => storage.exportTo(destination)).toThrow('already exists') + } finally { + restored.close() + } + }) + + test('rejects non-JSON values, oversized payloads and excessive nesting before writing', () => { + const cyclic: Record = {} + cyclic.self = cyclic + const invalid: unknown[] = [ + undefined, + NaN, + Infinity, + new Date(), + { missing: undefined }, + cyclic, + [undefined] + ] + for (const value of invalid) { + expect(() => + storage.mutate('board', 'anna', 'invalid', [set('bad', value as CollabJsonValue)]) + ).toThrow() + } + let nested: CollabJsonValue = null + for (let i = 0; i < 40; i++) nested = [nested] + expect(() => storage.mutate('board', 'anna', 'nested', [set('bad', nested)])).toThrow( + 'too complex' + ) + expect(() => storage.mutate('board', 'anna', 'large', [set('bad', 'x'.repeat(65536))])).toThrow( + '64 KiB' + ) + expect(() => + storage.mutate( + 'board', + 'anna', + 'batch-large', + Array.from({ length: 101 }, (_, i) => set(`${i}`, 1)) + ) + ).toThrow('100 operations') + expect(() => + storage.mutate( + 'board', + 'anna', + 'request-large', + Array.from({ length: 5 }, (_, i) => set(`${i}`, 'x'.repeat(60_000))) + ) + ).toThrow('256 KiB') + expect(() => + storage.mutate('board', 'anna', 'key-large', [set('x'.repeat(1025), true)]) + ).toThrow('Invalid shared key') + expect(storage.snapshot('board')).toEqual({ scope: 'board', revision: 0, entries: {} }) + }) + + test('scope capacity rejection rolls back earlier operations in the same batch', () => { + const value = 'x'.repeat(65_000) + for (let batch = 0; batch < 40; batch++) { + storage.mutate( + 'board', + 'anna', + `fill-${batch}`, + Array.from({ length: 4 }, (_, i) => set(`fill/${batch * 4 + i}`, value)) + ) + } + expect(() => + storage.mutate('board', 'anna', 'overflow', [ + set('marker', true), + set('extra/1', value), + set('extra/2', value) + ]) + ).toThrow('storage limit') + const snapshot = storage.snapshot('board') + expect(snapshot.revision).toBe(40) + expect(Object.keys(snapshot.entries)).toHaveLength(160) + expect(snapshot.entries.marker).toBeUndefined() + expect(storage.lookupReceipts('anna', ['overflow'])[0]?.status).toBe('unknown') + }) + + test('special object keys round-trip as data without prototype mutation', () => { + const value = JSON.parse('{"__proto__":{"admin":true},"constructor":"data"}') as CollabJsonValue + storage.mutate('board', 'anna', 'special', [set('__proto__', value)]) + const entries = storage.snapshot('board').entries + expect(Object.hasOwn(entries, '__proto__')).toBe(true) + expect(entries['__proto__']).toEqual(value) + expect(Object.getPrototypeOf(entries)).toBe(Object.prototype) + }) + + test('uses versioned rollback-journal storage and refuses a newer schema', () => { + storage.close() + const inspection = new Database(path) + expect( + inspection.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()?.journal_mode + ).toBe('delete') + expect( + inspection.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version + ).toBe(1) + inspection.exec('PRAGMA user_version = 2') + inspection.close() + expect(() => openCollabStorage(path)).toThrow('newer version') + }) +}) diff --git a/server/collab/storage.ts b/server/collab/storage.ts new file mode 100644 index 00000000..06da9eea --- /dev/null +++ b/server/collab/storage.ts @@ -0,0 +1,327 @@ +import { Database } from 'bun:sqlite' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +import type { + CollabJsonValue, + CollabMutationResult, + CollabOperation, + CollabReceipt, + CollabScopeSnapshot +} from '@/lib/collab/types' + +export const COLLAB_STORAGE_LIMITS = { + operations: 100, + valueBytes: 64 * 1024, + requestBytes: 256 * 1024, + scopeBytes: 10 * 1024 * 1024, + scopeEntries: 10_000, + identifierBytes: 256, + keyBytes: 1024, + jsonDepth: 32, + receiptTtlMs: 24 * 60 * 60 * 1000 +} as const + +const PRUNE_INTERVAL_MS = 60_000 +const SCHEMA_VERSION = 1 + +export class CollabStorageError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message) + this.name = 'CollabStorageError' + } +} + +export type CollabStorage = { + snapshot: (scope: string) => CollabScopeSnapshot + mutate: ( + scope: string, + actorId: string, + operationId: string, + operations: readonly CollabOperation[] + ) => CollabMutationResult + lookupReceipts: (actorId: string, operationIds: readonly string[]) => CollabReceipt[] + pruneReceipts: () => void + exportTo: (path: string) => void + close: () => void +} + +export type CollabStorageOptions = { + now?: () => number +} + +function fail(code: string, message: string): never { + throw new CollabStorageError(code, message) +} + +function identifier(value: unknown, label: string, maxBytes: number) { + if ( + typeof value !== 'string' || + !value.length || + value.includes('\0') || + Buffer.byteLength(value) > maxBytes + ) { + fail('INVALID_REQUEST', `Invalid ${label}`) + } +} + +// Canonical JSON gives equivalent objects the same fingerprint. Validate before +// encoding: JSON.stringify otherwise silently drops undefined and changes NaN. +function encodeJson(value: unknown): string { + const ancestors = new Set() + let nodes = 0 + + function encode(item: unknown, depth: number): string { + if (++nodes > COLLAB_STORAGE_LIMITS.valueBytes || depth > COLLAB_STORAGE_LIMITS.jsonDepth) { + return fail('LIMIT_EXCEEDED', 'Shared value is too complex') + } + if (item === null || typeof item === 'boolean' || typeof item === 'string') { + return JSON.stringify(item) + } + if (typeof item === 'number' && Number.isFinite(item)) return JSON.stringify(item) + if (typeof item !== 'object' || item === null) { + return fail('INVALID_REQUEST', 'Shared values must contain only JSON data') + } + if (ancestors.has(item)) return fail('INVALID_REQUEST', 'Shared values cannot contain cycles') + ancestors.add(item) + let encoded: string + if (Array.isArray(item)) { + const parts: string[] = [] + for (let i = 0; i < item.length; i++) parts.push(encode(item[i], depth + 1)) + encoded = `[${parts.join(',')}]` + } else { + const prototype: unknown = Object.getPrototypeOf(item) + if (prototype !== Object.prototype && prototype !== null) { + return fail('INVALID_REQUEST', 'Shared values must contain only plain JSON objects') + } + if (Object.getOwnPropertySymbols(item).length) { + return fail('INVALID_REQUEST', 'Shared values cannot contain symbol keys') + } + const parts: string[] = [] + for (const key of Object.keys(item).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(item, key) + if (!descriptor || !('value' in descriptor)) { + return fail('INVALID_REQUEST', 'Shared values cannot contain getters') + } + parts.push(`${JSON.stringify(key)}:${encode(descriptor.value, depth + 1)}`) + } + encoded = `{${parts.join(',')}}` + } + ancestors.delete(item) + if (Buffer.byteLength(encoded) > COLLAB_STORAGE_LIMITS.valueBytes) { + return fail('LIMIT_EXCEEDED', 'Shared value exceeds 64 KiB') + } + return encoded + } + + const result = encode(value, 0) + if (Buffer.byteLength(result) > COLLAB_STORAGE_LIMITS.valueBytes) { + fail('LIMIT_EXCEEDED', 'Shared value exceeds 64 KiB') + } + return result +} + +function normalizeOperations(operations: readonly CollabOperation[]): CollabOperation[] { + if (!Array.isArray(operations) || operations.length > COLLAB_STORAGE_LIMITS.operations) { + return fail('LIMIT_EXCEEDED', 'A shared mutation accepts at most 100 operations') + } + return operations.map(operation => { + if (!operation || typeof operation !== 'object') { + return fail('INVALID_REQUEST', 'Invalid shared operation') + } + identifier(operation.key, 'shared key', COLLAB_STORAGE_LIMITS.keyBytes) + if (operation.type === 'delete') return { type: 'delete', key: operation.key } + if (operation.type !== 'set') return fail('INVALID_REQUEST', 'Unknown shared operation') + return { + type: 'set', + key: operation.key, + value: JSON.parse(encodeJson(operation.value)) as CollabJsonValue + } + }) +} + +export function openCollabStorage(path: string, options: CollabStorageOptions = {}): CollabStorage { + const now = options.now ?? Date.now + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }) + const db = new Database(path, { create: true, strict: true }) + try { + db.exec('PRAGMA journal_mode = DELETE; PRAGMA synchronous = EXTRA') + const version = db + .query<{ user_version: number }, []>('PRAGMA user_version') + .get()!.user_version + if (version > SCHEMA_VERSION) { + fail('SCHEMA_VERSION', 'Shared storage was created by a newer version of moi') + } + if (version === 0) { + db.transaction(() => { + db.exec(` + CREATE TABLE scopes ( + scope TEXT PRIMARY KEY, + revision INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE entries ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); + CREATE TABLE receipts ( + actor_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + scope TEXT NOT NULL, + revision INTEGER NOT NULL, + committed_at INTEGER NOT NULL, + PRIMARY KEY (actor_id, operation_id) + ); + CREATE INDEX receipts_expiry ON receipts(committed_at); + PRAGMA user_version = 1; + `) + })() + } + } catch (error) { + db.close() + throw error + } + + const scopeRevision = db.query<{ revision: number }, [string]>( + 'SELECT revision FROM scopes WHERE scope = ?' + ) + const scopeEntries = db.query<{ key: string; value_json: string }, [string]>( + 'SELECT key, value_json FROM entries WHERE scope = ? ORDER BY key' + ) + const receipt = db.query< + { request_hash: string; scope: string; revision: number }, + [string, string, number] + >(`SELECT request_hash, scope, revision FROM receipts + WHERE actor_id = ? AND operation_id = ? AND committed_at > ?`) + const insertScope = db.query('INSERT INTO scopes(scope) VALUES (?) ON CONFLICT DO NOTHING') + const advanceRevision = db.query<{ revision: number }, [string]>( + 'UPDATE scopes SET revision = revision + 1 WHERE scope = ? RETURNING revision' + ) + const setEntry = db.query(`INSERT INTO entries(scope, key, value_json) VALUES (?, ?, ?) + ON CONFLICT(scope, key) DO UPDATE SET value_json = excluded.value_json`) + const deleteEntry = db.query('DELETE FROM entries WHERE scope = ? AND key = ?') + const size = db.query<{ bytes: number; count: number }, [string]>( + `SELECT COALESCE(SUM(length(CAST(key AS BLOB)) + length(CAST(value_json AS BLOB))), 0) AS bytes, + COUNT(*) AS count FROM entries WHERE scope = ?` + ) + const insertReceipt = db.query(`INSERT INTO receipts + (actor_id, operation_id, request_hash, scope, revision, committed_at) VALUES (?, ?, ?, ?, ?, ?)`) + const deleteExpired = db.query('DELETE FROM receipts WHERE committed_at <= ?') + let closed = false + + function pruneReceipts() { + if (!closed) deleteExpired.run(now() - COLLAB_STORAGE_LIMITS.receiptTtlMs) + } + pruneReceipts() + const pruneTimer = setInterval(pruneReceipts, PRUNE_INTERVAL_MS) + pruneTimer.unref() + + const commit = db.transaction( + ( + scope: string, + actorId: string, + operationId: string, + operations: CollabOperation[], + fingerprint: string, + timestamp: number + ): CollabMutationResult => { + const previous = receipt.get( + actorId, + operationId, + timestamp - COLLAB_STORAGE_LIMITS.receiptTtlMs + ) + if (previous) { + if (previous.request_hash !== fingerprint) { + return fail( + 'OPERATION_CONFLICT', + 'Operation id was already used for a different mutation' + ) + } + return { scope, operationId, operations, revision: previous.revision, duplicate: true } + } + // Expired ids are outside the retry contract. Remove an expired receipt + // before inserting; clients must give deliberate new edits new ids. + db.query('DELETE FROM receipts WHERE actor_id = ? AND operation_id = ?').run( + actorId, + operationId + ) + insertScope.run(scope) + const revision = advanceRevision.get(scope)!.revision + for (const operation of operations) { + if (operation.type === 'set') { + setEntry.run(scope, operation.key, JSON.stringify(operation.value)) + } else { + deleteEntry.run(scope, operation.key) + } + } + const totals = size.get(scope)! + if ( + totals.bytes > COLLAB_STORAGE_LIMITS.scopeBytes || + totals.count > COLLAB_STORAGE_LIMITS.scopeEntries + ) { + return fail('LIMIT_EXCEEDED', 'Shared scope exceeds its storage limit') + } + insertReceipt.run(actorId, operationId, fingerprint, scope, revision, timestamp) + return { scope, operationId, revision, operations, duplicate: false } + } + ) + + return { + snapshot(scope) { + identifier(scope, 'scope', COLLAB_STORAGE_LIMITS.identifierBytes) + return { + scope, + revision: scopeRevision.get(scope)?.revision ?? 0, + entries: Object.fromEntries( + scopeEntries + .all(scope) + .map(row => [row.key, JSON.parse(row.value_json) as CollabJsonValue]) + ) + } + }, + mutate(scope, actorId, operationId, input) { + identifier(scope, 'scope', COLLAB_STORAGE_LIMITS.identifierBytes) + identifier(actorId, 'actor id', COLLAB_STORAGE_LIMITS.identifierBytes) + identifier(operationId, 'operation id', COLLAB_STORAGE_LIMITS.identifierBytes) + const operations = normalizeOperations(input) + const encoded = JSON.stringify({ scope, operations }) + if (Buffer.byteLength(encoded) > COLLAB_STORAGE_LIMITS.requestBytes) { + return fail('LIMIT_EXCEEDED', 'Shared mutation exceeds 256 KiB') + } + const fingerprint = new Bun.CryptoHasher('sha256').update(encoded).digest('hex') + return commit(scope, actorId, operationId, operations, fingerprint, now()) + }, + lookupReceipts(actorId, operationIds) { + identifier(actorId, 'actor id', COLLAB_STORAGE_LIMITS.identifierBytes) + if (!Array.isArray(operationIds) || operationIds.length > COLLAB_STORAGE_LIMITS.operations) { + return fail('LIMIT_EXCEEDED', 'Look up at most 100 operations at once') + } + const cutoff = now() - COLLAB_STORAGE_LIMITS.receiptTtlMs + return operationIds.map(operationId => { + identifier(operationId, 'operation id', COLLAB_STORAGE_LIMITS.identifierBytes) + const found = receipt.get(actorId, operationId, cutoff) + return found + ? { operationId, status: 'committed', scope: found.scope, revision: found.revision } + : { operationId, status: 'unknown' } + }) + }, + pruneReceipts, + exportTo(destination) { + if (existsSync(destination)) fail('EXPORT_EXISTS', 'Shared storage export already exists') + mkdirSync(dirname(destination), { recursive: true }) + pruneReceipts() + db.query('VACUUM INTO ?').run(destination) + }, + close() { + if (closed) return + closed = true + clearInterval(pruneTimer) + db.close() + } + } +} From 72c8af043bcc35fa16a2f4c511fc545b9ac182a6 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 15 Sep 2026 15:18:30 +0200 Subject: [PATCH 02/86] Add workspace collab workers and explicit CLI setup --- lib/moi-context.ts | 7 + server/api.ts | 8 +- server/applet-typecheck.ts | 7 +- server/cli.ts | 43 +- server/collab/config.test.ts | 55 +++ server/collab/config.ts | 26 ++ server/collab/http.test.ts | 235 ++++++++++ server/collab/http.ts | 37 ++ server/collab/ipc.ts | 19 + server/collab/manager.test.ts | 251 ++++++++++ server/collab/manager.ts | 430 ++++++++++++++++++ server/collab/skill/collab-env.d.ts | 85 ++++ server/collab/skill/index.ts | 60 +++ .../collab/skill/references/COLLABORATIVE.md | 290 ++++++++++++ server/collab/worker.ts | 71 +++ server/service.ts | 9 +- server/test/cli-collab.test.ts | 154 +++++++ server/test/service.test.ts | 6 +- server/web.ts | 54 ++- 19 files changed, 1827 insertions(+), 20 deletions(-) create mode 100644 server/collab/config.test.ts create mode 100644 server/collab/config.ts create mode 100644 server/collab/http.test.ts create mode 100644 server/collab/http.ts create mode 100644 server/collab/ipc.ts create mode 100644 server/collab/manager.test.ts create mode 100644 server/collab/manager.ts create mode 100644 server/collab/skill/collab-env.d.ts create mode 100644 server/collab/skill/index.ts create mode 100644 server/collab/skill/references/COLLABORATIVE.md create mode 100644 server/collab/worker.ts create mode 100644 server/test/cli-collab.test.ts diff --git a/lib/moi-context.ts b/lib/moi-context.ts index 23ee8b7e..df27786a 100644 --- a/lib/moi-context.ts +++ b/lib/moi-context.ts @@ -48,6 +48,7 @@ export type MoiAppletMessage = { // the server for programmatic sends (the view builder). Extend this (and // `renderMoiContext`) when new ambient fields land. export type MoiContext = { + collabReference?: string // The workspace tab the user is on when they hit send — for a view-builder // request that's the builder's own tab (`view-builder:`). activeTab: WorkspaceTabId @@ -158,6 +159,10 @@ export function renderMoiContextBody(ctx: MoiContext): string { const tabParams = ctx.tabParams ? renderAppletJson(ctx.tabParams) : null if (tabParams) tabLines.push(`Params it is rendering with right now: ${tabParams}`) const sections = [`# Active tab\n${tabLines.join('\n')}`] + if (ctx.collabReference) + sections.push( + `# Collab\nThis workspace has collab enabled. Before writing collaborative applets, read ${escapeTags(ctx.collabReference)}.` + ) if (ctx.applet) { const appletLines = [ `The message above was not typed by the user — the ${describeAppletSource(ctx.applet.source)} sent it when the user acted in its UI.` @@ -184,6 +189,7 @@ export function renderMoiContext(ctx: MoiContext): string { export function isMoiContext(value: unknown): value is MoiContext { if (typeof value !== 'object' || value === null) return false const v = value as { + collabReference?: unknown activeTab?: unknown tabTitle?: unknown tabParams?: unknown @@ -192,6 +198,7 @@ export function isMoiContext(value: unknown): value is MoiContext { } return ( typeof v.activeTab === 'string' && + (v.collabReference === undefined || typeof v.collabReference === 'string') && (v.tabTitle === undefined || typeof v.tabTitle === 'string') && (v.tabParams === undefined || isParamsRecord(v.tabParams)) && (v.applet === undefined || isMoiAppletMessage(v.applet)) && diff --git a/server/api.ts b/server/api.ts index 3dccdf5a..b0f823a4 100644 --- a/server/api.ts +++ b/server/api.ts @@ -22,6 +22,8 @@ import type { import type { MoiContext } from '@/lib/moi-context' import { viewBuilderDirectives } from '@/lib/view-builder-directives' +import { getCollabCapability } from './collab/config' +import { collabRoutes } from './collab/http' import { agentStore } from './agent' import { clientAppConfig, getAppConfig } from './app-config' import { getAppSettings, pickAppSettingsPatch, saveAppSettings } from './app-settings' @@ -169,6 +171,7 @@ const withWorkspace = createMiddleware(async (c, next) => { // ---- single workspace: /api/workspaces/:id/* -------------------------------- const one = new Hono() one.use('*', withWorkspace) +one.route('/collab', collabRoutes) one.get('/preview', async c => { const ws = c.get('ws') @@ -336,7 +339,9 @@ one.post('/view-builders/:builderId/submit', async c => { // The bootstrap instructions ride the moi-context envelope, injected by // the harness like any other ambient context; the user text stays bare. // The user submits from the builder's own tab, so that's the active tab. + const collab = await getCollabCapability(ws.path, ws.type) const context: MoiContext = { + ...(collab.referencePath ? { collabReference: collab.referencePath } : {}), activeTab: `view-builder:${builder.id}`, directives: [ ...viewBuilderDirectives(builder.id, availableIcons), @@ -890,7 +895,8 @@ one.get('/', async c => { name: layout.name || basename(ws.path), cwd: ws.path, provider: ws.type, - agentId: ws.agentId + agentId: ws.agentId, + collab: await getCollabCapability(ws.path, ws.type) }) }) diff --git a/server/applet-typecheck.ts b/server/applet-typecheck.ts index 9c993732..3c6c3665 100644 --- a/server/applet-typecheck.ts +++ b/server/applet-typecheck.ts @@ -64,8 +64,13 @@ export async function typecheckApplets( if (files.length === 0) return { files, diagnostics: [] } await writeAppletEnvDts(workspaceRoot) + const collabTypes = join(moiRoot, 'collab-env.d.ts') const program = ts.createProgram({ - rootNames: [join(moiRoot, 'applet-env.d.ts'), ...files], + rootNames: [ + join(moiRoot, 'applet-env.d.ts'), + ...(existsSync(collabTypes) ? [collabTypes] : []), + ...files + ], options: { allowImportingTsExtensions: true, esModuleInterop: true, diff --git a/server/cli.ts b/server/cli.ts index 9d2b2e11..2ab3c499 100755 --- a/server/cli.ts +++ b/server/cli.ts @@ -45,6 +45,7 @@ import { resolveCwdWorkspace } from './cli-env' import { columns, keyValue } from './cli-ui' +import { installCollabSkill } from './collab/skill' import { CONTROL_HOST, CONTROL_PORT, CONTROL_URL, PORT } from './constants' import { type ControlProbe, controlFailureMessage, probeControlServer } from './control-client' import { @@ -157,14 +158,21 @@ async function openBrowser(url: string) { // (see runDevSupervisor). function spawnServer( cwd: string, - env: Record = process.env + env: Record = process.env, + experimentalCollab = false ): ReturnType { return Bun.spawn(['bun', import.meta.filename, 'start'], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', cwd, - env: { ...env, MOI_SERVER: '1' } + env: { + ...env, + MOI_SERVER: '1', + // Runtime opt-in comes only from `start --experimental-collab`. + // Override inherited values, including when `init --web` starts us. + MOI_EXPERIMENTAL_COLLAB: experimentalCollab ? '1' : '0' + } }) } @@ -175,11 +183,12 @@ function spawnServer( // (closing servers + killing function workers), so restarts leak nothing. async function runDevSupervisor( projectRoot: string, - env: Record + env: Record, + experimentalCollab: boolean ): Promise { const { watch } = await import('node:fs') - let child = spawnServer(projectRoot, env) + let child = spawnServer(projectRoot, env, experimentalCollab) let restarting = false let debounce: ReturnType | undefined @@ -198,7 +207,7 @@ async function runDevSupervisor( await child.exited clearTimeout(sigkill) restarting = false - child = spawnServer(projectRoot, env) + child = spawnServer(projectRoot, env, experimentalCollab) } for (const dir of ['server', 'lib']) { @@ -334,6 +343,11 @@ const init = defineCommand({ default: false, description: 'Start the web server if not already running' }, + 'experimental-collab': { + type: 'boolean', + default: false, + description: 'Include the optional collaboration guide and applet types' + }, id: { type: 'string', description: @@ -396,6 +410,11 @@ const init = defineCommand({ // package.json + bun install). An existing `.moi/` is left untouched. console.log() const { scaffold, skillsDir } = await provisionWorkspace(target, type) + if (args['experimental-collab']) { + const { referencePath } = await installCollabSkill(target, type) + console.log(pc.dim(' Collaboration guide installed to ' + referencePath)) + console.log(pc.dim(' Shared state requires moi start --experimental-collab')) + } if (scaffold !== 'exists') { if (scaffold === 'installing') { console.log(pc.dim(' Widget dependencies still installing in .moi/ (background)')) @@ -474,6 +493,11 @@ const start = defineCommand({ port: { type: 'string', description: 'HTTP port to listen on (default: 13337)' + }, + 'experimental-collab': { + type: 'boolean', + default: false, + description: 'Enable the collaboration runtime for views (no identity or workspace UI)' } }, async run({ args }) { @@ -529,17 +553,20 @@ const start = defineCommand({ ...(debug ? { MOI_DEBUG: '1' } : {}) } if (dev) { - await runDevSupervisor(projectRoot, env) + await runDevSupervisor(projectRoot, env, args['experimental-collab']) return } const cwd = serverCwd(projectRoot, dev) - const proc = spawnServer(cwd, env) - process.exit(await superviseServerUpdates(proc, () => spawnServer(cwd, env))) + const proc = spawnServer(cwd, env, args['experimental-collab']) + process.exit( + await superviseServerUpdates(proc, () => spawnServer(cwd, env, args['experimental-collab'])) + ) } // This IS the server process (MOI_SERVER=1). cwd is the package root when the // dev bundler runs (bunfig loaded at Bun startup) or a neutral dir for a // prebuilt install — see serverCwd(). + if (args['experimental-collab']) process.env.MOI_EXPERIMENTAL_COLLAB = '1' try { await import('./web') } catch (err) { diff --git a/server/collab/config.test.ts b/server/collab/config.test.ts new file mode 100644 index 00000000..96a8818c --- /dev/null +++ b/server/collab/config.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { collabReferencePath, getCollabCapability, isCollabEnabled } from './config' + +let workspacePath: string +const envKeys = ['MOI_EXPERIMENTAL_COLLAB', 'MOI_COLLAB', 'MOI_DEV', 'MOI_COLLAB_IDENTITY'] +let savedEnv: Record +beforeEach(async () => { + workspacePath = await mkdtemp(join(tmpdir(), 'moi-collab-config-')) + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) + for (const key of envKeys) delete process.env[key] +}) +afterEach(async () => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedEnv[key] + } + await rm(workspacePath, { recursive: true, force: true }) +}) + +describe('collab process opt-in', () => { + test('dev mode, old environment settings and legacy workspace flags do not enable runtime', async () => { + process.env.MOI_DEV = '1' + process.env.MOI_COLLAB = '1' + process.env.MOI_COLLAB_IDENTITY = 'local' + await Bun.write( + join(workspacePath, '.moi', '.workspace.json'), + JSON.stringify({ version: 1, experimental: { collab: true } }) + ) + expect(isCollabEnabled()).toBe(false) + expect(await getCollabCapability(workspacePath)).toEqual({ enabled: false }) + }) + + test('the process flag enables every workspace without configuration or files', async () => { + process.env.MOI_EXPERIMENTAL_COLLAB = '1' + expect(isCollabEnabled()).toBe(true) + expect(await getCollabCapability(workspacePath)).toEqual({ enabled: true }) + expect(await getCollabCapability(join(workspacePath, 'another'))).toEqual({ enabled: true }) + expect(await Bun.file(join(workspacePath, '.moi', '.workspace.json')).exists()).toBe(false) + expect(await Bun.file(collabReferencePath(workspacePath)).exists()).toBe(false) + process.env.MOI_EXPERIMENTAL_COLLAB = '0' + expect(isCollabEnabled()).toBe(false) + }) + + test('only advertises an already installed guide when runtime is enabled', async () => { + const referencePath = collabReferencePath(workspacePath) + await Bun.write(referencePath, '# Collaborative applets') + expect(await getCollabCapability(workspacePath)).toEqual({ enabled: false }) + process.env.MOI_EXPERIMENTAL_COLLAB = '1' + expect(await getCollabCapability(workspacePath)).toEqual({ enabled: true, referencePath }) + }) +}) diff --git a/server/collab/config.ts b/server/collab/config.ts new file mode 100644 index 00000000..c3502de5 --- /dev/null +++ b/server/collab/config.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path' + +import type { WorkspaceType } from '@/lib/types' +import type { CollabCapability } from '@/lib/collab/types' +import { skillsDirFor } from '../workspace-init' + +// The CLI owns this process setting. Workspace files and dev mode never enable it. +export function isCollabEnabled(): boolean { + return process.env.MOI_EXPERIMENTAL_COLLAB === '1' +} + +export function collabReferencePath(workspacePath: string, type?: WorkspaceType): string { + return join(skillsDirFor(workspacePath, type), 'moi-workspace', 'references', 'COLLABORATIVE.md') +} + +export async function getCollabCapability( + workspacePath: string, + type?: WorkspaceType +): Promise { + const enabled = isCollabEnabled() + const referencePath = enabled ? collabReferencePath(workspacePath, type) : undefined + return { + enabled, + ...(referencePath && (await Bun.file(referencePath).exists()) ? { referencePath } : {}) + } +} diff --git a/server/collab/http.test.ts b/server/collab/http.test.ts new file mode 100644 index 00000000..47d55b00 --- /dev/null +++ b/server/collab/http.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { Hono } from 'hono' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import type { CollabCommand, CollabServerMessage } from '@/lib/collab/types' +import type { WorkspaceEntry } from '@/lib/types' + +import { collabReferencePath } from './config' +import { collabRoutes } from './http' +import { collabManager, type CollabSocket } from './manager' + +let directory: string +let workspace: WorkspaceEntry +let app: Hono<{ Variables: { ws: WorkspaceEntry } }> +let savedEnv: Record +const envKeys = ['MOI_EXPERIMENTAL_COLLAB', 'MOI_COLLAB', 'MOI_DEV'] + +async function until(predicate: () => boolean) { + const deadline = Date.now() + 5000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for collab cleanup') + await Bun.sleep(10) + } +} + +beforeEach(async () => { + savedEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])) + for (const key of envKeys) delete process.env[key] + directory = await mkdtemp(join(tmpdir(), 'moi-collab-http-')) + workspace = { + id: 'test-collab', + path: directory, + type: 'codex', + addedAt: new Date().toISOString() + } + app = new Hono<{ Variables: { ws: WorkspaceEntry } }>() + app.use('*', async (c, next) => { + c.set('ws', workspace) + await next() + }) + app.route('/collab', collabRoutes) +}) + +afterEach(async () => { + const canonicalPath = await realpath(directory) + await collabManager.stopWorkspace(directory) + await until( + () => !collabManager.debugSnapshot().some(slot => slot.workspacePath === canonicalPath) + ) + for (const key of envKeys) { + if (savedEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedEnv[key] + } + await rm(directory, { recursive: true, force: true }) +}) + +function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) +} + +function command(command: CollabCommand, id = 'agent') { + return post('/collab/command', { actor: { id, kind: 'agent' }, command }) +} + +async function enable() { + process.env.MOI_EXPERIMENTAL_COLLAB = '1' + const response = await app.request('/collab') + expect(response.status).toBe(200) + return response +} + +describe('collab HTTP integration', () => { + test('ordinary and dev starts remain disabled without creating data', async () => { + expect(await (await app.request('/collab')).json()).toEqual({ + enabled: false + }) + expect((await post('/collab', { enabled: true })).status).toBe(404) + expect((await command({ type: 'snapshot', scope: 'board' })).status).toBe(404) + process.env.MOI_DEV = '1' + expect(await (await app.request('/collab')).json()).toEqual({ + enabled: false + }) + expect((await command({ type: 'snapshot', scope: 'board' })).status).toBe(404) + expect(await Bun.file(collabReferencePath(directory, workspace.type)).exists()).toBe(false) + expect(await Bun.file(join(directory, '.moi', 'data', 'collab.sqlite')).exists()).toBe(false) + }) + + test('runtime flag does not install documents, identity or workspace configuration', async () => { + const referencePath = collabReferencePath(directory, workspace.type) + const defaultSkillPath = join(dirname(dirname(referencePath)), 'SKILL.md') + const defaultSkill = '# moi workspace\nExisting workspace instructions.\n' + await Bun.write(defaultSkillPath, defaultSkill) + const response = await enable() + expect(await response.json()).toEqual({ + enabled: true + }) + expect((await post('/collab', { enabled: true })).status).toBe(404) + expect(await Bun.file(join(directory, '.moi', '.workspace.json')).exists()).toBe(false) + expect(await Bun.file(defaultSkillPath).text()).toBe(defaultSkill) + expect(await Bun.file(referencePath).exists()).toBe(false) + expect(await Bun.file(join(directory, '.moi', 'collab-env.d.ts')).exists()).toBe(false) + expect(await Bun.file(join(directory, '.moi', 'data', 'collab.sqlite')).exists()).toBe(false) + }) + + test('HTTP snapshots, mutations and recovery receipts use the same persistent worker', async () => { + await enable() + expect(await (await command({ type: 'snapshot', scope: 'board' })).json()).toEqual({ + scope: 'board', + revision: 0, + entries: {} + }) + const edit: CollabCommand = { + type: 'mutate', + scope: 'board', + operationId: 'first', + operations: [{ type: 'set', key: 'task/title', value: 'First' }] + } + expect(await (await command(edit)).json()).toMatchObject({ revision: 1, duplicate: false }) + expect( + await ( + await command( + { + type: 'mutate', + scope: 'board', + operationId: 'second', + operations: [{ type: 'set', key: 'task/done', value: true }] + }, + 'other-agent' + ) + ).json() + ).toMatchObject({ revision: 2, duplicate: false }) + expect(await (await command(edit)).json()).toMatchObject({ revision: 1, duplicate: true }) + expect(await (await command({ type: 'snapshot', scope: 'board' })).json()).toEqual({ + scope: 'board', + revision: 2, + entries: { 'task/title': 'First', 'task/done': true } + }) + expect( + await ( + await command({ type: 'receipts', operationIds: ['first', 'second', 'missing'] }) + ).json() + ).toEqual([ + { operationId: 'first', status: 'committed', scope: 'board', revision: 1 }, + { operationId: 'second', status: 'unknown' }, + { operationId: 'missing', status: 'unknown' } + ]) + const mismatch = await command({ + ...edit, + operations: [{ type: 'set', key: 'task/title', value: 'Changed payload' }] + }) + expect(mismatch.status).toBe(409) + expect(await (await command({ type: 'snapshot', scope: 'board' })).json()).toMatchObject({ + revision: 2 + }) + }) + + test('rejects commands, actors and public filesystem export', async () => { + await enable() + expect( + ( + await post('/collab/command', { + actor: { id: 'user', kind: 'admin' }, + command: { type: 'snapshot', scope: 'board' } + }) + ).status + ).toBe(400) + expect((await command({ type: 'export', path: join(directory, 'leak.sqlite') })).status).toBe( + 400 + ) + expect( + ( + await post('/collab/command', { + actor: { id: 'agent', kind: 'agent' }, + command: { + type: 'mutate', + scope: 'board', + operationId: 'invalid', + operations: [{ type: 'increment', key: 'value' }] + } + }) + ).status + ).toBe(400) + expect(await Bun.file(join(directory, 'leak.sqlite')).exists()).toBe(false) + }) + + test('stopping runtime closes connections and preserves the guide and durable content', async () => { + await enable() + const referencePath = collabReferencePath(directory, workspace.type) + await Bun.write(referencePath, '# Manually installed guide') + await command({ + type: 'mutate', + scope: 'board', + operationId: 'keep', + operations: [{ type: 'set', key: 'title', value: 'Keep me' }] + }) + const messages: CollabServerMessage[] = [] + let closed = false + const socket: CollabSocket = { + send(data) { + messages.push(JSON.parse(data) as CollabServerMessage) + return data.length + }, + close() { + closed = true + } + } + collabManager.open(socket, directory) + collabManager.message( + socket, + JSON.stringify({ + type: 'join', + version: 1, + identity: { id: 'anna', name: 'Anna', color: 'blue' } + }) + ) + await until(() => messages.some(message => message.type === 'welcome')) + await collabManager.stopWorkspace(directory) + process.env.MOI_EXPERIMENTAL_COLLAB = '0' + expect(closed).toBe(true) + expect(await Bun.file(referencePath).text()).toBe('# Manually installed guide') + expect(await Bun.file(join(directory, '.moi', 'data', 'collab.sqlite')).exists()).toBe(true) + expect((await command({ type: 'snapshot', scope: 'board' })).status).toBe(404) + await enable() + expect(await (await command({ type: 'snapshot', scope: 'board' })).json()).toMatchObject({ + revision: 1, + entries: { title: 'Keep me' } + }) + }) +}) diff --git a/server/collab/http.ts b/server/collab/http.ts new file mode 100644 index 00000000..dc00ffb4 --- /dev/null +++ b/server/collab/http.ts @@ -0,0 +1,37 @@ +import { Hono } from 'hono' + +import { isCollabActor, isCollabCommand } from '@/lib/collab/protocol' +import type { WorkspaceEntry } from '@/lib/types' +import { getCollabCapability, isCollabEnabled } from './config' +import { callCollab } from './manager' + +export const collabRoutes = new Hono<{ Variables: { ws: WorkspaceEntry } }>() + +collabRoutes.get('/', async c => { + const ws = c.get('ws') + return c.json(await getCollabCapability(ws.path, ws.type)) +}) + +// Agent/server callers use the same ordered writer as browsers. Identity here +// is attribution supplied by the outer environment, never an access credential. +collabRoutes.post('/command', async c => { + const ws = c.get('ws') + if (!isCollabEnabled()) return c.json({ error: 'Start moi with --experimental-collab' }, 404) + const body: unknown = await c.req.json().catch(() => null) + if ( + !body || + typeof body !== 'object' || + !('actor' in body) || + !('command' in body) || + !isCollabActor(body.actor) || + !isCollabCommand(body.command) || + body.command.type === 'export' + ) { + return c.json({ error: 'Expected actor and a snapshot, mutate, or receipts command' }, 400) + } + try { + return c.json(await callCollab(ws.path, body.actor, body.command)) + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : 'Collab request failed' }, 409) + } +}) diff --git a/server/collab/ipc.ts b/server/collab/ipc.ts new file mode 100644 index 00000000..c25b75bd --- /dev/null +++ b/server/collab/ipc.ts @@ -0,0 +1,19 @@ +import type { + CollabActor, + CollabClientMessage, + CollabCommand, + CollabCommandResult, + CollabServerMessage +} from '@/lib/collab/types' + +export type ParentMessage = + | { type: 'client'; connectionId: string; message: CollabClientMessage } + | { type: 'leave'; connectionId: string } + | { type: 'call'; requestId: string; actor: CollabActor; command: CollabCommand } + | { type: 'shutdown' } + +export type WorkerMessage = + | { type: 'ready'; generation: string } + | { type: 'client'; generation: string; connectionId: string; message: CollabServerMessage } + | { type: 'result'; generation: string; requestId: string; result: CollabCommandResult } + | { type: 'error'; generation: string; requestId: string; code: string; message: string } diff --git a/server/collab/manager.test.ts b/server/collab/manager.test.ts new file mode 100644 index 00000000..b29687b0 --- /dev/null +++ b/server/collab/manager.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { CollabClientMessage, CollabServerMessage } from '@/lib/collab/types' + +import { CollabManager, type CollabSocket } from './manager' + +const managers: CollabManager[] = [] +const directories: string[] = [] +const servers: Bun.Server<{ workspacePath: string }>[] = [] +const sockets: WebSocket[] = [] + +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.close() + for (const server of servers.splice(0)) server.stop(true) + await Promise.all(managers.splice(0).map(manager => manager.shutdown())) + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }) +}) + +function directory() { + const path = mkdtempSync(join(tmpdir(), 'moi-collab-runtime-')) + directories.push(path) + return path +} + +function manager(options: ConstructorParameters[0] = {}) { + const instance = new CollabManager({ enabled: async () => true, ...options }) + managers.push(instance) + return instance +} + +async function until(predicate: () => boolean, timeout = 5000) { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for collab event') + await Bun.sleep(10) + } +} + +function serve(runtime: CollabManager, workspacePath: string, dropAcknowledgments = false) { + const connections = new Map, CollabSocket>() + const server = Bun.serve<{ workspacePath: string }>({ + port: 0, + hostname: '127.0.0.1', + fetch(req, server) { + return server.upgrade(req, { data: { workspacePath } }) + ? undefined + : new Response('Upgrade failed', { status: 400 }) + }, + websocket: { + open(socket) { + const connection: CollabSocket = { + send(message) { + // Simulate a lost acknowledgment after the worker has committed its transaction. + if (dropAcknowledgments && (JSON.parse(message) as CollabServerMessage).type === 'ack') + return 0 + return socket.send(message) + }, + close: (code, reason) => socket.close(code, reason), + getBufferedAmount: () => socket.getBufferedAmount() + } + connections.set(socket, connection) + runtime.open(connection, socket.data.workspacePath) + }, + message(socket, message) { + const connection = connections.get(socket) + if (connection) runtime.message(connection, message) + }, + close(socket) { + const connection = connections.get(socket) + if (connection) runtime.close(connection) + connections.delete(socket) + } + } + }) + servers.push(server) + return `ws://127.0.0.1:${server.port}` +} + +async function connect(url: string, id: string) { + const socket = new WebSocket(url) + sockets.push(socket) + const messages: CollabServerMessage[] = [] + socket.onmessage = event => messages.push(JSON.parse(String(event.data)) as CollabServerMessage) + const send = (message: CollabClientMessage) => socket.send(JSON.stringify(message)) + await until(() => socket.readyState === WebSocket.OPEN) + send({ type: 'join', version: 1, identity: { id, name: id, color: 'blue' } }) + await until(() => messages.some(message => message.type === 'welcome')) + return { socket, messages, send } +} + +describe('collab process and socket integration', () => { + test('simultaneous requests start one worker and different workspaces keep separate databases', async () => { + const runtime = manager() + const first = directory() + const second = directory() + const actor = { id: 'agent', kind: 'agent' as const } + await Promise.all( + ['title', 'done'].map(key => + runtime.call(first, actor, { + type: 'mutate', + scope: 'board', + operationId: key, + operations: [{ type: 'set', key, value: key }] + }) + ) + ) + expect(runtime.debugSnapshot()).toHaveLength(1) + expect(await runtime.call(first, actor, { type: 'snapshot', scope: 'board' })).toMatchObject({ + revision: 2, + entries: { title: 'title', done: 'done' } + }) + expect(await runtime.call(second, actor, { type: 'snapshot', scope: 'board' })).toEqual({ + scope: 'board', + revision: 0, + entries: {} + }) + expect(runtime.debugSnapshot()).toHaveLength(2) + }) + + test('real sockets converge and closing a tab removes presence', async () => { + const runtime = manager() + const path = directory() + const url = serve(runtime, path) + const a = await connect(url, 'anna') + const b = await connect(url, 'boris') + for (const [client, subscriptionId] of [ + [a, 'a'], + [b, 'b'] + ] as const) { + client.send({ type: 'subscribe', scope: 'board', subscriptionId }) + await until(() => client.messages.some(message => message.type === 'snapshot')) + } + a.send({ + type: 'mutate', + scope: 'board', + operationId: 'edit', + operations: [{ type: 'set', key: 'done', value: true }] + }) + await until(() => a.messages.some(message => message.type === 'ack')) + await until(() => + b.messages.some(message => message.type === 'update' && message.revision === 1) + ) + expect(runtime.debugSnapshot()[0]?.connections).toBe(2) + a.socket.close() + await until(() => + b.messages.some( + message => message.type === 'participants' && message.participants.length === 1 + ) + ) + expect(runtime.debugSnapshot()[0]?.connections).toBe(1) + }) + + test('a lost acknowledgment and worker death recover committed content through receipts', async () => { + const runtime = manager() + const path = directory() + const url = serve(runtime, path, true) + const client = await connect(url, 'anna') + client.send({ + type: 'mutate', + scope: 'board', + operationId: 'committed', + operations: [{ type: 'set', key: 'title', value: 'Survives' }] + }) + await until(() => client.socket.readyState === WebSocket.CLOSED) + expect(client.messages.some(message => message.type === 'ack')).toBe(false) + const pid = runtime.debugSnapshot()[0]?.pid + if (!pid) throw new Error('Missing worker process') + process.kill(pid, 'SIGKILL') + await until(() => client.socket.readyState === WebSocket.CLOSED) + await until(() => runtime.debugSnapshot().length === 0) + await Bun.sleep(300) + const replacement = await connect(url, 'anna') + replacement.send({ type: 'subscribe', scope: 'board', subscriptionId: 'fresh' }) + replacement.send({ + type: 'receipts', + requestId: 'recover', + operationIds: ['committed', 'not-sent'] + }) + await until(() => replacement.messages.some(message => message.type === 'receipts')) + expect(replacement.messages.find(message => message.type === 'snapshot')).toMatchObject({ + revision: 1, + entries: { title: 'Survives' } + }) + expect(replacement.messages.find(message => message.type === 'receipts')).toMatchObject({ + receipts: [ + { operationId: 'committed', status: 'committed', scope: 'board', revision: 1 }, + { operationId: 'not-sent', status: 'unknown' } + ] + }) + }) + + test('idle workers stop but a connected browser keeps its worker alive', async () => { + const runtime = manager({ idleTimeoutMs: 30 }) + const path = directory() + const client = await connect(serve(runtime, path), 'anna') + await Bun.sleep(80) + expect(runtime.debugSnapshot()).toHaveLength(1) + client.socket.close() + await until(() => runtime.debugSnapshot().length === 0) + }) + + test('a child that never becomes ready rejects startup and is reaped', async () => { + const workerPath = join(directory(), 'stalled.ts') + await Bun.write(workerPath, 'process.on("message", () => {}); setInterval(() => {}, 1000)') + const runtime = manager({ workerPath, startupTimeoutMs: 30 }) + await expect( + runtime.call( + directory(), + { id: 'test', kind: 'system' }, + { type: 'snapshot', scope: 'board' } + ) + ).rejects.toThrow('did not start') + await until(() => runtime.debugSnapshot().length === 0) + }) + + test('parent process death does not leave an orphan collab worker', async () => { + const root = directory() + const parentPath = join(root, 'parent.ts') + await Bun.write( + parentPath, + ` + import { CollabManager } from ${JSON.stringify(join(import.meta.dir, 'manager.ts'))}; + const runtime = new CollabManager({ enabled: async () => true }); + await runtime.call(${JSON.stringify(root)}, { id: 'test', kind: 'system' }, { type: 'snapshot', scope: 'board' }); + console.log(runtime.debugSnapshot()[0].pid); + process.exit(0); + ` + ) + const parent = Bun.spawn([process.execPath, parentPath], { stdout: 'pipe', stderr: 'inherit' }) + const pid = Number((await new Response(parent.stdout).text()).trim()) + expect(await parent.exited).toBe(0) + expect(Number.isInteger(pid) && pid > 0).toBe(true) + try { + await until(() => { + try { + process.kill(pid, 0) + return false + } catch { + return true + } + }) + } finally { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + }) +}) diff --git a/server/collab/manager.ts b/server/collab/manager.ts new file mode 100644 index 00000000..36179682 --- /dev/null +++ b/server/collab/manager.ts @@ -0,0 +1,430 @@ +import { realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + COLLAB_MAX_MESSAGE_BYTES, + isCollabActor, + isCollabClientMessage, + isCollabCommand, + isCollabString, + isRecord +} from '@/lib/collab/protocol' +import type { + CollabActor, + CollabCommand, + CollabCommandResult, + CollabServerMessage +} from '@/lib/collab/types' + +import { isCollabEnabled } from './config' +import type { ParentMessage, WorkerMessage } from './ipc' + +export type CollabSocket = { + send: (message: string) => number + close: (code?: number, reason?: string) => void + getBufferedAmount?: () => number +} + +type Pending = { + resolve: (result: CollabCommandResult) => void + reject: (error: Error) => void + timer: ReturnType +} + +type Binding = { + socket: CollabSocket + connectionId: string + closed: boolean + lastSeen: number + joined: boolean + slot?: Slot + queue: Promise +} + +type Slot = { + workspacePath: string + generation: string + child: ReturnType + ready: Promise + resolveReady: () => void + rejectReady: (error: Error) => void + readyTimer: ReturnType + stopping: boolean + clients: Map + pending: Map + idleTimer?: ReturnType +} + +type ManagerOptions = { + enabled?: (workspacePath: string) => Promise + workerPath?: string + startupTimeoutMs?: number + requestTimeoutMs?: number + idleTimeoutMs?: number + livenessTimeoutMs?: number +} + +export class CollabRuntimeError extends Error { + constructor( + public code: string, + message: string + ) { + super(message) + } +} + +// One owner map per moi server. No LRU: active workspaces cannot be evicted. +export class CollabManager { + private slots = new Map() + private bindings = new Map() + private failures = new Map() + private livenessTimer: ReturnType | null = null + private closed = false + + constructor(private options: ManagerOptions = {}) {} + + private emit(binding: Binding, message: CollabServerMessage) { + if (binding.closed) return + const buffered = binding.socket.getBufferedAmount?.() ?? 0 + if (buffered > 1024 * 1024) { + if (message.type === 'participants') return + this.disconnect(binding, 'Collab updates need a fresh connection') + return + } + try { + if (binding.socket.send(JSON.stringify(message)) === 0) + this.disconnect(binding, 'Connection lost') + } catch { + this.disconnect(binding, 'Connection lost') + } + } + + private send(slot: Slot, message: ParentMessage) { + if (slot.stopping) throw new CollabRuntimeError('restarting', 'Collab is restarting') + slot.child.send(message) + } + + private async getSlot(workspacePath: string): Promise { + if (this.closed) throw new CollabRuntimeError('unavailable', 'Collab is shutting down') + const canonicalPath = await realpath(workspacePath) + if (!(await (this.options.enabled ?? isCollabEnabled)(canonicalPath))) { + throw new CollabRuntimeError('unavailable', 'Start moi with --experimental-collab') + } + if (this.closed) throw new CollabRuntimeError('unavailable', 'Collab is shutting down') + const current = this.slots.get(canonicalPath) + if (current) { + if (current.stopping) { + await current.child.exited + if (this.slots.get(canonicalPath) === current) this.slots.delete(canonicalPath) + return this.getSlot(canonicalPath) + } + if (current.idleTimer) clearTimeout(current.idleTimer) + current.idleTimer = undefined + return current + } + const failure = this.failures.get(canonicalPath) + if (failure && failure.retryAfter > Date.now()) { + throw new CollabRuntimeError('restarting', 'Collab is recovering; reconnect shortly') + } + return this.spawn(canonicalPath) + } + + private spawn(workspacePath: string): Slot { + let resolveReady: () => void = () => {} + let rejectReady: (error: Error) => void = () => {} + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + // Startup failure can precede a caller's await; callers still observe the original rejection. + void ready.catch(() => {}) + const generation = crypto.randomUUID() + const slot: Slot = { + workspacePath, + generation, + child: undefined as never, + ready, + resolveReady, + rejectReady, + readyTimer: setTimeout(() => { + slot.rejectReady(new CollabRuntimeError('startup_timeout', 'Collab did not start in time')) + this.stopSlot(slot, 'Collab startup timed out') + }, this.options.startupTimeoutMs ?? 10_000), + stopping: false, + clients: new Map(), + pending: new Map() + } + try { + slot.child = Bun.spawn( + [process.execPath, this.options.workerPath ?? join(import.meta.dir, 'worker.ts')], + { + cwd: tmpdir(), + env: { + PATH: process.env.PATH, + MOI_COLLAB_WORKSPACE: workspacePath, + MOI_COLLAB_GENERATION: generation + }, + stdout: 'inherit', + stderr: 'inherit', + ipc: (raw: unknown) => this.receive(slot, raw as WorkerMessage), + onExit: () => this.exited(slot) + } + ) + this.slots.set(workspacePath, slot) + } catch (error) { + clearTimeout(slot.readyTimer) + rejectReady(error instanceof Error ? error : new Error('Collab could not start')) + throw error + } + return slot + } + + private receive(slot: Slot, message: WorkerMessage) { + if (slot.stopping || message.generation !== slot.generation) return + if (message.type === 'ready') { + clearTimeout(slot.readyTimer) + slot.resolveReady() + return + } + if (message.type === 'client') { + const binding = slot.clients.get(message.connectionId) + if (binding) this.emit(binding, message.message) + return + } + const pending = slot.pending.get(message.requestId) + if (!pending) return + slot.pending.delete(message.requestId) + clearTimeout(pending.timer) + if (message.type === 'error') + pending.reject(new CollabRuntimeError(message.code, message.message)) + else pending.resolve(message.result) + this.scheduleIdle(slot) + } + + private exited(slot: Slot) { + const expected = slot.stopping + slot.stopping = true + clearTimeout(slot.readyTimer) + if (slot.idleTimer) clearTimeout(slot.idleTimer) + const error = new CollabRuntimeError( + 'worker_exited', + 'Collab restarted; reconnect to check pending saves' + ) + slot.rejectReady(error) + for (const pending of slot.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + slot.pending.clear() + for (const binding of [...slot.clients.values()]) this.disconnect(binding, error.message) + if (this.slots.get(slot.workspacePath) === slot) this.slots.delete(slot.workspacePath) + if (!expected) { + const count = (this.failures.get(slot.workspacePath)?.count ?? 0) + 1 + this.failures.set(slot.workspacePath, { + count, + retryAfter: Date.now() + Math.min(250 * 2 ** Math.min(count - 1, 7), 30_000) + }) + } + } + + private scheduleIdle(slot: Slot) { + if (slot.stopping || slot.clients.size || slot.pending.size || slot.idleTimer) return + slot.idleTimer = setTimeout( + () => this.stopSlot(slot, 'Collab workspace is idle'), + this.options.idleTimeoutMs ?? 60_000 + ) + slot.idleTimer.unref() + } + + private stopSlot(slot: Slot, reason: string) { + if (slot.stopping) return + slot.stopping = true + clearTimeout(slot.readyTimer) + slot.rejectReady(new CollabRuntimeError('restarting', reason)) + for (const binding of [...slot.clients.values()]) this.disconnect(binding, reason) + try { + slot.child.send({ type: 'shutdown' } satisfies ParentMessage) + } catch {} + const deadline = setTimeout(() => slot.child.kill('SIGKILL'), 2000) + deadline.unref() + void slot.child.exited.finally(() => clearTimeout(deadline)) + } + + private disconnect(binding: Binding, reason: string) { + if (binding.closed) return + this.close(binding.socket) + binding.socket.close(1012, reason.slice(0, 120)) + } + + open(socket: CollabSocket, workspacePath: string) { + const binding: Binding = { + socket, + connectionId: crypto.randomUUID(), + closed: false, + lastSeen: Date.now(), + joined: false, + queue: Promise.resolve() + } + this.bindings.set(socket, binding) + binding.queue = this.getSlot(workspacePath) + .then(async slot => { + if (binding.closed) { + this.scheduleIdle(slot) + return + } + binding.slot = slot + slot.clients.set(binding.connectionId, binding) + await slot.ready + }) + .catch(error => { + this.emit(binding, { + type: 'error', + code: 'unavailable', + message: error instanceof Error ? error.message : 'Collab unavailable' + }) + this.disconnect(binding, 'Collab unavailable') + }) + this.livenessTimer ??= setInterval(() => { + const now = Date.now() + for (const client of this.bindings.values()) { + const timeout = client.joined ? (this.options.livenessTimeoutMs ?? 60_000) : 15_000 + if (now - client.lastSeen > timeout) this.disconnect(client, 'Collab connection timed out') + } + }, 5000) + this.livenessTimer.unref() + } + + message(socket: CollabSocket, raw: string | Uint8Array) { + const binding = this.bindings.get(socket) + if (!binding || binding.closed) return + const size = typeof raw === 'string' ? new TextEncoder().encode(raw).length : raw.byteLength + if (size > COLLAB_MAX_MESSAGE_BYTES) { + this.disconnect(binding, 'Collab message is too large') + return + } + let message: unknown + try { + message = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw)) + } catch {} + if (!isCollabClientMessage(message)) { + this.emit(binding, { + type: 'error', + code: 'invalid_message', + message: 'Invalid collab message', + ...(isRecord(message) && isCollabString(message.operationId) + ? { operationId: message.operationId } + : {}), + ...(isRecord(message) && isCollabString(message.subscriptionId) + ? { subscriptionId: message.subscriptionId } + : {}), + ...(isRecord(message) && isCollabString(message.requestId) + ? { requestId: message.requestId } + : {}) + }) + return + } + binding.lastSeen = Date.now() + const validated = message + binding.queue = binding.queue + .then(() => { + if (binding.closed || !binding.slot) return + if (validated.type === 'join') binding.joined = true + this.send(binding.slot, { + type: 'client', + connectionId: binding.connectionId, + message: validated + }) + }) + .catch(() => this.disconnect(binding, 'Collab restarted')) + } + + close(socket: CollabSocket) { + const binding = this.bindings.get(socket) + if (!binding) return + binding.closed = true + this.bindings.delete(socket) + if (!this.bindings.size && this.livenessTimer) { + clearInterval(this.livenessTimer) + this.livenessTimer = null + } + const slot = binding.slot + if (!slot) return + slot.clients.delete(binding.connectionId) + if (!slot.stopping) { + try { + this.send(slot, { type: 'leave', connectionId: binding.connectionId }) + } catch {} + } + this.scheduleIdle(slot) + } + + async call( + workspacePath: string, + actor: CollabActor, + command: CollabCommand + ): Promise { + if (!isCollabActor(actor) || !isCollabCommand(command)) { + throw new CollabRuntimeError('invalid_request', 'Invalid collab actor or command') + } + if (new TextEncoder().encode(JSON.stringify(command)).length > COLLAB_MAX_MESSAGE_BYTES) { + throw new CollabRuntimeError('too_large', 'Collab request is too large') + } + const slot = await this.getSlot(workspacePath) + const requestId = crypto.randomUUID() + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + slot.pending.delete(requestId) + reject( + new CollabRuntimeError( + 'timeout', + 'Collab request timed out; check its receipt before retrying' + ) + ) + this.scheduleIdle(slot) + }, this.options.requestTimeoutMs ?? 15_000) + slot.pending.set(requestId, { resolve, reject, timer }) + void slot.ready + .then(() => { + if (!slot.pending.has(requestId)) return + this.send(slot, { type: 'call', requestId, actor, command }) + }) + .catch(error => { + clearTimeout(timer) + slot.pending.delete(requestId) + reject(error) + this.scheduleIdle(slot) + }) + }) + } + + async stopWorkspace(workspacePath: string) { + const canonicalPath = await realpath(workspacePath) + const slot = this.slots.get(canonicalPath) + if (slot) this.stopSlot(slot, 'Collab disabled for this workspace') + } + + async shutdown() { + this.closed = true + if (this.livenessTimer) clearInterval(this.livenessTimer) + this.livenessTimer = null + const slots = [...this.slots.values()] + for (const slot of slots) this.stopSlot(slot, 'moi is shutting down') + await Promise.allSettled(slots.map(slot => slot.child.exited)) + } + + debugSnapshot() { + return [...this.slots.values()].map(slot => ({ + workspacePath: slot.workspacePath, + pid: slot.child.pid, + generation: slot.generation, + connections: slot.clients.size, + pending: slot.pending.size, + stopping: slot.stopping + })) + } +} + +export const collabManager = new CollabManager() +export const callCollab = (workspacePath: string, actor: CollabActor, command: CollabCommand) => + collabManager.call(workspacePath, actor, command) diff --git a/server/collab/skill/collab-env.d.ts b/server/collab/skill/collab-env.d.ts new file mode 100644 index 00000000..a023c86e --- /dev/null +++ b/server/collab/skill/collab-env.d.ts @@ -0,0 +1,85 @@ +// Generated by `moi init --experimental-collab`; do not edit this file. +declare module 'moi/collab' { + import type { ReactElement, ReactNode } from 'react' + + export type CollabJsonValue = + | null + | boolean + | number + | string + | CollabJsonValue[] + | { [key: string]: CollabJsonValue } + + export type CollabIdentity = { id: string; name: string; avatar?: string; color: string } + export type CollabLocation = { page: string; title?: string } + export type CollabPresenceRegistration = { + registrationId: string + surface: string + channel: string + value: CollabJsonValue + } + export type CollabParticipant = { + connectionId: string + identity: CollabIdentity + location: CollabLocation | null + presence: CollabPresenceRegistration[] + } + export type CollabOperation = + | { type: 'set'; key: string; value: CollabJsonValue } + | { type: 'delete'; key: string } + export type MutationOutcome = + | { status: 'committed'; revision: number } + | { status: 'rejected' | 'unknown'; message: string } + + export type OthersOptions = { scope?: 'page' | 'workspace' } + export function useSelf(): CollabParticipant | null + export function useOthers(options?: OthersOptions): CollabParticipant[] + export type PresenceValue = { participant: CollabParticipant; value: T } + export function usePresence( + channel: string, + initialValue: T + ): { value: T; setValue: (value: T) => void; others: PresenceValue[] } + + export type SharedOptions = { scope?: string } + export type SharedStateOptions = SharedOptions & { defaultValue?: T } + export type SharedStatus = { + loaded: boolean + canWrite: boolean + isSaving: boolean + error: string | null + } + export function useSharedState( + key: string, + options?: SharedStateOptions + ): SharedStatus & { + value: T | undefined + exists: boolean + setValue: (value: T) => Promise + deleteValue: () => Promise + } + export function useSharedStore( + prefix?: string, + options?: SharedOptions + ): SharedStatus & { + entries: Readonly> + set: (key: string, value: CollabJsonValue) => Promise + delete: (key: string) => Promise + batch: (operations: CollabOperation[]) => Promise + } + + export type ActivityProps = { scope?: 'page' | 'workspace'; className?: string } + export function Activity(props: ActivityProps): ReactElement + export type CursorsProps = { surface?: string; children: ReactNode; className?: string } + export function Cursors(props: CursorsProps): ReactElement + export type PresenceFieldProps = { target: string; children: ReactNode; className?: string } + export function PresenceField(props: PresenceFieldProps): ReactElement + export type SelectionProps = { + target: string + selected: boolean + children: ReactNode + className?: string + } + export function Selection(props: SelectionProps): ReactElement + export type SyncStatusProps = { className?: string } + export function SyncStatus(props: SyncStatusProps): ReactElement +} diff --git a/server/collab/skill/index.ts b/server/collab/skill/index.ts new file mode 100644 index 00000000..014fff18 --- /dev/null +++ b/server/collab/skill/index.ts @@ -0,0 +1,60 @@ +import { mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import type { WorkspaceType } from '@/lib/types' + +import { skillsDirFor } from '../../workspace-init' + +export const COLLAB_REFERENCE_SOURCE_PATH = join(import.meta.dir, 'references', 'COLLABORATIVE.md') +const TYPES_SOURCE_PATH = join(import.meta.dir, 'collab-env.d.ts') + +export type CollabSkillPaths = { referencePath: string; typesPath: string } + +export function collabSkillReferencePath(workspacePath: string, type?: WorkspaceType): string { + return join(skillsDirFor(workspacePath, type), 'moi-workspace', 'references', 'COLLABORATIVE.md') +} + +function targetPaths(workspacePath: string, type?: WorkspaceType): CollabSkillPaths { + return { + referencePath: collabSkillReferencePath(workspacePath, type), + typesPath: join(workspacePath, '.moi', 'collab-env.d.ts') + } +} + +async function writeChanged(path: string, contents: string): Promise { + const file = Bun.file(path) + if ((await file.exists()) && (await file.text()) === contents) return + await mkdir(dirname(path), { recursive: true }) + await Bun.write(path, contents) +} + +// Keep this outside the ordinary skill template: only an explicit +// `moi init --experimental-collab` installs the guide and types. +export async function installCollabSkill( + workspacePath: string, + type?: WorkspaceType +): Promise { + const source = Bun.file(COLLAB_REFERENCE_SOURCE_PATH) + if (!(await source.exists())) { + throw new Error( + 'The collaboration guide is unavailable in this build: COLLABORATIVE.md is missing.' + ) + } + const [reference, declarations] = await Promise.all([ + source.text(), + Bun.file(TYPES_SOURCE_PATH).text() + ]) + const paths = targetPaths(workspacePath, type) + await writeChanged(paths.referencePath, reference) + await writeChanged(paths.typesPath, declarations) + return paths +} + +export async function removeCollabSkill( + workspacePath: string, + type?: WorkspaceType +): Promise { + const paths = targetPaths(workspacePath, type) + await rm(paths.referencePath, { force: true }) + await rm(paths.typesPath, { force: true }) +} diff --git a/server/collab/skill/references/COLLABORATIVE.md b/server/collab/skill/references/COLLABORATIVE.md new file mode 100644 index 00000000..6c3a9962 --- /dev/null +++ b/server/collab/skill/references/COLLABORATIVE.md @@ -0,0 +1,290 @@ +# Collaborative applets + +This optional reference is installed by `moi init --experimental-collab`. Start the server with +`moi start --experimental-collab` to use shared state; there is no per-workspace flag. Follow the main workspace skill +and [DESIGN.md](DESIGN.md) for normal applet development and styling. This optional reference +describes the additional applet API; it does not require another skill or an applet provider. + +## Imports and identity + +Import the built-in API from `moi/collab`. Do not install an npm package or create a connection. +The host supplies the connection and current applet context. The generated `.moi/collab-env.d.ts` +provides editor types; flagged init refreshes it alongside this reference. Ordinary init and UI +workspace creation do not install collaboration documents. + +The start flag enables runtime only. Shared-state hooks work without a profile; anonymous callers +do not appear in presence. `useSelf()` stays `null` until identity is supplied through `/dev/collab` +or the outer host bridge. Applets must not create an identity to read or write shared data. +Workspace Share/people controls and personal navigation require an explicit identity. + +```tsx +import { + Activity, + Cursors, + PresenceField, + Selection, + SyncStatus, + useSelf, + useOthers, + usePresence, + useSharedState, + useSharedStore +} from 'moi/collab' +``` + +`useSelf()` returns a participant or `null` while unavailable. Each participant has: + +| Field | Meaning | +| ----------------- | -------------------------------------------------- | +| `identity.id` | The person's stable id. | +| `identity.name` | Display name supplied by the workspace. | +| `identity.avatar` | Optional avatar URL. | +| `identity.color` | Their supplied display color. | +| `connectionId` | This live connection; one person may have several. | +| `location` | Their current `{ page, title? }`, or `null`. | +| `presence` | Temporary registrations from their applets. | + +Read identity; do not fabricate or replace it inside applets. `useOthers()` returns other +connections on the current page. `useOthers({ scope: 'workspace' })` includes other pages. +These arrays contain connections, so the same person may appear more than once. `` +deduplicates its avatar stack by `identity.id`. + +## Components + +| Component | Props and behavior | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `` | Avatar stack. Optional `scope="page"` (default) or `scope="workspace"`, and `className`. | +| `` | Wraps a surface and displays other pointers. Accepts `children`, optional stable `surface` name and `className`. | +| `` | Wraps a control, reports focus, and shows other editors. Required stable `target`, `children`; optional `className`. | +| `` | Shows other selections around an item. Required `target`, local `selected` boolean, and `children`; optional `className`. | +| `` | Connection/save status. Optional `className`. Place beside controls that change shared data. | + +```tsx + + + + + + + + +``` + +Targets describe data and must be stable across people, for example `task/42/title`. Do not use +array indices, display names, or random ids generated while rendering. Field and selection +wrappers also anchor cursors to meaningful items. For custom controls, an element may use +`data-collab-target="task/42"` inside ``. + +Presence is advisory. A colored field does not lock it or save its value. `` reports +your local selection; keep `selectedId` in local React state. Multiple cursor surfaces in one +applet need different stable `surface` names. Free space in differently arranged canvases has +no automatic shared coordinate system; use semantic targets for items. + +## Temporary presence + +```tsx +const selection = usePresence<{ taskId: string | null }>('selection', { taskId: null }) + +// From an event handler: +selection.setValue({ taskId: '42' }) + +// Other mounted registrations on this applet surface and channel: +selection.others.map(({ participant, value }) => ({ + name: participant.identity.name, + taskId: value.taskId +})) +``` + +`usePresence(channel, initialValue)` returns `{ value, setValue, others }`. +`setValue` replaces the complete channel value; it is not a partial patch and is not persistent. +Each hook registration belongs to its own mount. A different widget cannot clear its state. +Cleanup, hiding, and rebuilding applets remove inactive presence automatically. Use presence for +cursors, focused items, and selections; use shared storage for information that must survive a refresh. + +## Shared values and readiness + +```tsx +const done = useSharedState('task/42/done', { + scope: 'shared:tasks', + defaultValue: false +}) + +if (!done.loaded) return

Loading…

+ +return ( + void done.setValue(event.target.checked)} + /> +) +``` + +The result exposes `value`, `exists`, `loaded`, `canWrite`, `isSaving`, `error`, +`setValue(value)`, and `deleteValue()`. + +- Before `loaded`, the value is unavailable. `canWrite` also requires a live connection. +- `defaultValue` is a display fallback after loading confirms the key is absent. It never writes + data. Mounting or remounting a hook cannot recreate a deleted value. +- Save methods return promises that resolve with `{ status: 'committed', revision }` or + `{ status: 'rejected' | 'unknown', message }`. Handle the result or visibly render `error`. +- `unknown` means a save could not be confirmed. Keep the user's draft and let them deliberately + reapply it after reconnecting. Do not automatically replay old or offline edits. +- Local optimistic changes may appear while `isSaving` is true. Treat `committed` as confirmation. + +```tsx +const outcome = await title.setValue(draft) +if (outcome.status === 'committed') { + setEditing(false) +} else { + setError(outcome.message) // Keep draft available. +} +``` + +The default storage scope is `applet::`, for example `applet:view:board`. It survives +rebuilds. Use an explicit scope such as `shared:tasks` when several widgets/views share data. +Renaming an applet changes its default scope; choose a stable explicit scope when needed. + +## Collections, fields, and batches + +`useSharedStore(prefix, { scope })` returns prefix-relative `entries`, `set`, `delete`, `batch`, +and the same `loaded`, `canWrite`, `isSaving`, and `error` status fields. Never mutate `entries` +directly. With prefix `task/`, entry `42/title` addresses the full key `task/42/title`. + +```tsx +const tasks = useSharedStore('task/', { scope: 'shared:tasks' }) +const id = crypto.randomUUID() // Create once in the Add task event handler. + +await tasks.batch([ + { type: 'set', key: `${id}/exists`, value: true }, + { type: 'set', key: `${id}/title`, value: 'Ship demo' }, + { type: 'set', key: `${id}/done`, value: false } +]) + +await tasks.set(`${id}/done`, true) + +await tasks.batch([ + { type: 'delete', key: `${id}/exists` }, + { type: 'delete', key: `${id}/title` }, + { type: 'delete', key: `${id}/done` } +]) +``` + +A batch applies all its changes together within one scope. Creating a task writes its membership +and initial fields together. Deleting it removes membership and its known fields together. List +tasks only from `/exists` keys whose value is `true`; a delayed title edit then cannot make a +deleted task visible again. Only explicit creation/restoration writes membership. + +Different field keys merge independently: one person can change a title while another checks +the task. On the same key, the last committed write wins. Arrays and objects are each one value; +replacing an entire task array may overwrite another person's work. Use small field keys. +Strings are replaced, not collaboratively merged character by character. A local read followed by +`set` does not provide an atomic counter increment or enforce a reservation rule. + +Values must be plain JSON: no functions, dates, undefined, cycles, or non-finite numbers. +Keep values under 64 KiB and mutations under 256 KiB/100 operations. Prefer compact records to +large blobs. All persistent writes go through this API or the workspace's collab command access; +do not modify the underlying shared storage files. + +## Complete task-list view + +This example uses native controls; use the workspace's installed UI components for final styling. +It creates tasks only in an event handler, keeps collection membership explicit, and shows save +errors. Put it in a view source file and rebuild with `moi bundle`. + +```tsx +import { useState } from 'react' +import { Activity, Cursors, PresenceField, SyncStatus, useSharedStore } from 'moi/collab' + +export const config = { title: 'Team tasks' } + +export default function TeamTasks() { + const tasks = useSharedStore('task/', { scope: 'shared:tasks' }) + const [draft, setDraft] = useState('') + const ids = Object.keys(tasks.entries) + .filter(key => key.endsWith('/exists') && tasks.entries[key] === true) + .map(key => key.slice(0, -'/exists'.length)) + + async function addTask() { + const title = draft.trim() + if (!title || !tasks.canWrite) return + const id = crypto.randomUUID() + const outcome = await tasks.batch([ + { type: 'set', key: `${id}/exists`, value: true }, + { type: 'set', key: `${id}/title`, value: title }, + { type: 'set', key: `${id}/done`, value: false } + ]) + if (outcome.status === 'committed') setDraft(current => (current === draft ? '' : current)) + } + + if (!tasks.loaded) return

Loading tasks…

+ + return ( + +
+

Team tasks

+ + +
+
{ + event.preventDefault() + void addTask() + }} + > + setDraft(event.target.value)} + /> + +
+ {tasks.error &&

{tasks.error}

} + {ids.map(id => ( +
+ void tasks.set(`${id}/done`, event.target.checked)} + /> + + void tasks.set(`${id}/title`, event.target.value)} + /> + + +
+ ))} +
+ ) +} +``` + +## Before considering an applet complete + +Try it in two browser tabs with different participants. Edit different fields, then the same field. +Delete an item while the other tab is editing it. Refresh and confirm the data remains. Navigate +away and confirm stale focus/cursors disappear. Disconnect and confirm the UI preserves drafts and +shows an unresolved save rather than silently claiming success. Keep personal navigation and +selections local; sharing data does not require making everyone look at the same view. diff --git a/server/collab/worker.ts b/server/collab/worker.ts new file mode 100644 index 00000000..f32fe7c4 --- /dev/null +++ b/server/collab/worker.ts @@ -0,0 +1,71 @@ +import { join } from 'node:path' + +import type { CollabServerMessage } from '@/lib/collab/types' + +import type { ParentMessage, WorkerMessage } from './ipc' +import { CollabService } from './service' +import { openCollabStorage } from './storage' + +const workspacePath = process.env.MOI_COLLAB_WORKSPACE +const generation = process.env.MOI_COLLAB_GENERATION +if (!workspacePath || !generation || !process.send) throw new Error('Missing collab worker context') + +function send(message: WorkerMessage) { + process.send?.(message) +} + +const storage = openCollabStorage(join(workspacePath, '.moi', 'data', 'collab.sqlite')) +const service = new CollabService(storage, (connectionId, message) => { + send({ type: 'client', generation, connectionId, message }) +}) + +function errorInfo(error: unknown): { code: string; message: string } { + return { + code: error instanceof Error && 'code' in error ? String(error.code) : 'invalid_request', + message: error instanceof Error ? error.message : 'Collab request failed' + } +} + +process.on('message', (message: ParentMessage) => { + if (message.type === 'shutdown') return shutdown() + if (message.type === 'leave') return service.leave(message.connectionId) + if (message.type === 'client') { + try { + service.receive(message.connectionId, message.message) + } catch (error) { + const source = message.message + const response: CollabServerMessage = { + type: 'error', + ...errorInfo(error), + ...('operationId' in source ? { operationId: source.operationId } : {}), + ...('subscriptionId' in source ? { subscriptionId: source.subscriptionId } : {}), + ...('requestId' in source ? { requestId: source.requestId } : {}) + } + send({ type: 'client', generation, connectionId: message.connectionId, message: response }) + } + return + } + if (message.type === 'call') { + try { + const result = service.run(message.actor, message.command) + send({ type: 'result', generation, requestId: message.requestId, result }) + } catch (error) { + send({ type: 'error', generation, requestId: message.requestId, ...errorInfo(error) }) + } + } +}) + +const pruneTimer = setInterval(() => storage.pruneReceipts(), 60 * 60_000) +pruneTimer.unref() +let shuttingDown = false +function shutdown() { + if (shuttingDown) return + shuttingDown = true + clearInterval(pruneTimer) + service.close() + process.exit(0) +} +process.on('disconnect', shutdown) +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) +send({ type: 'ready', generation }) diff --git a/server/service.ts b/server/service.ts index 5953ee32..8e9d297b 100644 --- a/server/service.ts +++ b/server/service.ts @@ -128,7 +128,14 @@ const ENV_ALLOW_PREFIXES = ['MOI_', 'ANTHROPIC_', 'OPENCLAW_', 'OPENAI_', 'PUBLI // Runtime flags the unit stamps itself — never inherited from the installing // shell (a dev shell must not bake MOI_DEV into the service, and an // agent-run install must not bake its MOI_AGENT marker into the daemon). -const ENV_OWNED = new Set(['MOI_SERVER', 'MOI_SERVICE', 'MOI_DEV', 'MOI_DEBUG', 'MOI_AGENT']) +const ENV_OWNED = new Set([ + 'MOI_SERVER', + 'MOI_SERVICE', + 'MOI_DEV', + 'MOI_DEBUG', + 'MOI_AGENT', + 'MOI_EXPERIMENTAL_COLLAB' +]) const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ diff --git a/server/test/cli-collab.test.ts b/server/test/cli-collab.test.ts new file mode 100644 index 00000000..df4e1c12 --- /dev/null +++ b/server/test/cli-collab.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const CLI = join(import.meta.dir, '..', 'cli.ts') +let directory: string +let workspace: string +let capturePath: string +let env: Record +const children: ReturnType[] = [] + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'moi-cli-collab-')) + workspace = join(directory, 'workspace') + capturePath = join(directory, 'server-env.json') + await mkdir(join(workspace, '.moi'), { recursive: true }) + await Bun.write(join(workspace, '.moi', 'package.json'), '{}\n') + const bin = join(directory, 'bin') + await mkdir(bin) + // The real CLI runs under process.execPath. Its spawned server goes through + // this stub so we can assert the launch boundary without starting moi. + await Bun.write( + join(bin, 'bun'), + '#!/bin/sh\n' + + `printf '{"enabled":"%s","dev":"%s","server":"%s"}\\n' "$MOI_EXPERIMENTAL_COLLAB" "$MOI_DEV" "$MOI_SERVER" > "$COLLAB_CAPTURE_PATH"\n` + ) + await chmod(join(bin, 'bun'), 0o755) + env = { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ''}`, + MOI_DATA_DIR: join(directory, 'data'), + // Never probe the user's running server during CLI tests. + MOI_CONTROL_PORT: '1', + MOI_SERVER: undefined, + MOI_SERVICE: undefined, + MOI_CLOUD_DEMO: undefined, + MOI_DEV: undefined, + // Neither a legacy flag nor an inherited child marker enables plain start. + MOI_COLLAB: '1', + MOI_EXPERIMENTAL_COLLAB: '1', + COLLAB_CAPTURE_PATH: capturePath, + NO_COLOR: '1' + } +}) + +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode === null) child.kill('SIGTERM') + await child.exited + } + await rm(directory, { recursive: true, force: true }) +}) + +function spawnCli(args: string[]) { + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: workspace, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + env + }) + children.push(child) + return child +} + +async function runCli(args: string[]) { + const child = spawnCli(args) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text() + ]) + return { code, stdout, stderr } +} + +async function capturedServerEnv(): Promise<{ enabled: string; dev: string; server: string }> { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const file = Bun.file(capturePath) + if (await file.exists()) { + const value = await file.text() + if (value.endsWith('\n')) return JSON.parse(value) + } + await Bun.sleep(20) + } + throw new Error('The CLI did not launch its server child') +} + +describe('collab runtime CLI flag', () => { + for (const dev of [false, true]) { + for (const enabled of [false, true]) { + const args = [ + 'start', + ...(dev ? ['--dev'] : []), + ...(enabled ? ['--experimental-collab'] : []) + ] + test(`${args.join(' ')} sets the child runtime explicitly`, async () => { + const child = spawnCli(args) + expect(await capturedServerEnv()).toEqual({ + enabled: enabled ? '1' : '0', + dev: dev ? '1' : '', + server: '1' + }) + if (dev) child.kill('SIGTERM') + expect(await child.exited).toBe(0) + }, 15_000) + } + } +}) + +describe('collab init CLI flag', () => { + test('plain init omits the guide; flagged init adds it and plain refresh preserves it', async () => { + const args = ['init', '--harness=codex'] + const guide = join( + workspace, + '.agents', + 'skills', + 'moi-workspace', + 'references', + 'COLLABORATIVE.md' + ) + const types = join(workspace, '.moi', 'collab-env.d.ts') + expect((await runCli(args)).code).toBe(0) + expect(await Bun.file(guide).exists()).toBe(false) + expect(await Bun.file(types).exists()).toBe(false) + const initialized = await runCli([...args, '--experimental-collab']) + expect(initialized.code).toBe(0) + expect(initialized.stdout).toContain('Collaboration guide installed') + expect(await Bun.file(guide).exists()).toBe(true) + expect(await Bun.file(types).exists()).toBe(true) + expect(await Bun.file(capturePath).exists()).toBe(false) + expect((await runCli(args)).code).toBe(0) + expect(await Bun.file(guide).exists()).toBe(true) + expect(await Bun.file(types).exists()).toBe(true) + }, 30_000) + + test('init --web --experimental-collab installs docs without enabling the runtime', async () => { + spawnCli(['init', '--harness=codex', '--web', '--experimental-collab']) + expect((await capturedServerEnv()).enabled).toBe('0') + expect(await Bun.file(join(workspace, '.moi', 'collab-env.d.ts')).exists()).toBe(true) + }, 15_000) + + test('both commands explain their separate flags in help', async () => { + const start = await runCli(['start', '--help']) + expect(start.code).toBe(0) + expect(start.stdout).toContain('--experimental-collab') + expect(start.stdout).toContain('no identity or workspace UI') + const init = await runCli(['init', '--help']) + expect(init.code).toBe(0) + expect(init.stdout).toContain('--experimental-collab') + expect(init.stdout).toContain('guide and applet types') + }, 15_000) +}) diff --git a/server/test/service.test.ts b/server/test/service.test.ts index 0e953ce2..218f3aff 100644 --- a/server/test/service.test.ts +++ b/server/test/service.test.ts @@ -91,7 +91,8 @@ describe('captureServiceEnv', () => { PORT: '9999', // Runtime flags the unit owns. MOI_DEV: '1', - MOI_SERVER: '1' + MOI_SERVER: '1', + MOI_EXPERIMENTAL_COLLAB: '1' } test('captures the allowlist, drops everything else', () => { @@ -118,7 +119,8 @@ describe('captureServiceEnv', () => { 'HOST', 'HOSTNAME', 'PORT', - 'MOI_DEV' + 'MOI_DEV', + 'MOI_EXPERIMENTAL_COLLAB' ]) { expect(env).not.toHaveProperty(key) } diff --git a/server/web.ts b/server/web.ts index a7f8e9a1..9977a6f0 100644 --- a/server/web.ts +++ b/server/web.ts @@ -1,10 +1,12 @@ import type { ClientMessage, StatusSnapshotMessage } from '@/lib/types' -import { isMoiContext } from '@/lib/moi-context' +import { isMoiContext, type MoiContext } from '@/lib/moi-context' import index from '../client/index.html' import { api } from './api' import { PORT } from './constants' import { control } from './control' +import { getCollabCapability, isCollabEnabled } from './collab/config' +import { collabManager } from './collab/manager' import { EVENTS_TOPIC, publishEvent, setEventServer } from './events' import { killBuildWorkers } from './applets/build-worker' import { killAllWorkers } from './functions' @@ -19,7 +21,11 @@ import { distShell, prebuilt } from './static' import { renderStatus } from './status' import { serveVendorEmojibase, serveVendorReact } from './vendor' -type WsData = { channel: 'chat' | 'events'; workspaceId: string } +type WsData = { + channel: 'chat' | 'events' | 'collab' + workspaceId: string + workspacePath?: string +} function isClientMessage(value: unknown): value is ClientMessage { if (typeof value !== 'object' || value === null || !('type' in value)) return false @@ -120,14 +126,31 @@ export const app = Bun.serve({ // routes it ahead of the Hono-served `/api/workspaces/:id`; the upgrade // happens in-handler via the route's `server` argument. '/api/workspaces/ws': (req: Request, server: Bun.Server) => - upgrade(server, req, { channel: 'events', workspaceId: '' }) + upgrade(server, req, { channel: 'events', workspaceId: '' }), + + '/api/workspaces/:id/collab/ws': async (req: Request, server: Bun.Server) => { + const workspaceId = decodeURIComponent(new URL(req.url).pathname.split('/')[3] ?? '') + const workspace = await getWorkspace(workspaceId) + if (!workspace) return new Response('Workspace not found', { status: 404 }) + if (!isCollabEnabled()) { + return new Response('Collab is not enabled for this workspace', { status: 403 }) + } + return upgrade(server, req, { + channel: 'collab', + workspaceId, + workspacePath: workspace.path + }) + } }, // Anything not matched above (the whole HTTP API + prod static assets + 404) // is handled by Hono. fetch: req => api.fetch(req), websocket: { open(ws) { - if (ws.data.channel === 'chat') { + if (ws.data.channel === 'collab') { + if (ws.data.workspacePath) collabManager.open(ws, ws.data.workspacePath) + else ws.close(1008, 'Missing workspace') + } else if (ws.data.channel === 'chat') { addClient(ws) // Authoritative snapshot of every non-idle session across all // harnesses so the client can light/clear spinners correctly even for @@ -138,6 +161,10 @@ export const app = Bun.serve({ } }, async message(ws, message) { + if (ws.data.channel === 'collab') { + collabManager.message(ws, message) + return + } if (ws.data.channel !== 'chat') return try { const data = JSON.parse(String(message)) @@ -147,6 +174,14 @@ export const app = Bun.serve({ if (data.type === 'chat' && (data.content?.trim() || data.attachments?.length)) { const workspace = await getWorkspace(data.workspaceId) if (!workspace) return + const collab = await getCollabCapability(workspace.path, workspace.type) + const context: MoiContext | undefined = + data.context || collab.referencePath + ? { + ...(data.context ?? { activeTab: 'agent' }), + collabReference: collab.referencePath + } + : undefined if (data.isNew) { const selection = await saveSelectedSession(workspace.path, data.sessionId, null) if (selection.changed) { @@ -172,7 +207,7 @@ export const app = Bun.serve({ effort: data.effort, fastMode: data.fastMode, stream: data.stream, - context: data.context, + context, agentId: workspace.agentId }) .catch(() => {}) @@ -191,7 +226,8 @@ export const app = Bun.serve({ } catch {} }, close(ws) { - if (ws.data.channel === 'chat') removeClient(ws) + if (ws.data.channel === 'collab') collabManager.close(ws) + else if (ws.data.channel === 'chat') removeClient(ws) else ws.unsubscribe(EVENTS_TOPIC) } } @@ -222,7 +258,10 @@ startServiceLogMaintenance() // changes; in any context Ctrl-C sends SIGINT. Close both servers and kill the // per-workspace function workers and any in-flight applet build child so no // child processes are orphaned. -function shutdown() { +let shuttingDown = false +async function shutdown() { + if (shuttingDown) return + shuttingDown = true try { app.stop(true) } catch {} @@ -232,6 +271,7 @@ function shutdown() { for (const h of allHarnesses()) h.shutdown?.() killAllWorkers() killBuildWorkers() + await collabManager.shutdown() process.exit() } process.on('SIGTERM', shutdown) From 2afd3cd41a58c1794d81599b46885dfb4d8a2afd Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 15 Sep 2026 15:19:14 +0200 Subject: [PATCH 03/86] Add collab client state and external identity bridge --- client/features/collab/client.test.ts | 123 ++++++++ client/features/collab/client.ts | 184 +++++++++++ client/features/collab/identity.test.ts | 94 ++++++ client/features/collab/identity.ts | 125 ++++++++ client/features/collab/store.test.ts | 288 +++++++++++++++++ client/features/collab/store.ts | 404 ++++++++++++++++++++++++ 6 files changed, 1218 insertions(+) create mode 100644 client/features/collab/client.test.ts create mode 100644 client/features/collab/client.ts create mode 100644 client/features/collab/identity.test.ts create mode 100644 client/features/collab/identity.ts create mode 100644 client/features/collab/store.test.ts create mode 100644 client/features/collab/store.ts diff --git a/client/features/collab/client.test.ts b/client/features/collab/client.test.ts new file mode 100644 index 00000000..84084252 --- /dev/null +++ b/client/features/collab/client.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' + +import { isCollabClientMessage } from '@/lib/collab/protocol' +import type { CollabClientMessage } from '@/lib/collab/types' +import { CollabService } from '@/server/collab/service' +import { openCollabStorage } from '@/server/collab/storage' + +import { CollabClient } from './client' +import { getIdentity, setIdentity } from './identity' + +const originalLocation = Object.getOwnPropertyDescriptor(globalThis, 'location') +const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket') +let service: CollabService +let sockets: TestSocket[] +let stop: (() => void) | undefined + +class TestSocket { + static OPEN = 1 + readyState = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + readonly sent: CollabClientMessage[] = [] + readonly id: string + constructor(readonly url: string) { + this.id = `connection-${sockets.length}` + sockets.push(this) + } + open() { + this.readyState = TestSocket.OPEN + this.onopen?.() + } + send(data: string) { + const message: unknown = JSON.parse(data) + if (!isCollabClientMessage(message)) throw new Error('Invalid collab message') + this.sent.push(message) + service.receive(this.id, message) + } + close() { + this.readyState = 3 + service.leave(this.id) + this.onclose?.() + } +} + +beforeEach(() => { + setIdentity(null) + sockets = [] + service = new CollabService(openCollabStorage(':memory:'), (connectionId, message) => { + sockets + .find(socket => socket.id === connectionId) + ?.onmessage?.({ data: JSON.stringify(message) }) + }) + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { protocol: 'http:', host: 'localhost' } + }) + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: TestSocket }) +}) + +afterEach(() => { + stop?.() + stop = undefined + service.close() + setIdentity(null) + if (originalLocation) Object.defineProperty(globalThis, 'location', originalLocation) + else Reflect.deleteProperty(globalThis, 'location') + if (originalWebSocket) Object.defineProperty(globalThis, 'WebSocket', originalWebSocket) + else Reflect.deleteProperty(globalThis, 'WebSocket') +}) + +test('runtime starts idle without assigning an identity; a shared-state applet connects anonymously', async () => { + const client = new CollabClient('workspace') + stop = client.start() + expect(sockets).toHaveLength(0) + expect(getIdentity()).toBeNull() + client.store.setLocation({ page: 'view:board' }) + client.store.setPresence({ + registrationId: 'field', + surface: 'board', + channel: 'focus', + value: 'title' + }) + const releaseScope = client.store.acquireScope('board') + const releaseConnection = client.acquireSharedState() + expect(sockets).toHaveLength(1) + const socket = sockets[0]! + socket.open() + expect(socket.sent[0]).toMatchObject({ type: 'join', identity: null }) + expect(client.store.getSnapshot()).toMatchObject({ status: 'connected', participants: [] }) + expect(client.store.getScopeSnapshot('board').loaded).toBe(true) + expect( + socket.sent.some(message => message.type === 'presence:set' || message.type === 'location') + ).toBe(false) + const outcome = await client.store.mutate('board', [ + { type: 'set', key: 'title', value: 'Anonymous storage' } + ]) + expect(outcome.status).toBe('committed') + expect(client.store.getScopeSnapshot('board').entries.title).toBe('Anonymous storage') + expect(getIdentity()).toBeNull() + releaseScope() + releaseConnection() + expect(socket.readyState).toBe(3) +}) + +test('explicit identity enables workspace presence without an applet and clearing it disconnects', () => { + const client = new CollabClient('workspace') + stop = client.start() + const identity = { id: 'alice', name: 'Alice', color: '#0f766e' } + setIdentity(identity) + expect(sockets).toHaveLength(1) + const socket = sockets[0]! + socket.open() + expect(socket.sent[0]).toMatchObject({ type: 'join', identity }) + expect(client.store.getSnapshot().participants[0]?.identity).toEqual(identity) + setIdentity({ ...identity, name: 'Alicia' }) + expect(socket.sent.at(-1)).toMatchObject({ type: 'identity', identity: { name: 'Alicia' } }) + expect(sockets).toHaveLength(1) + setIdentity(null) + expect(socket.readyState).toBe(3) + expect(client.store.getSnapshot().status).toBe('disconnected') +}) diff --git a/client/features/collab/client.ts b/client/features/collab/client.ts new file mode 100644 index 00000000..875f0cb3 --- /dev/null +++ b/client/features/collab/client.ts @@ -0,0 +1,184 @@ +import { wsUrl } from '@/client/lib/ws-url' +import type { CollabClientMessage, CollabServerMessage } from '@/lib/collab/types' + +import { getIdentity, subscribeIdentityStore } from './identity' +import { CollabStore } from './store' + +// One transport per mounted workspace. An explicit identity joins presence; +// otherwise shared-state hooks acquire an anonymous connection only while used. +export class CollabClient { + readonly store = new CollabStore() + private socket: WebSocket | null = null + private retry: ReturnType | undefined + private heartbeat: ReturnType | undefined + private unsubscribeIdentity: (() => void) | undefined + private presenceTimer: ReturnType | undefined + private queuedPresence = new Map() + private writeTimeouts = new Map>() + private stopped = true + private attempts = 0 + private lastMessageAt = 0 + private userId = getIdentity()?.id ?? null + private readonly anonymousId = crypto.randomUUID() + private sharedStateUsers = 0 + + constructor(readonly workspaceId: string) { + this.store.setSender(message => this.send(message)) + } + + start(): () => void { + this.stopped = false + this.userId = getIdentity()?.id ?? null + this.unsubscribeIdentity = subscribeIdentityStore(() => { + const identity = getIdentity() + if ((identity?.id ?? null) !== this.userId) { + this.userId = identity?.id ?? null + this.store.resetIdentity() + clearTimeout(this.retry) + if (this.socket) this.socket.close() + else this.connect() + } else if (identity) this.send({ type: 'identity', identity }) + }) + this.connect() + return () => this.stop() + } + + acquireSharedState(): () => void { + this.sharedStateUsers++ + if (!this.socket) this.connect() + return () => { + this.sharedStateUsers = Math.max(0, this.sharedStateUsers - 1) + if (!this.wantsConnection()) { + clearTimeout(this.retry) + this.socket?.close() + } + } + } + + private wantsConnection(): boolean { + return !this.stopped && (getIdentity() !== null || this.sharedStateUsers > 0) + } + + private connect(): void { + if (!this.wantsConnection()) { + this.store.disconnect() + return + } + if (this.socket) return + clearTimeout(this.retry) + this.store.connecting() + const socket = new WebSocket( + wsUrl(`/api/workspaces/${encodeURIComponent(this.workspaceId)}/collab/ws`) + ) + this.socket = socket + socket.onopen = () => { + if (socket !== this.socket) return + this.lastMessageAt = Date.now() + const identity = getIdentity() + this.rawSend( + identity + ? { type: 'join', version: 1, identity, location: this.store.getLocation() } + : { type: 'join', version: 1, identity: null, anonymousId: this.anonymousId } + ) + this.heartbeat = setInterval(() => { + if (Date.now() - this.lastMessageAt > 45_000) socket.close() + else this.rawSend({ type: 'ping' }) + }, 15_000) + } + socket.onmessage = event => { + if (socket !== this.socket) return + this.lastMessageAt = Date.now() + try { + const message = JSON.parse(String(event.data)) as CollabServerMessage + if (message.type === 'welcome') this.attempts = 0 + if ( + message.type === 'ack' || + message.type === 'update' || + (message.type === 'error' && message.operationId) + ) { + const operationId = message.operationId + if (operationId) { + clearTimeout(this.writeTimeouts.get(operationId)) + this.writeTimeouts.delete(operationId) + } + } + this.store.receive(message) + } catch { + this.store.disconnect('The collaboration connection returned an invalid message.') + socket.close() + } + } + socket.onerror = () => socket.close() + socket.onclose = () => { + if (socket !== this.socket) return + clearInterval(this.heartbeat) + clearTimeout(this.presenceTimer) + this.presenceTimer = undefined + this.queuedPresence.clear() + for (const timeout of this.writeTimeouts.values()) clearTimeout(timeout) + this.writeTimeouts.clear() + this.socket = null + this.store.disconnect() + if (this.wantsConnection()) { + const delay = Math.min(500 * 2 ** this.attempts++, 10_000) + this.retry = setTimeout(() => this.connect(), delay) + } + } + } + + private send(message: CollabClientMessage): void { + if ( + !getIdentity() && + (message.type === 'location' || + message.type === 'presence:set' || + message.type === 'presence:delete') + ) + return + if (message.type === 'mutate') { + // An open connection is not proof a save completed. Reconnect and ask + // for its receipt if an acknowledgement never arrives. + this.writeTimeouts.set( + message.operationId, + setTimeout(() => this.socket?.close(), 15_000) + ) + } + if (message.type === 'presence:set') { + this.queuedPresence.set(message.registrationId, message) + if (!this.presenceTimer) { + this.presenceTimer = setTimeout(() => { + this.presenceTimer = undefined + for (const update of this.queuedPresence.values()) this.rawSend(update) + this.queuedPresence.clear() + }, 50) + } + return + } + if (message.type === 'presence:delete') this.queuedPresence.delete(message.registrationId) + this.rawSend(message) + } + + private rawSend(message: CollabClientMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) { + try { + this.socket.send(JSON.stringify(message)) + } catch { + this.socket.close() + } + } + } + + private stop(): void { + this.stopped = true + this.unsubscribeIdentity?.() + clearTimeout(this.retry) + clearTimeout(this.presenceTimer) + this.presenceTimer = undefined + clearInterval(this.heartbeat) + this.queuedPresence.clear() + for (const timeout of this.writeTimeouts.values()) clearTimeout(timeout) + this.writeTimeouts.clear() + this.socket?.close() + this.socket = null + this.store.disconnect() + } +} diff --git a/client/features/collab/identity.test.ts b/client/features/collab/identity.test.ts new file mode 100644 index 00000000..b3fc4b0b --- /dev/null +++ b/client/features/collab/identity.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'bun:test' + +import type { CollabIdentity } from '@/lib/collab/types' + +import { + getIdentity, + getIdentitySource, + installIdentityApi, + setDevIdentity, + shareWorkspace +} from './identity' +import type { CollabIdentityApi } from './identity' + +test('runtime identity starts empty until explicit setup and supports the outer identity/share bridge', async () => { + const descriptors = new Map( + ['window', 'location', 'navigator', 'sessionStorage'].map(key => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key) + ]) + ) + const host: { moi?: { collab?: CollabIdentityApi } } = {} + const copied: string[] = [] + const saved = new Map([ + [ + 'moi:collab:dev-identity', + JSON.stringify({ id: 'old-auto', name: 'Automatic', color: '#0f766e' }) + ] + ]) + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: { + getItem: (key: string) => saved.get(key) ?? null, + setItem: (key: string, value: string) => saved.set(key, value) + } + }) + Object.defineProperty(globalThis, 'window', { configurable: true, value: host }) + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://moi.example' } + }) + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + writeText: async (text: string) => { + copied.push(text) + } + } + } + }) + try { + installIdentityApi() + expect(getIdentity()).toBeNull() + expect(getIdentitySource()).toBeNull() + expect(saved.has('moi:collab:dev-profile')).toBe(false) + const api = host.moi?.collab + if (!api) throw new Error('The collab identity bridge was not installed') + const notifications: Array = [] + const unsubscribe = api.subscribeIdentity(identity => notifications.push(identity)) + expect(notifications).toEqual([null]) + const alice = { id: 'alice', name: 'Alice', color: '#0f766e' } + api.setIdentity(alice) + expect(api.getIdentity()).toEqual(alice) + expect(notifications.at(-1)).toEqual(alice) + api.setIdentity(null) + expect(api.getIdentity()).toBeNull() + expect(notifications).toHaveLength(3) + unsubscribe() + api.setIdentity(alice) + expect(notifications).toHaveLength(3) + api.setShareHandler(async context => { + expect(context.workspaceId).toBe('board') + return { url: 'https://cloud.example/share/board' } + }) + expect(await shareWorkspace('board')).toBe('copied') + expect(copied).toEqual(['https://cloud.example/share/board']) + api.setShareHandler(null) + await shareWorkspace('board') + expect(copied.at(-1)).toBe('https://moi.example/workspace/board') + api.setShareHandler(async () => ({ url: 'javascript:alert(1)' })) + await expect(shareWorkspace('board')).rejects.toThrow('invalid URL') + expect(copied).toHaveLength(2) + api.setShareHandler(null) + setDevIdentity(alice) + expect(getIdentitySource()).toBe('dev') + expect(JSON.parse(saved.get('moi:collab:dev-profile') ?? 'null')).toEqual(alice) + api.setIdentity(null) + } finally { + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + } +}) diff --git a/client/features/collab/identity.ts b/client/features/collab/identity.ts new file mode 100644 index 00000000..651f1f4a --- /dev/null +++ b/client/features/collab/identity.ts @@ -0,0 +1,125 @@ +import type { CollabIdentity } from '@/lib/collab/types' + +export type CollabShareContext = { workspaceId: string; url: string } +export type CollabShareHandler = (context: CollabShareContext) => Promise<{ url: string }> +export type CollabIdentityApi = { + getIdentity: () => CollabIdentity | null + setIdentity: (identity: CollabIdentity | null) => void + subscribeIdentity: (listener: (identity: CollabIdentity | null) => void) => () => void + setShareHandler: (handler: CollabShareHandler | null) => void +} + +// Earlier development builds generated dev-identity automatically. Only this +// explicit profile key opts a tab into identity and workspace controls. +const PROFILE_KEY = 'moi:collab:dev-profile' +let identity: CollabIdentity | null = null +let installed = false +let shareHandler: CollabShareHandler | null = null +let identitySource: 'dev' | 'external' | null = null +const listeners = new Set<() => void>() + +function normalizeIdentity(value: CollabIdentity): CollabIdentity { + if (!value || !value.id?.trim() || !value.name?.trim()) { + throw new Error('An identity needs an id and name.') + } + return { + id: value.id.trim(), + name: value.name.trim(), + color: /^#[0-9a-f]{6}$/i.test(value.color) ? value.color : '#0f766e', + ...(value.avatar ? { avatar: value.avatar } : {}) + } +} + +export function getIdentity(): CollabIdentity | null { + return identity +} + +export function setIdentity(next: CollabIdentity | null): void { + identitySource = next === null ? null : 'external' + identity = next === null ? null : normalizeIdentity(next) + listeners.forEach(listener => listener()) +} + +export function subscribeIdentityStore(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function subscribeIdentity(listener: (identity: CollabIdentity | null) => void): () => void { + listener(identity) + return subscribeIdentityStore(() => listener(identity)) +} + +export function getIdentitySource(): 'dev' | 'external' | null { + return identitySource +} + +function persistDevIdentity(): void { + try { + sessionStorage.setItem(PROFILE_KEY, JSON.stringify(identity)) + } catch { + /* Private browsing. */ + } +} + +export function setDevIdentity(next: CollabIdentity): void { + identitySource = 'dev' + identity = normalizeIdentity(next) + persistDevIdentity() + listeners.forEach(listener => listener()) +} + +export async function shareWorkspace(workspaceId: string): Promise<'copied'> { + const url = new URL(`/workspace/${encodeURIComponent(workspaceId)}`, location.origin).href + if (shareHandler) { + const shared = await shareHandler({ workspaceId, url }) + const target = new URL(shared.url) + if (target.protocol !== 'https:' && target.protocol !== 'http:') + throw new Error('The share handler returned an invalid URL.') + await navigator.clipboard.writeText(target.href) + return 'copied' + } + await navigator.clipboard.writeText(url) + return 'copied' +} + +// Installed only when collab is loaded. An outer identity script can supply an +// initial getter before loading; afterward it uses this stable subscription API. +export function installIdentityApi(): void { + if (typeof window === 'undefined' || installed) return + installed = true + const host = window as unknown as { + moi?: { collab?: Partial; [key: string]: unknown } + } + const previous = host.moi?.collab + if (previous?.getIdentity) { + try { + const initial = previous.getIdentity() + identity = initial === null ? null : normalizeIdentity(initial) + identitySource = 'external' + } catch { + /* Use the local profile. */ + } + } else { + try { + const saved = sessionStorage.getItem(PROFILE_KEY) + if (saved) { + identity = normalizeIdentity(JSON.parse(saved) as CollabIdentity) + identitySource = 'dev' + } + } catch { + /* Missing, invalid, or unavailable storage leaves identity unset. */ + } + } + host.moi ??= {} + host.moi.collab = { + getIdentity, + setIdentity, + subscribeIdentity, + setShareHandler(handler) { + shareHandler = handler + } + } +} diff --git a/client/features/collab/store.test.ts b/client/features/collab/store.test.ts new file mode 100644 index 00000000..a93e5a2d --- /dev/null +++ b/client/features/collab/store.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, test } from 'bun:test' + +import type { CollabClientMessage, CollabJsonValue } from '@/lib/collab/types' + +import { CollabStore } from './store' + +const IDENTITY = { id: 'alice', name: 'Alice', color: '#0f766e' } +const SCOPE = 'shared:tasks' + +function fixture(entries: Record = {}) { + const store = new CollabStore() + const sent: CollabClientMessage[] = [] + store.setSender(message => sent.push(message)) + const welcome = () => + store.receive({ + type: 'welcome', + version: 1, + connectionId: crypto.randomUUID(), + identity: IDENTITY, + participants: [] + }) + welcome() + const release = store.acquireScope(SCOPE) + const subscription = () => { + const message = sent.findLast(item => item.type === 'subscribe') + if (!message || message.type !== 'subscribe') throw new Error('No subscription') + return message.subscriptionId + } + const snapshot = (next: Record, revision: number) => + store.receive({ + type: 'snapshot', + scope: SCOPE, + entries: next, + revision, + subscriptionId: subscription() + }) + snapshot(entries, 1) + const mutations = () => sent.filter(message => message.type === 'mutate') + return { store, sent, welcome, snapshot, subscription, mutations, release } +} + +describe('collab client state', () => { + test('an ack alone cannot skip an intervening authoritative field update', async () => { + const f = fixture({ title: 'Original', done: false }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Mine' }]) + const operationId = f.mutations()[0].operationId + f.store.receive({ type: 'ack', scope: SCOPE, operationId, revision: 3, duplicate: false }) + expect(await result).toEqual({ status: 'committed', revision: 3 }) + expect(f.store.getScopeSnapshot(SCOPE).revision).toBe(1) + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Mine') + expect(f.store.getSnapshot().pendingCount).toBe(0) + f.store.receive({ + type: 'update', + scope: SCOPE, + revision: 2, + operationId: 'remote', + subscriptionId: f.subscription(), + operations: [{ type: 'set', key: 'done', value: true }] + }) + expect(f.store.getScopeSnapshot(SCOPE).entries).toEqual({ title: 'Mine', done: true }) + f.store.receive({ + type: 'update', + scope: SCOPE, + revision: 3, + operationId, + subscriptionId: f.subscription(), + operations: [{ type: 'set', key: 'title', value: 'Mine' }] + }) + expect(f.store.getScopeSnapshot(SCOPE).revision).toBe(3) + expect(f.store.getScopeSnapshot(SCOPE).entries).toEqual({ title: 'Mine', done: true }) + }) + + test('a newer snapshot retires an acknowledged value layer', async () => { + const f = fixture({ title: 'Original' }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Mine' }]) + f.store.receive({ + type: 'ack', + scope: SCOPE, + operationId: f.mutations()[0].operationId, + revision: 2, + duplicate: false + }) + await result + f.store.disconnect() + f.welcome() + f.snapshot({ title: 'Newer remote edit' }, 3) + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Newer remote edit') + expect(f.store.getScopeSnapshot(SCOPE).isSaving).toBe(false) + }) + + test('independent remote fields survive a pending local field edit', async () => { + const f = fixture({ '42/title': 'Before', '42/done': false }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: '42/title', value: 'After' }]) + f.store.receive({ + type: 'update', + scope: SCOPE, + revision: 2, + operationId: 'remote', + subscriptionId: f.subscription(), + operations: [{ type: 'set', key: '42/done', value: true }] + }) + expect(f.store.getScopeSnapshot(SCOPE).entries).toEqual({ + '42/title': 'After', + '42/done': true + }) + f.store.receive({ + type: 'ack', + scope: SCOPE, + revision: 3, + operationId: f.mutations()[0].operationId, + duplicate: false + }) + expect(await result).toEqual({ status: 'committed', revision: 3 }) + expect(f.store.getScopeSnapshot(SCOPE).entries).toEqual({ + '42/title': 'After', + '42/done': true + }) + }) + + test('rejecting an earlier optimistic write preserves the later write', async () => { + const f = fixture({ title: 'Original' }) + const first = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'First' }]) + const second = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Second' }]) + f.store.receive({ + type: 'error', + code: 'write_failed', + message: 'Disk full', + operationId: f.mutations()[0].operationId + }) + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Second') + f.store.receive({ + type: 'ack', + scope: SCOPE, + revision: 2, + operationId: f.mutations()[1].operationId, + duplicate: false + }) + expect((await first).status).toBe('rejected') + expect((await second).status).toBe('committed') + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Second') + }) + + test('the own update retires its optimistic layer before a newer remote update', async () => { + const f = fixture({ title: 'Original' }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Mine' }]) + f.store.receive({ + type: 'update', + scope: SCOPE, + revision: 2, + operationId: f.mutations()[0].operationId, + subscriptionId: f.subscription(), + operations: [{ type: 'set', key: 'title', value: 'Mine' }] + }) + f.store.receive({ + type: 'update', + scope: SCOPE, + revision: 3, + operationId: 'remote', + subscriptionId: f.subscription(), + operations: [{ type: 'set', key: 'title', value: 'Theirs' }] + }) + f.store.receive({ + type: 'ack', + scope: SCOPE, + revision: 2, + operationId: f.mutations()[0].operationId, + duplicate: false + }) + await result + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Theirs') + }) + + test('reconnect checks receipts without replaying an uncertain write', async () => { + const f = fixture({ title: 'Original' }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Maybe saved' }]) + const operationId = f.mutations()[0].operationId + f.store.disconnect() + expect(f.store.getScopeSnapshot(SCOPE).loaded).toBe(true) + expect(f.store.getScopeSnapshot(SCOPE).synced).toBe(false) + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Maybe saved') + f.welcome() + f.snapshot({ title: 'Original' }, 1) + expect(f.mutations()).toHaveLength(1) + expect( + f.sent.some( + message => message.type === 'receipts' && message.operationIds.includes(operationId) + ) + ).toBe(true) + f.store.receive({ + type: 'receipts', + requestId: 'lookup', + receipts: [{ operationId, status: 'unknown' }] + }) + expect((await result).status).toBe('unknown') + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Original') + expect(f.store.getScopeSnapshot(SCOPE).error).toContain('could not be confirmed') + }) + + test('a committed receipt never reapplies an older edit over the fresh snapshot', async () => { + const f = fixture({ title: 'Original' }) + const result = f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Mine' }]) + const operationId = f.mutations()[0].operationId + f.store.disconnect() + f.welcome() + f.snapshot({ title: 'Newer remote edit' }, 3) + f.store.receive({ + type: 'receipts', + requestId: 'lookup', + receipts: [{ operationId, status: 'committed', scope: SCOPE, revision: 2 }] + }) + expect((await result).status).toBe('committed') + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Newer remote edit') + }) + + test('old subscription snapshots cannot overwrite a remounted subscription', () => { + const f = fixture({ title: 'Original' }) + const oldSubscription = f.subscription() + f.release() + f.store.acquireScope(SCOPE) + f.snapshot({ title: 'Current' }, 3) + f.store.receive({ + type: 'snapshot', + scope: SCOPE, + revision: 1, + entries: { title: 'Stale' }, + subscriptionId: oldSubscription + }) + expect(f.store.getScopeSnapshot(SCOPE).entries.title).toBe('Current') + }) + + test('reading a missing key never seeds storage and offline writes are rejected', async () => { + const f = fixture() + expect(f.store.getScopeSnapshot(SCOPE).entries.missing).toBeUndefined() + expect(f.mutations()).toHaveLength(0) + f.store.disconnect() + const result = await f.store.mutate(SCOPE, [{ type: 'set', key: 'title', value: 'Offline' }]) + expect(result.status).toBe('rejected') + expect(f.mutations()).toHaveLength(0) + }) + + test('presence cleanup removes only the owning registration', () => { + const f = fixture() + f.store.setPresence({ + registrationId: 'field-a', + surface: 'widget:a', + channel: 'focus', + value: true + }) + f.store.setPresence({ + registrationId: 'field-b', + surface: 'widget:b', + channel: 'focus', + value: true + }) + f.store.deletePresence('field-a') + f.store.disconnect() + f.sent.length = 0 + f.welcome() + const presence = f.sent.filter(message => message.type === 'presence:set') + expect(presence).toHaveLength(1) + expect(presence[0].registrationId).toBe('field-b') + }) + + test('a task removal batch preserves unrelated task fields', async () => { + const f = fixture({ + '42/exists': true, + '42/title': 'Removed', + '43/exists': true, + '43/title': 'Kept' + }) + const result = f.store.mutate(SCOPE, [ + { type: 'delete', key: '42/exists' }, + { type: 'delete', key: '42/title' } + ]) + expect(f.store.getScopeSnapshot(SCOPE).entries).toEqual({ + '43/exists': true, + '43/title': 'Kept' + }) + f.store.receive({ + type: 'ack', + scope: SCOPE, + revision: 2, + operationId: f.mutations()[0].operationId, + duplicate: false + }) + await result + }) +}) diff --git a/client/features/collab/store.ts b/client/features/collab/store.ts new file mode 100644 index 00000000..6a38851f --- /dev/null +++ b/client/features/collab/store.ts @@ -0,0 +1,404 @@ +import type { + CollabClientMessage, + CollabIdentity, + CollabJsonValue, + CollabLocation, + CollabOperation, + CollabParticipant, + CollabPresenceRegistration, + CollabServerMessage +} from '@/lib/collab/types' + +export type MutationOutcome = + | { status: 'committed'; revision: number } + | { status: 'rejected' | 'unknown'; message: string } + +export type CollabConnectionState = { + status: 'connecting' | 'connected' | 'disconnected' + connectionId: string | null + participants: CollabParticipant[] + pendingCount: number + error: string | null +} + +export type SharedScopeState = { + entries: Readonly> + loaded: boolean + synced: boolean + revision: number + isSaving: boolean + error: string | null +} + +type Scope = { + confirmed: Record + snapshot: SharedScopeState + subscriptionId: string + users: number + listeners: Set<() => void> +} + +type PendingMutation = { + scope: string + operations: CollabOperation[] + uncertain: boolean + acknowledgedRevision?: number + resolve: (outcome: MutationOutcome) => void +} + +function applyOperations( + entries: Readonly>, + operations: CollabOperation[] +): Record { + const next = { ...entries } + for (const operation of operations) { + if (operation.type === 'delete') delete next[operation.key] + else + Object.defineProperty(next, operation.key, { + value: operation.value, + enumerable: true, + writable: true, + configurable: true + }) + } + return next +} + +function isJson(value: unknown): value is CollabJsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) + if (Array.isArray(value)) return value.every(isJson) + if (typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false + return Object.values(value).every(isJson) +} + +// Pure connection model: sockets and timers live in client.ts. Keeping server +// data separate from ordered optimistic layers prevents a rejected earlier save +// from rolling back a later edit. +export class CollabStore { + private state: CollabConnectionState = { + status: 'connecting', + connectionId: null, + participants: [], + pendingCount: 0, + error: null + } + private listeners = new Set<() => void>() + private scopes = new Map() + private pending = new Map() + private registrations = new Map() + private location: CollabLocation | null = null + private send: (message: CollabClientMessage) => void = () => {} + + getSnapshot = (): CollabConnectionState => this.state + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + setSender(send: (message: CollabClientMessage) => void): void { + this.send = send + } + + connecting(): void { + this.publish({ status: 'connecting' }) + } + + disconnect(message?: string): void { + for (const pending of this.pending.values()) { + pending.uncertain = pending.acknowledgedRevision === undefined + } + for (const scope of this.scopes.values()) { + scope.snapshot = { ...scope.snapshot, synced: false } + scope.listeners.forEach(listener => listener()) + } + this.publish({ + status: 'disconnected', + connectionId: null, + participants: [], + ...(message ? { error: message } : {}) + }) + } + + // Identity changes cannot inherit another person's pending writes. + resetIdentity(): void { + for (const [id, pending] of [...this.pending]) + this.settle( + id, + pending.acknowledgedRevision !== undefined + ? { status: 'committed', revision: pending.acknowledgedRevision } + : { + status: 'unknown', + message: + 'The account changed before this save was confirmed. Review the current value.' + } + ) + this.disconnect() + } + + setLocation(location: CollabLocation | null): void { + this.location = location + if (this.state.status === 'connected') this.send({ type: 'location', location }) + } + + getLocation(): CollabLocation | null { + return this.location + } + + setPresence(registration: CollabPresenceRegistration): void { + this.registrations.set(registration.registrationId, registration) + if (this.state.status === 'connected') this.send({ type: 'presence:set', ...registration }) + } + + deletePresence(registrationId: string): void { + this.registrations.delete(registrationId) + if (this.state.status === 'connected') this.send({ type: 'presence:delete', registrationId }) + } + + acquireScope(name: string): () => void { + const scope = this.scope(name) + scope.users++ + if (scope.users === 1) scope.subscriptionId = crypto.randomUUID() + if (scope.users === 1 && this.state.status === 'connected') { + this.send({ type: 'subscribe', scope: name, subscriptionId: scope.subscriptionId }) + } + return () => { + scope.users = Math.max(0, scope.users - 1) + if (!scope.users && this.state.status === 'connected') { + this.send({ type: 'unsubscribe', scope: name, subscriptionId: scope.subscriptionId }) + scope.snapshot = { ...scope.snapshot, synced: false } + } + } + } + + getScopeSnapshot(name: string): SharedScopeState { + return this.scope(name).snapshot + } + subscribeScope(name: string, listener: () => void): () => void { + const scope = this.scope(name) + scope.listeners.add(listener) + return () => { + scope.listeners.delete(listener) + } + } + + mutate(name: string, operations: CollabOperation[]): Promise { + const scope = this.scope(name) + const reject = (message: string): Promise => { + scope.snapshot = { ...scope.snapshot, error: message } + this.refreshScope(name) + return Promise.resolve({ status: 'rejected', message }) + } + if (this.state.status !== 'connected' || !scope.snapshot.synced) + return reject('Reconnect before saving this change.') + if (!operations.length) return reject('There are no changes to save.') + try { + if ( + operations.some( + operation => !operation.key || (operation.type === 'set' && !isJson(operation.value)) + ) + ) + return reject('Shared values must contain plain JSON data.') + operations = JSON.parse(JSON.stringify(operations)) as CollabOperation[] + } catch { + return reject('Shared values must contain plain JSON data without cycles.') + } + const operationId = crypto.randomUUID() + return new Promise(resolve => { + this.pending.set(operationId, { scope: name, operations, uncertain: false, resolve }) + scope.snapshot = { ...scope.snapshot, error: null } + this.refreshScope(name) + this.publish({ error: null }) + this.send({ type: 'mutate', scope: name, operationId, operations }) + }) + } + + receive(message: CollabServerMessage): void { + switch (message.type) { + case 'welcome': { + this.publish({ + status: 'connected', + connectionId: message.connectionId, + participants: message.participants, + error: null + }) + for (const [name, scope] of this.scopes) { + if (scope.users) + this.send({ type: 'subscribe', scope: name, subscriptionId: scope.subscriptionId }) + } + for (const registration of this.registrations.values()) + this.send({ type: 'presence:set', ...registration }) + const operationIds = [...this.pending] + .filter(([, pending]) => pending.uncertain) + .map(([id]) => id) + if (operationIds.length) + this.send({ type: 'receipts', requestId: crypto.randomUUID(), operationIds }) + break + } + case 'participants': + this.publish({ participants: message.participants }) + break + case 'snapshot': { + const scope = this.scope(message.scope) + if (message.subscriptionId !== scope.subscriptionId || !scope.users) return + scope.confirmed = message.entries + scope.snapshot = { + ...scope.snapshot, + loaded: true, + synced: true, + revision: message.revision + } + for (const [operationId, pending] of this.pending) { + if ( + pending.scope === message.scope && + pending.acknowledgedRevision !== undefined && + pending.acknowledgedRevision <= message.revision + ) { + this.settle(operationId, { + status: 'committed', + revision: pending.acknowledgedRevision + }) + } + } + this.refreshScope(message.scope) + break + } + case 'update': { + const scope = this.scope(message.scope) + if (message.subscriptionId !== scope.subscriptionId || !scope.users) return + if (message.revision > scope.snapshot.revision) { + scope.confirmed = applyOperations(scope.confirmed, message.operations) + scope.snapshot = { ...scope.snapshot, revision: message.revision } + } + if (this.pending.has(message.operationId)) + this.settle(message.operationId, { status: 'committed', revision: message.revision }) + else this.refreshScope(message.scope) + break + } + case 'ack': { + const pending = this.pending.get(message.operationId) + if (!pending) return + const scope = this.scope(message.scope) + // An acknowledgement proves the write committed, but carries no state + // for intervening writes. Never advance the authoritative revision from + // an ack: doing so would make a later update look already applied. + pending.acknowledgedRevision = message.revision + pending.uncertain = false + pending.resolve({ status: 'committed', revision: message.revision }) + if (scope.snapshot.synced && scope.snapshot.revision >= message.revision) { + this.settle(message.operationId, { status: 'committed', revision: message.revision }) + } else { + this.refreshScope(message.scope) + this.publish({}) + } + break + } + case 'receipts': + for (const receipt of message.receipts) { + this.settle( + receipt.operationId, + receipt.status === 'committed' + ? { status: 'committed', revision: receipt.revision } + : { + status: 'unknown', + message: + 'This change could not be confirmed. Review the current value before trying again.' + } + ) + } + break + case 'error': + if (message.operationId) + this.settle(message.operationId, { status: 'rejected', message: message.message }) + else { + if (message.subscriptionId) { + for (const [name, scope] of this.scopes) { + if (scope.subscriptionId === message.subscriptionId) { + scope.snapshot = { ...scope.snapshot, error: message.message } + this.refreshScope(name) + } + } + } + this.publish({ error: message.message }) + } + break + case 'pong': + break + } + } + + private scope(name: string): Scope { + let scope = this.scopes.get(name) + if (!scope) { + scope = { + confirmed: {}, + subscriptionId: crypto.randomUUID(), + users: 0, + listeners: new Set(), + snapshot: { + entries: {}, + loaded: false, + synced: false, + revision: 0, + isSaving: false, + error: null + } + } + this.scopes.set(name, scope) + } + return scope + } + + private settle(operationId: string, outcome: MutationOutcome): void { + const pending = this.pending.get(operationId) + if (!pending) return + this.pending.delete(operationId) + const scope = this.scope(pending.scope) + if (outcome.status !== 'committed') + scope.snapshot = { ...scope.snapshot, error: outcome.message } + this.refreshScope(pending.scope) + this.publish(outcome.status === 'committed' ? {} : { error: outcome.message }) + pending.resolve(outcome) + } + + private refreshScope(name: string): void { + const scope = this.scope(name) + let entries = scope.confirmed + let isSaving = false + for (const pending of this.pending.values()) { + if (pending.scope !== name) continue + entries = applyOperations(entries, pending.operations) + if (pending.acknowledgedRevision === undefined) isSaving = true + } + scope.snapshot = { ...scope.snapshot, entries, isSaving } + scope.listeners.forEach(listener => listener()) + } + + private publish(patch: Partial): void { + this.state = { + ...this.state, + ...patch, + pendingCount: [...this.pending.values()].filter( + pending => pending.acknowledgedRevision === undefined + ).length + } + this.listeners.forEach(listener => listener()) + } +} + +export function participantForSelf( + state: CollabConnectionState, + identity: CollabIdentity +): CollabParticipant | null { + if (!state.connectionId) return null + return ( + state.participants.find(participant => participant.connectionId === state.connectionId) ?? { + connectionId: state.connectionId, + identity, + location: null, + presence: [] + } + ) +} From 8189305355f43edbbf146cf0f96588b2ae346d32 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 15 Sep 2026 15:20:00 +0200 Subject: [PATCH 04/86] Add collaborative applet primitives and dev identity setup --- bun.lock | 3 + client/features/collab/DevCollabPage.tsx | 113 ++++++ .../collab/WorkspaceCollabControls.tsx | 293 ++++++++++++++++ client/features/collab/components.tsx | 321 ++++++++++++++++++ client/features/collab/dev-persona.ts | 76 +++++ client/features/collab/entry.tsx | 98 ++++++ client/features/collab/facehash-avatar.ts | 132 +++++++ client/features/collab/hooks.tsx | 280 +++++++++++++++ client/features/collab/index.ts | 23 ++ client/features/collab/people.test.ts | 43 +++ client/features/collab/people.ts | 32 ++ client/features/dev/DevIndexPage.tsx | 5 + client/features/dev/DevRoutes.tsx | 10 + package.json | 1 + server/applets/build-applet.ts | 9 + server/collab/applet-module.ts | 21 ++ server/collab/skill/index.test.ts | 96 ++++++ 17 files changed, 1556 insertions(+) create mode 100644 client/features/collab/DevCollabPage.tsx create mode 100644 client/features/collab/WorkspaceCollabControls.tsx create mode 100644 client/features/collab/components.tsx create mode 100644 client/features/collab/dev-persona.ts create mode 100644 client/features/collab/entry.tsx create mode 100644 client/features/collab/facehash-avatar.ts create mode 100644 client/features/collab/hooks.tsx create mode 100644 client/features/collab/index.ts create mode 100644 client/features/collab/people.test.ts create mode 100644 client/features/collab/people.ts create mode 100644 server/collab/applet-module.ts create mode 100644 server/collab/skill/index.test.ts diff --git a/bun.lock b/bun.lock index 29f5ab71..39a14e39 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "culori": "^4.0.2", "devalue": "^5.9.2", "env-paths": "^4.0.0", + "facehash": "^0.1.0", "frimousse": "^0.3.0", "highlight.js": "^10.7.3", "hono": "^4.13.7", @@ -999,6 +1000,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "facehash": ["facehash@0.1.0", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-tv/QVZjLvEXHssqBaJECq+kRLFwwhd017PKk8ucT7aLingL2OZ5zEqKwPMHmT9+YQO92MVFWGZQP6vxV+P5vrQ=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@4.0.3", "", {}, "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="], diff --git a/client/features/collab/DevCollabPage.tsx b/client/features/collab/DevCollabPage.tsx new file mode 100644 index 00000000..e2f226c3 --- /dev/null +++ b/client/features/collab/DevCollabPage.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react' + +import { IconArrowsShuffle } from '@tabler/icons-react' +import { Link } from 'wouter' + +import { Button } from '@/client/components/ui/button' +import { Input } from '@/client/components/ui/input' + +import { PERSONA_COLORS, createDevIdentity, personaIdentity, randomPersona } from './dev-persona' +import type { Persona } from './dev-persona' +import { getIdentity, installIdentityApi, setDevIdentity } from './identity' + +installIdentityApi() + +function formatKb(bytes: number): string { + return `${(bytes / 1024).toFixed(1)} KB` +} + +export function DevCollabPage() { + // Visiting this setup page opts the tab into identity. Later edits keep the + // same id, so peers keep seeing one participant. + const [current] = useState(() => getIdentity() ?? createDevIdentity()) + const [persona, setPersona] = useState({ name: current.name, color: current.color }) + const name = persona.name.trim() + const identity = useMemo( + () => (name ? personaIdentity(current.id, { name, color: persona.color }) : null), + [current.id, name, persona.color] + ) + + useEffect(() => { + if (identity) setDevIdentity(identity) + }, [identity]) + + const avatar = identity?.avatar + return ( +
+ + ← Dev pages + +
+

Collab identity

+

+ Set up who this browser tab is. To test with two people, open this page in a second tab + and set up another identity. +

+
+
+ {avatar ? ( + + ) : ( +
+ )} +
+ +
+ Color +
+ {PERSONA_COLORS.map(([label, hex]) => ( + + ))} +
+
+
+ +
+
+
+

+ Kept in this tab’s session storage as {current.id} + {avatar && ` with a ${formatKb(avatar.length)} PNG avatar`}. Start moi with{' '} + --experimental-collab to connect. +

+
+ ) +} diff --git a/client/features/collab/WorkspaceCollabControls.tsx b/client/features/collab/WorkspaceCollabControls.tsx new file mode 100644 index 00000000..cc1d7828 --- /dev/null +++ b/client/features/collab/WorkspaceCollabControls.tsx @@ -0,0 +1,293 @@ +import { useState, useSyncExternalStore } from 'react' + +import { + IconArrowRight, + IconCheck, + IconChevronDown, + IconShare, + IconUserEdit +} from '@tabler/icons-react' +import type { Icon as TabIcon } from '@tabler/icons-react' +import { Link, useLocation } from 'wouter' + +import { Button, buttonVariants } from '@/client/components/ui/button' +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger +} from '@/client/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' +import { cn } from '@/client/lib/cn' +import type { WorkspaceTabId } from '@/lib/types' +import { parseWorkspaceTab } from '@/lib/workspace-tabs' +import { Avatar, AvatarFallback } from '@/ui-components/avatar' + +import { ParticipantAvatar } from './components' +import type { CollabTabInfo } from './entry' +import { pageFromPath, useConnection } from './hooks' +import { getIdentity, getIdentitySource, shareWorkspace, subscribeIdentityStore } from './identity' +import { groupPeople } from './people' +import type { Person } from './people' + +type DescribeTab = (tab: WorkspaceTabId) => CollabTabInfo | null + +export type WorkspaceCollabControlsProps = { + workspaceId: string + describeTab: DescribeTab + onOpenTab: (tab: WorkspaceTabId) => void +} + +// Faces shown in the header before the rest collapse into a count. +const MAX_FACES = 3 + +export function WorkspaceCollabControls({ + workspaceId, + describeTab, + onOpenTab +}: WorkspaceCollabControlsProps) { + const state = useConnection() + const identity = useSyncExternalStore(subscribeIdentityStore, getIdentity, getIdentity) + const [path] = useLocation() + const page = pageFromPath(path) + const people = groupPeople(state.participants, { + identity, + connectionId: state.connectionId, + page + }) + const self = people.find(person => person.self) + const others = people.filter(person => !person.self) + const hidden = Math.max(0, others.length - MAX_FACES) + const [open, setOpen] = useState(false) + const jump = (tab: WorkspaceTabId) => { + setOpen(false) + onOpenTab(tab) + } + const canEdit = getIdentitySource() === 'dev' + if (!identity) return null + return ( +
+ + + {others.slice(0, MAX_FACES).map(person => ( + + ))} + {hidden > 0 && ( + + +{hidden} + + )} + + } + > + {self && ( + + )} + + + + + People in this workspace + {self && ( +
+ + + {self.identity.name}{' '} + (you) + +
+ )} + {canEdit && ( + + + Change name or avatar + + )} +
+ {others.length > 0 ? ( +
    + {others.map(person => ( + + ))} +
+ ) : ( +

+ No one else is here yet. Share the link to bring people in. +

+ )} + + + +
+ ) +} + +type Place = { where: string; Icon?: TabIcon; target: WorkspaceTabId | null; away: boolean } + +function placeOf(person: Person, page: string, describeTab: DescribeTab): Place { + const tabs = person.pages.map(candidate => { + const tab = parseWorkspaceTab(candidate) + return { tab, info: tab ? describeTab(tab) : null } + }) + const away = tabs.length === 0 + return { + away, + where: away + ? 'Away' + : tabs.map(({ tab, info }) => info?.label ?? tab ?? 'Another tab').join(', '), + Icon: tabs[0]?.info?.Icon, + // The first tab of theirs that you are not already on. + target: tabs.find(({ tab, info }) => tab !== null && info !== null && tab !== page)?.tab ?? null + } +} + +// A face in the header stack: hover names the person and where they are, +// and a click opens the tab they are on. +type FaceProps = { person: Person; place: Place; onJump: (tab: WorkspaceTabId) => void } +function Face({ person, place, onJump }: FaceProps) { + const target = place.target + const hint = place.away + ? 'Away' + : target + ? `Click to go to ${place.where}` + : `Also on ${place.where}` + return ( + + onJump(target)} + /> + ) : ( + + ) + } + > + + + + + {person.identity.name} + {hint} + + + + ) +} + +type PersonRowProps = { person: Person; place: Place; onJump: (tab: WorkspaceTabId) => void } +function PersonRow({ person, place, onJump }: PersonRowProps) { + const { Icon, where, away, target } = place + const content = ( + <> + + + {person.identity.name} + + {Icon && } + {where} + + + + {target && } + + + ) + const row = 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left' + return ( +
  • + {target ? ( + + ) : ( +
    {content}
    + )} +
  • + ) +} + +type ShareButtonProps = { workspaceId: string } +function ShareButton({ workspaceId }: ShareButtonProps) { + const [sharing, setSharing] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const share = async () => { + setSharing(true) + setError(null) + try { + const outcome = await shareWorkspace(workspaceId) + setResult(outcome === 'copied' ? 'Link copied' : 'Shared') + } catch { + setError('Couldn’t share this workspace. Try again or copy its address.') + } finally { + setSharing(false) + } + } + return ( + + { + void share() + }} + disabled={sharing} + /> + } + > + {result ? : } + Share + + + + {sharing + ? 'Sharing workspace…' + : error + ? 'Couldn’t share workspace' + : (result ?? 'Share workspace')} + +

    + {error ?? 'Share the workspace link with your collaborators.'} +

    +
    +
    + ) +} diff --git a/client/features/collab/components.tsx b/client/features/collab/components.tsx new file mode 100644 index 00000000..26693f95 --- /dev/null +++ b/client/features/collab/components.tsx @@ -0,0 +1,321 @@ +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import type { FocusEvent, PointerEvent, ReactNode, RefObject } from 'react' + +import { + IconCheck, + IconCloudOff, + IconCursorText, + IconPointer, + IconRefresh +} from '@tabler/icons-react' + +import { cn } from '@/client/lib/cn' +import { + Avatar, + AvatarBadge, + AvatarFallback, + AvatarGroup, + AvatarImage +} from '@/ui-components/avatar' +import type { CollabIdentity, CollabJsonValue, CollabParticipant } from '@/lib/collab/types' + +import { facehashDataUrl } from './facehash-avatar' +import { useConnection, useOthers, usePresenceChannel, useSelf } from './hooks' + +export function uniqueParticipants(participants: CollabParticipant[]): CollabParticipant[] { + return [ + ...new Map(participants.map(participant => [participant.identity.id, participant])).values() + ] +} + +export type ParticipantAvatarProps = { + identity: CollabIdentity + size?: 'sm' | 'default' | 'lg' + // Marks a person whose browser tab is visible right now. + active?: boolean + label?: string + className?: string +} +export function ParticipantAvatar({ + identity, + size = 'sm', + active = false, + label = identity.name, + className +}: ParticipantAvatarProps) { + // An identity without a picture gets the same generated face on every + // client, so nobody shows up as bare initials. + const face = useMemo( + () => identity.avatar ?? facehashDataUrl(identity.name, identity.color), + [identity.avatar, identity.name, identity.color] + ) + return ( + + {face && } + {identity.name.trim().slice(0, 2).toUpperCase()} + {active && } + + ) +} + +export type ActivityProps = { scope?: 'page' | 'workspace'; className?: string } +export function Activity({ scope = 'page', className }: ActivityProps) { + const others = useOthers({ scope }) + const self = useSelf() + const participants = uniqueParticipants(self ? [self, ...others] : others) + return ( + + {participants.map(participant => ( + + ))} + + ) +} + +export type SyncStatusProps = { className?: string } +export function SyncStatus({ className }: SyncStatusProps) { + const state = useConnection() + const message = + state.error ?? + (state.status === 'connecting' + ? 'Connecting…' + : state.status === 'disconnected' + ? 'Disconnected' + : state.pendingCount + ? 'Saving…' + : 'Saved') + const Icon = + state.error || state.status === 'disconnected' + ? IconCloudOff + : state.status === 'connecting' || state.pendingCount + ? IconRefresh + : IconCheck + return ( + + + {message} + + ) +} + +type PointerPosition = { x: number; y: number; target?: string; targetX?: number; targetY?: number } +function pointerPosition(value: CollabJsonValue): PointerPosition | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + if (typeof value.x !== 'number' || typeof value.y !== 'number') return null + return { + x: value.x, + y: value.y, + ...(typeof value.target === 'string' ? { target: value.target } : {}), + ...(typeof value.targetX === 'number' ? { targetX: value.targetX } : {}), + ...(typeof value.targetY === 'number' ? { targetY: value.targetY } : {}) + } +} + +export type CursorsProps = { surface?: string; children: ReactNode; className?: string } +export function Cursors({ surface = 'default', children, className }: CursorsProps) { + const root = useRef(null) + const cursor = usePresenceChannel(`cursor:${surface}`, null) + const move = (event: PointerEvent) => { + if (event.pointerType === 'touch') return + const element = root.current + if (!element) return + const bounds = element.getBoundingClientRect() + const target = + event.target instanceof Element + ? event.target.closest('[data-collab-target]') + : null + const anchor = target && element.contains(target) ? target : null + const rect = anchor?.getBoundingClientRect() + cursor.setValue({ + x: event.clientX - bounds.left + element.scrollLeft, + y: event.clientY - bounds.top + element.scrollTop, + ...(anchor && rect + ? { + target: anchor.dataset.collabTarget ?? '', + targetX: rect.width ? (event.clientX - rect.left) / rect.width : 0, + targetY: rect.height ? (event.clientY - rect.top) / rect.height : 0 + } + : {}) + }) + } + return ( +
    cursor.setValue(null)} + > + {children} + +
    + ) +} + +type RemoteCursorProps = { + root: RefObject + point: PointerPosition + identity: CollabIdentity +} +function RemoteCursor({ root, point, identity }: RemoteCursorProps) { + const marker = useRef(null) + useLayoutEffect(() => { + const element = root.current + const node = marker.current + if (!element || !node) return + // Dynamic identity colors and pointer geometry are data, applied to this + // overlay's DOM node instead of adding arbitrary applet stylesheet rules. + node.style.setProperty('--collab-color', identity.color) + const position = () => { + let x = point.x - element.scrollLeft + let y = point.y - element.scrollTop + if (point.target) { + const target = [...element.querySelectorAll('[data-collab-target]')].find( + candidate => candidate.dataset.collabTarget === point.target + ) + if (!target) { + node.hidden = true + return + } + const bounds = element.getBoundingClientRect() + const rect = target.getBoundingClientRect() + x = rect.left - bounds.left + rect.width * (point.targetX ?? 0) + y = rect.top - bounds.top + rect.height * (point.targetY ?? 0) + } + node.hidden = false + node.style.transform = `translate(${x}px, ${y}px)` + } + position() + const observer = new ResizeObserver(position) + observer.observe(element) + element.addEventListener('scroll', position, true) + window.addEventListener('resize', position) + return () => { + observer.disconnect() + element.removeEventListener('scroll', position, true) + window.removeEventListener('resize', position) + } + }, [root, point, identity.color]) + return ( +
    + + + {identity.name} + +
    + ) +} + +type PresenceOutlineProps = { + people: CollabParticipant[] + children: ReactNode + target: string + className?: string + onFocusCapture?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void +} +function PresenceOutline({ + people, + children, + target, + className, + onFocusCapture, + onBlurCapture +}: PresenceOutlineProps) { + const root = useRef(null) + const color = people[0]?.identity.color + useLayoutEffect(() => { + if (root.current && color) root.current.style.setProperty('--collab-color', color) + }, [color]) + return ( +
    + {children} + {people.length > 0 && ( +
    + + + + {people.map(person => person.identity.name).join(', ')} + + +
    + )} +
    + ) +} + +export type PresenceFieldProps = { target: string; children: ReactNode; className?: string } +export function PresenceField({ target, children, className }: PresenceFieldProps) { + const presence = usePresenceChannel(`field:${target}`, false) + const people = useMemo( + () => + uniqueParticipants( + presence.others.filter(other => other.value === true).map(other => other.participant) + ), + [presence.others] + ) + return ( + presence.setValue(true)} + onBlurCapture={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) + presence.setValue(false) + }} + > + {children} + + ) +} + +export type SelectionProps = { + target: string + selected: boolean + children: ReactNode + className?: string +} +export function Selection({ target, selected, children, className }: SelectionProps) { + const { setValue, others } = usePresenceChannel(`selection:${target}`, selected) + useEffect(() => setValue(selected), [selected, setValue]) + const people = useMemo( + () => + uniqueParticipants( + others.filter(other => other.value === true).map(other => other.participant) + ), + [others] + ) + return ( + + {children} + + ) +} diff --git a/client/features/collab/dev-persona.ts b/client/features/collab/dev-persona.ts new file mode 100644 index 00000000..ed597580 --- /dev/null +++ b/client/features/collab/dev-persona.ts @@ -0,0 +1,76 @@ +import type { CollabIdentity } from '@/lib/collab/types' + +import { facehashDataUrl } from './facehash-avatar' + +// Test people are created only by explicit setup on /dev/collab. + +// Backgrounds that keep the black face readable and still work as a cursor +// color on light and dark surfaces. +export const PERSONA_COLORS = [ + ['Pink', '#ec4899'], + ['Orange', '#f97316'], + ['Amber', '#f59e0b'], + ['Lime', '#84cc16'], + ['Emerald', '#10b981'], + ['Cyan', '#06b6d4'], + ['Blue', '#3b82f6'], + ['Violet', '#8b5cf6'] +] as const + +export const PERSONA_NAMES = [ + 'Ada', + 'Alan', + 'Grace', + 'Linus', + 'Margaret', + 'Dennis', + 'Barbara', + 'Ken', + 'Radia', + 'Hedy', + 'Edsger', + 'Frances', + 'Donald', + 'Katherine', + 'Niklaus', + 'Sophie', + 'Adele', + 'Jean', + 'Bjarne', + 'Guido', + 'Yukihiro', + 'Joe', + 'Vint', + 'Tim', + 'Mary', + 'Kathleen', + 'Brian', + 'Leslie' +] + +export type Persona = { name: string; color: string } + +function pick(items: readonly T[], except?: T): T { + const pool = items.filter(item => item !== except) + return pool[Math.floor(Math.random() * pool.length)] ?? items[0] +} + +export function randomPersona(current?: Persona): Persona { + return { + name: pick(PERSONA_NAMES, current?.name), + color: pick( + PERSONA_COLORS.map(([, hex]) => hex), + current?.color + ) + } +} + +export function personaIdentity(id: string, persona: Persona): CollabIdentity { + const avatar = facehashDataUrl(persona.name, persona.color) + return { id, name: persona.name, color: persona.color, ...(avatar ? { avatar } : {}) } +} + +// The id stays with the tab through renames, so peers keep seeing one person. +export function createDevIdentity(): CollabIdentity { + return personaIdentity(`dev-${crypto.randomUUID().slice(0, 8)}`, randomPersona()) +} diff --git a/client/features/collab/entry.tsx b/client/features/collab/entry.tsx new file mode 100644 index 00000000..78f42142 --- /dev/null +++ b/client/features/collab/entry.tsx @@ -0,0 +1,98 @@ +// The ordinary app imports only this small boundary. Collaboration code and its +// transport load only when the server runtime is enabled. +import { Component, Suspense, createContext, lazy, useContext, useSyncExternalStore } from 'react' +import type { ReactNode } from 'react' +import type { Icon as TabIcon } from '@tabler/icons-react' +import type { AppletKind, WorkspaceTabId } from '@/lib/types' +import type * as CollabModuleNamespace from './index' + +type CollabModule = typeof CollabModuleNamespace +let loaded: CollabModule | undefined +const EnabledContext = createContext(false) +const LazyWorkspace = lazy(async () => { + loaded = await import('./index') + return { default: loaded.CollabWorkspaceProvider } +}) + +export function getAppletCollabApi(): + | ReturnType + | undefined { + return loaded?.createAppletCollabApi() +} + +type CollabErrorBoundaryProps = { children: ReactNode } +type CollabErrorBoundaryState = { error: boolean } +class CollabErrorBoundary extends Component { + state: CollabErrorBoundaryState = { error: false } + static getDerivedStateFromError(): CollabErrorBoundaryState { + return { error: true } + } + render() { + return this.state.error ? ( +

    + Collaboration could not load. Refresh this page to try again. +

    + ) : ( + this.props.children + ) + } +} + +export type CollabGateProps = { + workspaceId: string + enabled: boolean + children: ReactNode +} +export function CollabGate({ workspaceId, enabled, children }: CollabGateProps) { + if (!enabled) return {children} + return ( + + + Loading collaboration… +

    + } + > + + {children} + +
    +
    + ) +} + +const subscribeIdentity = (listener: () => void) => + loaded?.subscribeIdentityStore(listener) ?? (() => {}) +const hasIdentity = () => loaded?.getIdentity() != null + +// Identity controls personal navigation and host UI independently of storage. +export function useCollabIdentityEnabled(): boolean { + const enabled = useContext(EnabledContext) + const identity = useSyncExternalStore(subscribeIdentity, hasIdentity, hasIdentity) + return enabled && identity +} + +export type AppletCollabMountProps = { + workspaceId: string + applet: { kind: AppletKind; name: string } + active?: boolean + children: ReactNode +} +export function AppletCollabMount(props: AppletCollabMountProps) { + const enabled = useContext(EnabledContext) + if (!loaded || !enabled) return props.children + return +} + +export type CollabTabInfo = { label: string; Icon: TabIcon } +export type CollabControlsProps = { + workspaceId: string + // Resolves a participant's tab to the label and icon the tab strip uses. + describeTab: (tab: WorkspaceTabId) => CollabTabInfo | null + onOpenTab: (tab: WorkspaceTabId) => void +} +export function CollabControls(props: CollabControlsProps) { + const enabled = useCollabIdentityEnabled() + return loaded && enabled ? : null +} diff --git a/client/features/collab/facehash-avatar.ts b/client/features/collab/facehash-avatar.ts new file mode 100644 index 00000000..7dd10b78 --- /dev/null +++ b/client/features/collab/facehash-avatar.ts @@ -0,0 +1,132 @@ +import { stringHash } from 'facehash' + +import { COLLAB_MAX_AVATAR_BYTES } from '@/lib/collab/protocol' + +// Rasterizes a facehash face (https://facehash.dev) into a small PNG data URL, +// so a test avatar travels inside the identity instead of pointing at a URL. +// The library paints its faces with React and CSS, which a canvas cannot +// consume, so the eye shapes and their selection are mirrored here: a name gets +// the same face it gets from the library. This is the library's solid variant; +// its gradient sheen alone costs more than the protocol's avatar budget. + +type Face = { width: number; height: number; draw: (ctx: CanvasRenderingContext2D) => void } + +function circle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void { + ctx.beginPath() + ctx.arc(cx, cy, r, 0, Math.PI * 2) + ctx.fill() +} + +function pill(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void { + ctx.beginPath() + ctx.roundRect(x, y, w, h, 3.5) + ctx.fill() +} + +const CURVED_EYE = + 'M0 5.1c0-.1 0-.2 0-.3.1-.5.3-1 .7-1.3.1 0 .1-.1.2-.1C2.4 2.2 6 0 10.5 0S18.6 2.2 20.2 3.3c.1 0 .1.1.1.1.4.3.7.9.7 1.3v.3c0 1 0 1.4 0 1.7-.2 1.3-1.2 1.9-2.5 1.6-.2 0-.7-.3-1.8-.8C15 6.7 12.8 6 10.5 6s-4.5.7-6.3 1.5c-1 .5-1.5.7-1.8.8-1.3.3-2.3-.3-2.5-1.6v-1.7z' + +// Same order as the library: round, cross, line, curved. +const FACES: Face[] = [ + { + width: 63, + height: 15, + draw: ctx => { + circle(ctx, 7.2, 7.2, 7.2) + circle(ctx, 55.2, 7.2, 7.2) + } + }, + { + width: 71, + height: 23, + draw: ctx => { + pill(ctx, 8, 0, 7, 23) + pill(ctx, 0, 8, 23, 7) + pill(ctx, 55.2, 0, 7, 23) + pill(ctx, 47.3, 8, 23, 7) + } + }, + { + width: 82, + height: 8, + draw: ctx => { + pill(ctx, 0.07, 0.16, 6.9, 6.9) + pill(ctx, 7.9, 0.16, 20.7, 6.9) + pill(ctx, 74.7, 0.16, 6.9, 6.9) + pill(ctx, 53.1, 0.16, 20.7, 6.9) + } + }, + { + width: 63, + height: 9, + draw: ctx => { + const eye = new Path2D(CURVED_EYE) + ctx.fill(eye) + ctx.translate(42, 0) + ctx.fill(eye) + } + } +] + +// The library tilts the face in 3D; its image renderer flattens that into a +// small nudge, which is what a raster copy can reproduce. +const TILTS = [ + [-1, 1], + [1, 1], + [1, 0], + [0, 1], + [-1, 0], + [0, 0], + [0, -1], + [-1, -1], + [1, -1] +] as const + +// Crisp at the largest host avatar on a 2x display, and a few kilobytes as PNG. +const SIZE = 96 + +function drawFace(ctx: CanvasRenderingContext2D, name: string, color: string, size: number): void { + const hash = stringHash(name) + const face = FACES[hash % FACES.length] ?? FACES[0] + const [tiltX, tiltY] = TILTS[hash % TILTS.length] ?? [0, 0] + const dx = tiltY * size * 0.05 + const dy = -tiltX * size * 0.05 + + ctx.fillStyle = color + ctx.fillRect(0, 0, size, size) + + const eyesWidth = size * 0.6 + const eyesHeight = (eyesWidth * face.height) / face.width + const gap = size * 0.08 + const fontSize = size * 0.26 + const top = (size - (eyesHeight + gap + fontSize)) / 2 + + ctx.save() + ctx.translate((size - eyesWidth) / 2 + dx, top + dy) + ctx.scale(eyesWidth / face.width, eyesWidth / face.width) + ctx.fillStyle = '#000' + face.draw(ctx) + ctx.restore() + + ctx.fillStyle = '#000' + ctx.font = `700 ${fontSize}px ui-monospace, Menlo, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText( + name.charAt(0).toUpperCase(), + size / 2 + dx, + top + eyesHeight + gap + fontSize / 2 + dy + ) +} + +export function facehashDataUrl(name: string, color: string): string | undefined { + if (typeof document === 'undefined') return undefined + const canvas = document.createElement('canvas') + canvas.width = SIZE + canvas.height = SIZE + const ctx = canvas.getContext('2d') + if (!ctx) return undefined + drawFace(ctx, name, color, SIZE) + const url = canvas.toDataURL('image/png') + return url.length <= COLLAB_MAX_AVATAR_BYTES ? url : undefined +} diff --git a/client/features/collab/hooks.tsx b/client/features/collab/hooks.tsx new file mode 100644 index 00000000..467b6d7f --- /dev/null +++ b/client/features/collab/hooks.tsx @@ -0,0 +1,280 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from 'react' +import type { ReactNode } from 'react' +import { useLocation } from 'wouter' + +import type { AppletKind } from '@/lib/types' +import type { CollabJsonValue, CollabOperation, CollabParticipant } from '@/lib/collab/types' + +import { CollabClient } from './client' +import { getIdentity, installIdentityApi, subscribeIdentityStore } from './identity' +import { participantForSelf } from './store' +import type { CollabStore } from './store' +import type { MutationOutcome } from './store' + +type AppletIdentity = { kind: AppletKind; name: string } +type Mount = { applet: AppletIdentity; active: boolean; surface: string } +const WorkspaceContext = createContext(null) +const MountContext = createContext(null) + +installIdentityApi() + +// The route segment after `/workspace/:id/`, which is the workspace tab id. +export function pageFromPath(path: string): string { + return path.split('/').slice(3).join('/') || 'overview' +} + +export type CollabWorkspaceProviderProps = { + workspaceId: string + children: ReactNode +} +export function CollabWorkspaceProvider({ workspaceId, children }: CollabWorkspaceProviderProps) { + const [client] = useState(() => new CollabClient(workspaceId)) + const [path] = useLocation() + useEffect(() => { + window.dispatchEvent(new CustomEvent('moi:collab-ready')) + return client.start() + }, [client]) + useEffect(() => { + client.store.setLocation({ page: pageFromPath(path) }) + }, [client, path]) + useEffect(() => { + const update = () => { + if (document.visibilityState === 'hidden') client.store.setLocation(null) + else client.store.setLocation({ page: pageFromPath(location.pathname) }) + } + document.addEventListener('visibilitychange', update) + return () => document.removeEventListener('visibilitychange', update) + }, [client]) + return {children} +} + +export type AppletCollabProviderProps = { + workspaceId: string + applet: AppletIdentity + active?: boolean + children: ReactNode +} +export function AppletCollabProvider({ + applet, + active = true, + children +}: AppletCollabProviderProps) { + const { kind, name } = applet + const mount = useMemo( + () => ({ applet: { kind, name }, active, surface: `${kind}:${name}` }), + [kind, name, active] + ) + return {children} +} + +function useCollabClient(): CollabClient { + const client = useContext(WorkspaceContext) + if (!client) throw new Error('Start moi with --experimental-collab to use collaboration.') + return client +} + +export function useCollabStore(): CollabStore { + return useCollabClient().store +} + +export function useConnection() { + const store = useCollabStore() + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) +} + +export function useSelf(): CollabParticipant | null { + const state = useConnection() + const identity = useSyncExternalStore(subscribeIdentityStore, getIdentity, getIdentity) + return useMemo(() => (identity ? participantForSelf(state, identity) : null), [state, identity]) +} + +export type OthersOptions = { scope?: 'page' | 'workspace' } +export function useOthers({ scope = 'page' }: OthersOptions = {}): CollabParticipant[] { + const state = useConnection() + const store = useCollabStore() + return useMemo( + () => + state.participants.filter( + participant => + participant.connectionId !== state.connectionId && + (scope === 'workspace' || + (store.getLocation() !== null && + participant.location?.page === store.getLocation()?.page)) + ), + [state, store, scope] + ) +} + +export function useMount(): Mount { + const mount = useContext(MountContext) + if (!mount) throw new Error('This collaboration hook must be used inside an applet.') + return mount +} + +export type PresenceValue = { participant: CollabParticipant; value: T } +export function usePresence(channel: string, initialValue: T) { + return usePresenceChannel(`custom:${channel}`, initialValue) +} + +export function usePresenceChannel(channel: string, initialValue: T) { + const store = useCollabStore() + const mount = useMount() + const others = useOthers() + const [value, setValue] = useState(initialValue) + const [registrationId] = useState(() => crypto.randomUUID()) + const live = useRef(false) + const valueRef = useRef(value) + + useLayoutEffect(() => { + if (!mount.active) return + live.current = true + const publish = () => { + if (document.visibilityState === 'hidden') store.deletePresence(registrationId) + else + store.setPresence({ + registrationId, + surface: mount.surface, + channel, + value: valueRef.current + }) + } + publish() + document.addEventListener('visibilitychange', publish) + return () => { + live.current = false + document.removeEventListener('visibilitychange', publish) + store.deletePresence(registrationId) + } + }, [store, mount.active, mount.surface, channel, registrationId]) + + const update = useCallback( + (next: T) => { + if (!live.current) return + valueRef.current = next + setValue(next) + if (mount.active && document.visibilityState !== 'hidden') { + store.setPresence({ registrationId, surface: mount.surface, channel, value: next }) + } + }, + [store, mount.active, mount.surface, channel, registrationId] + ) + + const otherValues = useMemo( + () => + others.flatMap(participant => + participant.presence + .filter( + registration => + registration.surface === mount.surface && registration.channel === channel + ) + .map(registration => ({ participant, value: registration.value as T })) + ), + [others, mount.surface, channel] + ) + return { value, setValue: update, others: otherValues } +} + +export type SharedOptions = { scope?: string } +export type SharedStateOptions = SharedOptions & { defaultValue?: T } + +function useScope(explicitScope?: string) { + const client = useCollabClient() + const store = client.store + const mount = useMount() + const scope = explicitScope ?? `applet:${mount.surface}` + const subscribe = useCallback( + (listener: () => void) => store.subscribeScope(scope, listener), + [store, scope] + ) + const getSnapshot = useCallback(() => store.getScopeSnapshot(scope), [store, scope]) + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const connection = useConnection() + const live = useRef(false) + useLayoutEffect(() => { + if (!mount.active) return + live.current = true + const release = store.acquireScope(scope) + const releaseConnection = client.acquireSharedState() + return () => { + live.current = false + release() + releaseConnection() + } + }, [client, store, scope, mount.active]) + const mutate = useCallback( + (operations: CollabOperation[]): Promise => { + if (!live.current) + return Promise.resolve({ status: 'rejected', message: 'This applet is no longer active.' }) + return store.mutate(scope, operations) + }, + [store, scope] + ) + return { + mutate, + state, + canWrite: mount.active && state.synced && connection.status === 'connected' + } +} + +export function useSharedState( + key: string, + options: SharedStateOptions = {} +) { + const { mutate, state, canWrite } = useScope(options.scope) + const setValue = useCallback((value: T) => mutate([{ type: 'set', key, value }]), [mutate, key]) + const deleteValue = useCallback(() => mutate([{ type: 'delete', key }]), [mutate, key]) + const exists = Object.hasOwn(state.entries, key) + return { + value: state.loaded ? (exists ? (state.entries[key] as T) : options.defaultValue) : undefined, + exists: state.loaded && exists, + loaded: state.loaded, + canWrite, + isSaving: state.isSaving, + error: state.error, + setValue, + deleteValue + } +} + +export function useSharedStore(prefix = '', options: SharedOptions = {}) { + const { mutate, state, canWrite } = useScope(options.scope) + const entries = useMemo( + () => + Object.fromEntries( + Object.entries(state.entries) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => [key.slice(prefix.length), value]) + ), + [state.entries, prefix] + ) + const batch = useCallback( + (operations: CollabOperation[]) => + mutate(operations.map(operation => ({ ...operation, key: prefix + operation.key }))), + [mutate, prefix] + ) + const set = useCallback( + (key: string, value: CollabJsonValue) => batch([{ type: 'set', key, value }]), + [batch] + ) + const remove = useCallback((key: string) => batch([{ type: 'delete', key }]), [batch]) + return { + entries, + loaded: state.loaded, + canWrite, + isSaving: state.isSaving, + error: state.error, + set, + delete: remove, + batch + } +} diff --git a/client/features/collab/index.ts b/client/features/collab/index.ts new file mode 100644 index 00000000..1600544e --- /dev/null +++ b/client/features/collab/index.ts @@ -0,0 +1,23 @@ +import { Activity, Cursors, PresenceField, Selection, SyncStatus } from './components' +import { useOthers, usePresence, useSelf, useSharedState, useSharedStore } from './hooks' + +export { CollabWorkspaceProvider, AppletCollabProvider } from './hooks' +export { WorkspaceCollabControls } from './WorkspaceCollabControls' +export { getIdentity, subscribeIdentityStore } from './identity' + +const appletApi = { + Activity, + Cursors, + PresenceField, + Selection, + SyncStatus, + useOthers, + usePresence, + useSelf, + useSharedState, + useSharedStore +} + +export function createAppletCollabApi() { + return appletApi +} diff --git a/client/features/collab/people.test.ts b/client/features/collab/people.test.ts new file mode 100644 index 00000000..c8957147 --- /dev/null +++ b/client/features/collab/people.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'bun:test' + +import type { CollabParticipant } from '@/lib/collab/types' + +import { groupPeople } from './people' + +const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' } +const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' } + +function connection( + connectionId: string, + identity: typeof ada, + page: string | null +): CollabParticipant { + return { connectionId, identity, location: page ? { page } : null, presence: [] } +} + +test('groups connections by person, puts the current user first, and merges tabs', () => { + const people = groupPeople( + [ + connection('c2', ken, 'view:board'), + connection('c1', ada, 'overview'), + connection('c3', ken, 'agent'), + connection('c4', ada, 'scratchpad') + ], + { identity: ada, connectionId: 'c1', page: 'overview' } + ) + expect(people.map(person => person.identity.name)).toEqual(['Ada', 'Ken']) + expect(people[0]).toMatchObject({ self: true, pages: ['overview', 'scratchpad'] }) + expect(people[1]).toMatchObject({ self: false, pages: ['view:board', 'agent'] }) +}) + +test('a person whose tabs are all hidden has no pages, and the local user appears before connecting', () => { + const people = groupPeople([connection('c2', ken, null)], { + identity: ada, + connectionId: null, + page: null + }) + expect(people).toEqual([ + { identity: ada, self: true, pages: [] }, + { identity: ken, self: false, pages: [] } + ]) +}) diff --git a/client/features/collab/people.ts b/client/features/collab/people.ts new file mode 100644 index 00000000..2f119795 --- /dev/null +++ b/client/features/collab/people.ts @@ -0,0 +1,32 @@ +import type { CollabIdentity, CollabParticipant } from '@/lib/collab/types' + +// One row per person for the people list: connections collapse by identity +// id, the current user comes first, and `pages` lists the tabs their visible +// browser tabs are on. No pages means every tab of theirs is hidden: away. +export type Person = { identity: CollabIdentity; self: boolean; pages: string[] } + +export type SelfConnection = { + identity: CollabIdentity | null + connectionId: string | null + page: string | null +} + +export function groupPeople(participants: CollabParticipant[], self: SelfConnection): Person[] { + const people = new Map() + if (self.identity) { + people.set(self.identity.id, { + identity: self.identity, + self: true, + pages: self.page ? [self.page] : [] + }) + } + for (const participant of participants) { + if (participant.connectionId === self.connectionId) continue + const id = participant.identity.id + const person = people.get(id) ?? { identity: participant.identity, self: false, pages: [] } + const page = participant.location?.page + if (page && !person.pages.includes(page)) person.pages.push(page) + people.set(id, person) + } + return [...people.values()] +} diff --git a/client/features/dev/DevIndexPage.tsx b/client/features/dev/DevIndexPage.tsx index 7ef565b4..d34a838c 100644 --- a/client/features/dev/DevIndexPage.tsx +++ b/client/features/dev/DevIndexPage.tsx @@ -7,6 +7,11 @@ type DevRouteEntry = { } const DEV_ROUTES: DevRouteEntry[] = [ + { + path: '/dev/collab', + title: 'Collab', + description: 'Give this tab a collab identity: a name, a color, and a generated avatar.' + }, { path: '/dev/blobatar-shapes', title: 'Blobatar shape builder', diff --git a/client/features/dev/DevRoutes.tsx b/client/features/dev/DevRoutes.tsx index bdfbcde1..6c2a595a 100644 --- a/client/features/dev/DevRoutes.tsx +++ b/client/features/dev/DevRoutes.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { Route, Switch } from 'wouter' import { ChatStatesPage } from './ChatStatesPage' @@ -12,9 +13,18 @@ import { UiComponentsPage } from './UiComponentsPage' // (and its dependencies) lives in this folder and loads as one lazy chunk — // see the dynamic import in AppRouter. /dev itself is the index; list new // routes there too. +const DevCollabPage = lazy(() => + import('../collab/DevCollabPage').then(module => ({ default: module.DevCollabPage })) +) + export default function DevRoutes() { return ( + + + + + diff --git a/package.json b/package.json index 284672f6..4b65b4e2 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "culori": "^4.0.2", "devalue": "^5.9.2", "env-paths": "^4.0.0", + "facehash": "^0.1.0", "frimousse": "^0.3.0", "highlight.js": "^10.7.3", "hono": "^4.13.7", diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index f4cbd3e0..2d01c4cc 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -1,3 +1,4 @@ +import { COLLAB_MODULE_SOURCE } from '../collab/applet-module' import type { BunPlugin } from 'bun' import tailwind from 'bun-plugin-tailwind' import { realpathSync } from 'node:fs' @@ -232,6 +233,14 @@ function appletRuntimePlugin( })) // The `moi` runtime module (fileUrl). A bare specifier, so match it exactly. + build.onResolve({ filter: /^moi\/collab$/ }, () => ({ + path: 'moi/collab', + namespace: 'moi-collab' + })) + build.onLoad({ filter: /.*/, namespace: 'moi-collab' }, () => ({ + contents: COLLAB_MODULE_SOURCE, + loader: 'js' + })) build.onResolve({ filter: /^moi$/ }, () => ({ path: 'moi', namespace: 'moi-runtime' })) build.onLoad({ filter: /.*/, namespace: 'moi-runtime' }, () => ({ contents: MOI_MODULE_SOURCE, diff --git a/server/collab/applet-module.ts b/server/collab/applet-module.ts new file mode 100644 index 00000000..f86f660e --- /dev/null +++ b/server/collab/applet-module.ts @@ -0,0 +1,21 @@ +// Evaluated in the browser bundle only. All state and React contexts remain +// in the host feature, shared by every applet and revoked with its bridge. +export const COLLAB_MODULE_SOURCE = ` +import { createElement } from 'react'; +import { __getBridge } from 'moi'; +function api() { + const collab = __getBridge()?.collab; + if (!collab) throw new Error('Start moi with --experimental-collab to use collab, or reload this applet if its build was disposed.'); + return collab; +} +export function useSelf(...args) { return api().useSelf(...args); } +export function useOthers(...args) { return api().useOthers(...args); } +export function usePresence(...args) { return api().usePresence(...args); } +export function useSharedState(...args) { return api().useSharedState(...args); } +export function useSharedStore(...args) { return api().useSharedStore(...args); } +export function Cursors(props) { return createElement(api().Cursors, props); } +export function Activity(props) { return createElement(api().Activity, props); } +export function PresenceField(props) { return createElement(api().PresenceField, props); } +export function Selection(props) { return createElement(api().Selection, props); } +export function SyncStatus(props) { return createElement(api().SyncStatus, props); } +` diff --git a/server/collab/skill/index.test.ts b/server/collab/skill/index.test.ts new file mode 100644 index 00000000..2c202326 --- /dev/null +++ b/server/collab/skill/index.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type * as CollabApi from 'moi/collab' + +import type * as Components from '@/client/features/collab/components' +import type * as Hooks from '@/client/features/collab/hooks' +import type { WorkspaceType } from '@/lib/types' + +import { provisionWorkspace, skillsDirFor } from '../../workspace-init' +import { COLLAB_REFERENCE_SOURCE_PATH, installCollabSkill, removeCollabSkill } from './index' + +type ActualHooks = Pick< + typeof Hooks, + 'useSelf' | 'useOthers' | 'usePresence' | 'useSharedState' | 'useSharedStore' +> +type ActualComponents = Pick< + typeof Components, + 'Activity' | 'Cursors' | 'PresenceField' | 'Selection' | 'SyncStatus' +> +// This assignment is checked by tsc without importing React into the server. +const declarationsMatch: ActualHooks & ActualComponents extends typeof CollabApi ? true : false = + true + +const directories: string[] = [] + +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }) +}) + +test('optional collab reference installs beside the default skill without changing it', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-skill-')) + directories.push(workspace) + const installed = await installCollabSkill(workspace, 'codex') + expect(installed.referencePath).toBe( + join(workspace, '.agents', 'skills', 'moi-workspace', 'references', 'COLLABORATIVE.md') + ) + expect(installed.typesPath).toBe(join(workspace, '.moi', 'collab-env.d.ts')) + expect(await readFile(installed.referencePath, 'utf8')).toBe( + await readFile(COLLAB_REFERENCE_SOURCE_PATH, 'utf8') + ) + const defaultSkill = join(dirname(dirname(installed.referencePath)), 'SKILL.md') + const marker = '# Existing workspace skill\nLeave its authored instructions alone.\n' + await writeFile(defaultSkill, marker) + const before = await stat(installed.referencePath) + await installCollabSkill(workspace, 'codex') + expect((await stat(installed.referencePath)).mtimeMs).toBe(before.mtimeMs) + expect(await readFile(defaultSkill, 'utf8')).toBe(marker) + await removeCollabSkill(workspace, 'codex') + expect(await Bun.file(installed.referencePath).exists()).toBe(false) + expect(await Bun.file(installed.typesPath).exists()).toBe(false) + expect(await readFile(defaultSkill, 'utf8')).toBe(marker) +}) + +test('workspace declarations match the public hooks and components', () => { + expect(declarationsMatch).toBe(true) +}) + +test('optional guide uses each harness skill directory', async () => { + const types: WorkspaceType[] = ['claude-code', 'codex', 'openclaw', 'hermes'] + for (const type of types) { + const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-harness-')) + directories.push(workspace) + const { referencePath } = await installCollabSkill(workspace, type) + expect(referencePath).toBe( + join(skillsDirFor(workspace, type), 'moi-workspace', 'references', 'COLLABORATIVE.md') + ) + expect(await Bun.file(referencePath).exists()).toBe(true) + } +}) + +test('ordinary workspace provisioning omits collab and preserves a manually installed guide', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'moi-collab-provision-')) + directories.push(workspace) + // An existing scaffold skips dependency installation; this exercises the + // same provisioning path used by ordinary init and the UI. + await Bun.write(join(workspace, '.moi', 'package.json'), '{}\n') + await provisionWorkspace(workspace, 'codex') + const referencePath = join( + skillsDirFor(workspace, 'codex'), + 'moi-workspace', + 'references', + 'COLLABORATIVE.md' + ) + const typesPath = join(workspace, '.moi', 'collab-env.d.ts') + expect(await Bun.file(referencePath).exists()).toBe(false) + expect(await Bun.file(typesPath).exists()).toBe(false) + await installCollabSkill(workspace, 'codex') + const reference = await Bun.file(referencePath).text() + const declarations = await Bun.file(typesPath).text() + await provisionWorkspace(workspace, 'codex') + expect(await Bun.file(referencePath).text()).toBe(reference) + expect(await Bun.file(typesPath).text()).toBe(declarations) +}) From 7c80a7200c0ec3a0ae13172cef6de5e3913ddf53 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Tue, 15 Sep 2026 15:20:28 +0200 Subject: [PATCH 05/86] Integrate collab with workspace views and personal navigation --- client/app/routes/WorkspaceRoute.tsx | 27 +- client/features/applets/AppletMount.tsx | 9 +- client/features/applets/applet-runtime.ts | 5 + client/features/chat/useSelectedSession.ts | 82 ++++-- client/features/collab/personal-state.test.ts | 256 ++++++++++++++++++ client/features/collab/personal-state.ts | 61 +++++ client/features/views/ViewManager.tsx | 9 +- .../workspace/WorkspaceLayoutContext.tsx | 21 +- client/features/workspace/WorkspaceScreen.tsx | 33 ++- client/features/workspace/api.ts | 14 +- client/features/workspace/layout-save.test.ts | 67 +++++ client/features/workspace/layout-save.ts | 13 + client/features/workspace/moi-context.ts | 9 +- .../workspace/useWorkspaceNavigation.ts | 15 +- lib/types.ts | 2 + server/layout.ts | 17 +- server/test/layout.test.ts | 50 ++++ 17 files changed, 643 insertions(+), 47 deletions(-) create mode 100644 client/features/collab/personal-state.test.ts create mode 100644 client/features/collab/personal-state.ts create mode 100644 client/features/workspace/layout-save.test.ts create mode 100644 client/features/workspace/layout-save.ts diff --git a/client/app/routes/WorkspaceRoute.tsx b/client/app/routes/WorkspaceRoute.tsx index 74a40032..3cfdf332 100644 --- a/client/app/routes/WorkspaceRoute.tsx +++ b/client/app/routes/WorkspaceRoute.tsx @@ -1,3 +1,4 @@ +import { CollabGate, useCollabIdentityEnabled } from '@/client/features/collab/entry' import { useQueryClient } from '@tanstack/react-query' import { workspaceKeys } from '@/client/api/workspace-keys' @@ -24,13 +25,35 @@ type WorkspaceRouteProps = { export function WorkspaceRoute({ id }: WorkspaceRouteProps) { return ( - - + + ) } +function WorkspaceFeatures({ id }: WorkspaceRouteProps) { + const { collab, isLoading } = useWorkspaceLayoutCtx() + if (isLoading) + return ( + +
    + +
    +
    + ) + return ( + + + + ) +} + +function WorkspaceIdentity({ id }: WorkspaceRouteProps) { + const personal = useCollabIdentityEnabled() + return +} + function WorkspaceLoader({ id }: WorkspaceRouteProps) { const queryClient = useQueryClient() const [selectedSessionId] = useSelectedSession() diff --git a/client/features/applets/AppletMount.tsx b/client/features/applets/AppletMount.tsx index dcdd0c3c..127712e3 100644 --- a/client/features/applets/AppletMount.tsx +++ b/client/features/applets/AppletMount.tsx @@ -1,5 +1,6 @@ import { type ReactNode } from 'react' +import { AppletCollabMount } from '@/client/features/collab/entry' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { type AppletSegment, appletScope, appletStyleKey } from './applet-cache' @@ -25,7 +26,13 @@ export function AppletMount({ segment, name, version, children }: AppletMountPro return (
    - {children} + + {children} +
    ) } diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index dfb0137f..0b89805a 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -14,6 +14,7 @@ // — no central handlers object assembled by the screen. Applet → host only; // if a host → applet direction is ever added (`moi.on(...)`), `dispose` must // also unbind those listeners or a disposed module leaks. +import { getAppletCollabApi } from '@/client/features/collab/entry' import { useEffect } from 'react' import { createNanoEvents } from 'nanoevents' @@ -54,6 +55,7 @@ export type AppletEvents = { // cross the trust boundary from agent-authored code, and the runtime narrows // them before emitting. export type AppletBridge = { + collab?: ReturnType focusTab: (tab: unknown, params?: unknown) => void sendChatMessage: (message: unknown, context?: unknown) => void } @@ -124,6 +126,9 @@ function createRuntime(workspaceId: string) { let alive = true const source = appletSource(identity) const bridge: AppletBridge = { + get collab() { + return getAppletCollabApi() + }, focusTab(tab, params) { if (!alive) return if (!isWorkspaceTabId(tab)) return diff --git a/client/features/chat/useSelectedSession.ts b/client/features/chat/useSelectedSession.ts index c1ed38d2..13c3d6b6 100644 --- a/client/features/chat/useSelectedSession.ts +++ b/client/features/chat/useSelectedSession.ts @@ -1,3 +1,5 @@ +import { useCollabIdentityEnabled } from '@/client/features/collab/entry' +import { readPersonalSession, writePersonalSession } from '@/client/features/collab/personal-state' import { useCallback, useMemo } from 'react' import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -24,16 +26,22 @@ type SelectedSessionResult = readonly [ export type SelectedSessionSaveResult = 'applied' | 'conflict' | 'ignored' -function selectedSessionMutationKey(workspaceId: string) { - return [...appUiKeys.selectedSession(workspaceId), 'save'] as const +export function selectedSessionKey(workspaceId: string, personal = false) { + const key = appUiKeys.selectedSession(workspaceId) + return personal ? ([...key, 'collab-tab'] as const) : key +} + +function selectedSessionMutationKey(workspaceId: string, personal: boolean) { + return [...selectedSessionKey(workspaceId, personal), 'save'] as const } export function optimisticallySetSelectedSession( queryClient: QueryClient, workspaceId: string, - sessionId: string | null + sessionId: string | null, + personal = false ): SaveSelectedSessionInput | null { - const queryKey = appUiKeys.selectedSession(workspaceId) + const queryKey = selectedSessionKey(workspaceId, personal) const current = queryClient.getQueryData(queryKey) const previousSessionId = current?.sessionId ?? null if (current && previousSessionId === sessionId) return null @@ -46,9 +54,10 @@ export function settleSelectedSessionSave( queryClient: QueryClient, workspaceId: string, saved: SelectedSessionState, - input: SaveSelectedSessionInput + input: SaveSelectedSessionInput, + personal = false ): SelectedSessionSaveResult { - const queryKey = appUiKeys.selectedSession(workspaceId) + const queryKey = selectedSessionKey(workspaceId, personal) const current = queryClient.getQueryData(queryKey) if (saved.sessionId !== input.sessionId && current?.sessionId !== saved.sessionId) { @@ -80,16 +89,27 @@ export function renameSelectedSessionInCache( from: string, to: string ): void { - queryClient.setQueryData(appUiKeys.selectedSession(workspaceId), current => - current?.sessionId === from ? { sessionId: to } : current - ) + for (const personal of [false, true]) { + queryClient.setQueryData( + selectedSessionKey(workspaceId, personal), + current => (current?.sessionId === from ? { sessionId: to } : current) + ) + } + if (readPersonalSession(workspaceId) === from) writePersonalSession(workspaceId, to) } export function useSelectedSession(): SelectedSessionResult { const workspaceId = useWorkspaceId() + const collabEnabled = useCollabIdentityEnabled() const queryClient = useQueryClient() - const queryKey = useMemo(() => appUiKeys.selectedSession(workspaceId), [workspaceId]) - const mutationKey = useMemo(() => selectedSessionMutationKey(workspaceId), [workspaceId]) + const queryKey = useMemo( + () => selectedSessionKey(workspaceId, collabEnabled), + [workspaceId, collabEnabled] + ) + const mutationKey = useMemo( + () => selectedSessionMutationKey(workspaceId, collabEnabled), + [workspaceId, collabEnabled] + ) const pendingSaves = useIsMutating({ mutationKey, exact: true }) // WorkspaceLoader remains an observer for the active workspace. Nested hook @@ -97,7 +117,10 @@ export function useSelectedSession(): SelectedSessionResult { // the cache makes the next visit load the server-owned selection again. const query = useQuery({ queryKey, - queryFn: () => requestJson(`/api/workspaces/${workspaceId}/selected-session`), + queryFn: () => + collabEnabled + ? Promise.resolve({ sessionId: readPersonalSession(workspaceId) }) + : requestJson(`/api/workspaces/${workspaceId}/selected-session`), staleTime: Infinity, gcTime: 0, refetchOnMount: false, @@ -111,14 +134,25 @@ export function useSelectedSession(): SelectedSessionResult { >({ mutationKey, scope: { id: `selected-session:${workspaceId}` }, - mutationFn: input => - requestJson( + mutationFn: input => { + if (collabEnabled) { + writePersonalSession(workspaceId, input.sessionId) + return Promise.resolve({ sessionId: input.sessionId }) + } + return requestJson( `/api/workspaces/${workspaceId}/selected-session`, jsonRequest('PUT', input), 'Couldn’t save selected chat' - ), + ) + }, onSuccess: (saved, input) => { - const result = settleSelectedSessionSave(queryClient, workspaceId, saved, input) + const result = settleSelectedSessionSave( + queryClient, + workspaceId, + saved, + input, + collabEnabled + ) if (result !== 'conflict') return toast.add({ title: 'Couldn’t save selected chat', type: 'error' }) @@ -135,14 +169,24 @@ export function useSelectedSession(): SelectedSessionResult { const setSelectedSessionId = useCallback( sessionId => { - const input = optimisticallySetSelectedSession(queryClient, workspaceId, sessionId) + const input = optimisticallySetSelectedSession( + queryClient, + workspaceId, + sessionId, + collabEnabled + ) if (input) saveSelectedSession(input) }, - [queryClient, saveSelectedSession, workspaceId] + [queryClient, saveSelectedSession, workspaceId, collabEnabled] ) useWorkspaceEvent(event => { - if (event.type !== 'selected-session:updated' || event.workspaceId !== workspaceId) return + if ( + collabEnabled || + event.type !== 'selected-session:updated' || + event.workspaceId !== workspaceId + ) + return applySelectedSessionEvent(queryClient, workspaceId, event.sessionId, pendingSaves > 0) }) diff --git a/client/features/collab/personal-state.test.ts b/client/features/collab/personal-state.test.ts new file mode 100644 index 00000000..06e0d964 --- /dev/null +++ b/client/features/collab/personal-state.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { QueryClient, QueryObserver } from '@tanstack/react-query' + +import { + applySelectedSessionEvent, + optimisticallySetSelectedSession, + renameSelectedSessionInCache, + selectedSessionKey, + settleSelectedSessionSave +} from '@/client/features/chat/useSelectedSession' +import type { SelectedSessionState, WorkspaceTabsState } from '@/lib/types' +import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' +import { mergeLayoutForSave } from '@/server/layout' + +import { + readPersonalSession, + readPersonalTabs, + writePersonalSession, + writePersonalTabs +} from './personal-state' + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage') + +function browserTab(): Storage { + const data = new Map() + return { + get length() { + return data.size + }, + clear: () => data.clear(), + key: index => [...data.keys()][index] ?? null, + getItem: key => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value) + }, + removeItem: key => { + data.delete(key) + } + } +} + +function useBrowserTab(storage: Storage) { + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: storage }) +} + +afterEach(() => { + if (originalStorage) Object.defineProperty(globalThis, 'sessionStorage', originalStorage) + else Reflect.deleteProperty(globalThis, 'sessionStorage') +}) + +const defaults: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'overview' } + +describe('collab personal state', () => { + test('two browser tabs select chats independently, including New chat', () => { + const anna = browserTab() + const boris = browserTab() + useBrowserTab(anna) + writePersonalSession('workspace', 'annas-chat') + useBrowserTab(boris) + expect(readPersonalSession('workspace')).toBeNull() + writePersonalSession('workspace', 'boris-chat') + useBrowserTab(anna) + expect(readPersonalSession('workspace')).toBe('annas-chat') + writePersonalSession('workspace', null) + expect(readPersonalSession('workspace')).toBeNull() + useBrowserTab(boris) + expect(readPersonalSession('workspace')).toBe('boris-chat') + }) + + test('personal chat and view choices survive reads and stay partitioned by workspace', () => { + useBrowserTab(browserTab()) + const selected: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'view:board' } + writePersonalSession('one', 'chat-one') + writePersonalSession('two', 'chat-two') + writePersonalTabs('one', selected) + expect(readPersonalSession('one')).toBe('chat-one') + expect(readPersonalSession('two')).toBe('chat-two') + expect(readPersonalTabs('one', defaults)).toEqual(selected) + expect(readPersonalTabs('two', defaults)).toEqual(defaults) + expect(defaults.active).toBe('overview') + }) + + test('local view selection does not change another tab or the authored defaults', () => { + const anna = browserTab() + const boris = browserTab() + useBrowserTab(anna) + writePersonalTabs('workspace', { open: ['overview', 'view:board'], active: 'view:board' }) + useBrowserTab(boris) + expect(readPersonalTabs('workspace', defaults)).toEqual(defaults) + writePersonalTabs('workspace', { open: ['overview', 'scratchpad'], active: 'scratchpad' }) + useBrowserTab(anna) + expect(readPersonalTabs('workspace', defaults).active).toBe('view:board') + expect(defaults).toEqual({ open: ['overview', 'view:board'], active: 'overview' }) + }) + + test('malformed saved tabs fall back safely and stored tab lists are normalized', () => { + const storage = browserTab() + useBrowserTab(storage) + storage.setItem('moi:collab:workspace:tabs', '{broken') + expect(readPersonalTabs('workspace', defaults)).toEqual(defaults) + storage.setItem( + 'moi:collab:workspace:tabs', + JSON.stringify({ + open: ['view:board', 'view:board', 'not-a-tab'], + active: 'not-a-tab' + }) + ) + expect(readPersonalTabs('workspace', defaults)).toEqual({ + open: ['overview', 'view:board'], + active: 'overview' + }) + }) + + test('denied browser storage does not crash personal selection', () => { + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get: () => { + throw new Error('Storage disabled') + } + }) + expect(readPersonalSession('workspace')).toBeNull() + expect(readPersonalTabs('workspace', defaults)).toEqual(defaults) + expect(() => writePersonalSession('workspace', 'chat')).not.toThrow() + expect(() => writePersonalTabs('workspace', defaults)).not.toThrow() + }) +}) + +describe('collab authored layout preservation', () => { + test('saving shared layout after personal navigation preserves authored tab defaults', () => { + useBrowserTab(browserTab()) + const existing = { + ...createDefaultWorkspaceLayout(), + tabs: defaults + } + const personal: WorkspaceTabsState = { + open: ['overview', 'scratchpad'], + active: 'scratchpad' + } + writePersonalTabs('workspace', personal) + const { tabs: _tabs, ...layout } = existing + const merged = mergeLayoutForSave(existing, { ...layout, layoutMode: 'fullscreen' }) + expect(merged.tabs).toEqual(defaults) + expect(merged.layoutMode).toBe('fullscreen') + expect(readPersonalTabs('workspace', defaults)).toEqual(personal) + }) + + test('ordinary workspace saves retain their existing shared tab behavior', () => { + const existing = createDefaultWorkspaceLayout() + const tabs: WorkspaceTabsState = { open: ['overview', 'scratchpad'], active: 'scratchpad' } + expect(mergeLayoutForSave(existing, { ...existing, tabs }).tabs).toEqual(tabs) + }) +}) + +describe('collab selected chat cache transitions', () => { + test('supplying an identity immediately remounts onto the saved tab chat before shared cache GC', async () => { + useBrowserTab(browserTab()) + writePersonalSession('workspace', 'saved-tab-chat') + const client = new QueryClient() + const sharedKey = selectedSessionKey('workspace') + client.setQueryData(sharedKey, { sessionId: 'old-shared-chat' }) + const options = { staleTime: Infinity, gcTime: 0, refetchOnMount: false as const } + const shared = new QueryObserver(client, { + ...options, + queryKey: sharedKey, + queryFn: async () => ({ sessionId: 'old-shared-chat' }) + }) + const stopShared = shared.subscribe(() => {}) + stopShared() + let reads = 0 + const personal = new QueryObserver(client, { + ...options, + queryKey: selectedSessionKey('workspace', true), + queryFn: async () => { + reads++ + return { sessionId: readPersonalSession('workspace') } + } + }) + let stopPersonal = () => {} + try { + // Do not wait for gcTime: a keyed React remount adds its new observer in + // the same commit, before the old cache's zero-delay GC timer can run. + const selected = await new Promise(resolve => { + const inspect = () => { + const result = personal.getCurrentResult() + if (result.isSuccess) resolve(result.data) + } + stopPersonal = personal.subscribe(inspect) + inspect() + }) + expect(selected).toEqual({ sessionId: 'saved-tab-chat' }) + expect(reads).toBe(1) + } finally { + stopPersonal() + client.clear() + } + }) + + test('a late shared save and remote selection event cannot replace the current personal chat', () => { + const client = new QueryClient() + try { + client.setQueryData(selectedSessionKey('workspace'), { sessionId: 'old-shared-chat' }) + const pendingShared = optimisticallySetSelectedSession( + client, + 'workspace', + 'in-flight-shared-chat' + ) + const personal = optimisticallySetSelectedSession(client, 'workspace', 'personal-chat', true) + if (!pendingShared || !personal) throw new Error('Expected pending selections') + settleSelectedSessionSave( + client, + 'workspace', + { sessionId: 'in-flight-shared-chat' }, + pendingShared + ) + applySelectedSessionEvent(client, 'workspace', 'remote-chat', false) + expect( + client.getQueryData(selectedSessionKey('workspace', true)) + ).toEqual({ + sessionId: 'personal-chat' + }) + expect(client.getQueryData(selectedSessionKey('workspace'))).toEqual({ + sessionId: 'remote-chat' + }) + settleSelectedSessionSave(client, 'workspace', { sessionId: 'personal-chat' }, personal, true) + expect( + client.getQueryData(selectedSessionKey('workspace', true)) + ).toEqual({ + sessionId: 'personal-chat' + }) + } finally { + client.clear() + } + }) + + test('a session id replacement follows the selected personal chat into saved tab state', () => { + useBrowserTab(browserTab()) + const client = new QueryClient() + try { + writePersonalSession('workspace', 'temporary-id') + client.setQueryData(selectedSessionKey('workspace', true), { sessionId: 'temporary-id' }) + client.setQueryData(selectedSessionKey('workspace'), { sessionId: 'different-shared-chat' }) + renameSelectedSessionInCache(client, 'workspace', 'temporary-id', 'provider-id') + expect(readPersonalSession('workspace')).toBe('provider-id') + expect( + client.getQueryData(selectedSessionKey('workspace', true)) + ).toEqual({ + sessionId: 'provider-id' + }) + expect(client.getQueryData(selectedSessionKey('workspace'))).toEqual({ + sessionId: 'different-shared-chat' + }) + } finally { + client.clear() + } + }) +}) diff --git a/client/features/collab/personal-state.ts b/client/features/collab/personal-state.ts new file mode 100644 index 00000000..86c07297 --- /dev/null +++ b/client/features/collab/personal-state.ts @@ -0,0 +1,61 @@ +import { useCallback, useState } from 'react' + +import type { WorkspaceTabsState } from '@/lib/types' +import { normalizeWorkspaceTabs } from '@/lib/workspace-layout' + +const keyFor = (workspaceId: string, field: string) => `moi:collab:${workspaceId}:${field}` + +export function readPersonalSession(workspaceId: string): string | null { + try { + return sessionStorage.getItem(keyFor(workspaceId, 'session')) + } catch { + return null + } +} + +export function writePersonalSession(workspaceId: string, sessionId: string | null): void { + try { + if (sessionId === null) sessionStorage.removeItem(keyFor(workspaceId, 'session')) + else sessionStorage.setItem(keyFor(workspaceId, 'session'), sessionId) + } catch { + /* A private browser can still keep the active query in memory. */ + } +} + +export function readPersonalTabs( + workspaceId: string, + defaults: WorkspaceTabsState +): WorkspaceTabsState { + try { + const saved = sessionStorage.getItem(keyFor(workspaceId, 'tabs')) + return saved ? normalizeWorkspaceTabs(JSON.parse(saved)) : defaults + } catch { + return defaults + } +} + +export function writePersonalTabs(workspaceId: string, tabs: WorkspaceTabsState): void { + try { + sessionStorage.setItem(keyFor(workspaceId, 'tabs'), JSON.stringify(tabs)) + } catch { + // The hook retains its in-memory selection when browser storage is denied. + } +} + +export function usePersonalTabs( + workspaceId: string, + enabled: boolean, + defaults: WorkspaceTabsState +) { + const [local, setLocal] = useState(() => + enabled ? readPersonalTabs(workspaceId, defaults) : defaults + ) + const setTabs = useCallback( + (tabs: WorkspaceTabsState) => { + setLocal(tabs) + writePersonalTabs(workspaceId, tabs) + }, + [workspaceId] + ) + return [enabled ? local : defaults, setTabs] as const +} diff --git a/client/features/views/ViewManager.tsx b/client/features/views/ViewManager.tsx index cd56e81f..9e454b77 100644 --- a/client/features/views/ViewManager.tsx +++ b/client/features/views/ViewManager.tsx @@ -25,6 +25,7 @@ import { useView } from '@/client/features/applets/useApplet' import { WidgetErrorBoundary } from '@/client/features/applets/WidgetErrorBoundary' +import { AppletCollabMount } from '@/client/features/collab/entry' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { cn } from '@/client/lib/cn' import { useLatestRef } from '@/client/lib/use-latest-ref' @@ -227,7 +228,13 @@ function ViewFrame({ view, build, params, entering, thumbnailTarget }: ViewFrame workspaceId={workspaceId} resetKey={build.version} > - + + +
    diff --git a/client/features/workspace/WorkspaceLayoutContext.tsx b/client/features/workspace/WorkspaceLayoutContext.tsx index 8c58a831..b806314c 100644 --- a/client/features/workspace/WorkspaceLayoutContext.tsx +++ b/client/features/workspace/WorkspaceLayoutContext.tsx @@ -9,9 +9,11 @@ import { } from '@/client/features/workspace/api' import { workspaceKeys } from '@/client/api/workspace-keys' import { useLatestRef } from '@/client/lib/use-latest-ref' -import type { WorkspaceLayout, WorkspaceType } from '@/lib/types' +import type { WorkspaceLayout, WorkspaceLayoutSave, WorkspaceType } from '@/lib/types' import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' +import { accumulateLayoutSave } from './layout-save' + export type WorkspaceLayoutContextValue = { // The persisted layout (widget grid, layout mode, theme). Falls back to an // empty default while the query is still loading. @@ -29,6 +31,7 @@ export type WorkspaceLayoutContextValue = { // The workspace's registry id (the route param), so descendants can key // their own queries (e.g. the model picker) without prop-drilling. workspaceId: string + collab?: WorkspaceLayoutResponse['collab'] isLoading: boolean } @@ -53,7 +56,14 @@ export function useWorkspaceThemeSetting(): WorkspaceLayout['theme'] { // Strip the server-only metadata so what we PUT back (and expose as `layout`) // is just the persisted `WorkspaceLayout`. function stripMeta(data: WorkspaceLayoutResponse): WorkspaceLayout { - const { cwd: _cwd, name: _name, provider: _provider, agentId: _agentId, ...layout } = data + const { + cwd: _cwd, + name: _name, + provider: _provider, + agentId: _agentId, + collab: _collab, + ...layout + } = data return layout } @@ -73,6 +83,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide // reconcile), so reach the latest mutate via a ref instead of closing over it. const saveRef = useLatestRef(save.mutate) const timer = useRef | null>(null) + const pendingSave = useRef(null) const setLayout = useCallback( (update: Partial) => { @@ -82,6 +93,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide // Optimistic: the grid/theme reflects the change before the PUT lands. const next = { ...prev, ...update } qc.setQueryData(key, next) + pendingSave.current = accumulateLayoutSave(stripMeta(next), update, pendingSave.current) if (timer.current) clearTimeout(timer.current) // Persist the value captured at call time, NOT the cache at fire time: a @@ -92,7 +104,9 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide // optimistic write. timer.current = setTimeout(() => { timer.current = null - saveRef.current(stripMeta(next)) + const payload = pendingSave.current + pendingSave.current = null + if (payload) saveRef.current(payload) }, 600) }, [id, qc, saveRef] @@ -110,6 +124,7 @@ export function WorkspaceLayoutProvider({ id, children }: WorkspaceLayoutProvide cwd: query.data?.cwd ?? null, provider: query.data?.provider ?? null, workspaceId: id, + collab: query.data?.collab, isLoading: query.isLoading }), [query.data, query.isLoading, setLayout, id] diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 2f602d72..1485a8f6 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -22,6 +22,7 @@ import { ChatPopup } from '@/client/features/chat/ChatPopup' import { ThemePanel } from '@/client/features/workspace/ThemePanel' import { useAppletEvent } from '@/client/features/applets/applet-runtime' import { Overview } from '@/client/features/overview/Overview' +import { CollabControls, useCollabIdentityEnabled } from '@/client/features/collab/entry' import { PanelHeader } from '@/client/components/shared/PanelHeader' import { WorkspaceIcon } from '@/client/components/shared/WorkspaceIcon' import { Button } from '@/client/components/ui/button' @@ -217,6 +218,7 @@ function WorkspaceMenu({ onOpenTheme }: WorkspaceMenuProps) { export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenProps) { const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx() + const collabIdentityEnabled = useCollabIdentityEnabled() const theme = resolveWorkspaceTheme(layout.theme) const builderActions = useViewBuilderActions() const { @@ -242,9 +244,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // Split needs the open set to decide whether it's available at all, and the // navigation hook needs split to resolve the active tab — so the open set is // derived from the raw layout here, before either. - const openTabIds = effectiveOpenTabs(normalizeTabsState(layout.tabs), views, builders) - const nonAgentOpenTabs = openTabIds.filter(tab => tab !== 'agent') - const hasWorkspaceContent = nonAgentOpenTabs.length > 0 + const hasWorkspaceContent = effectiveOpenTabs( + normalizeTabsState(layout.tabs), + views, + builders + ).some(tab => tab !== 'agent') const hasAppletWidgets = widgets.some(widget => !isDefaultWidget(widget.id)) const hasWorkspaceApplets = hasAppletWidgets || views.length > 0 @@ -289,6 +293,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro } ) + const openTabIds = effectiveOpenTabs(tabsState, views, builders) + const nonAgentOpenTabs = openTabIds.filter(tab => tab !== 'agent') const openSet = new Set(tabsState.open) // Entering split with the agent tab on screen needs no special-casing @@ -347,13 +353,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro const open = tabsState.open.filter(tab => tabAvailable(tab, views, builders)) if (open.length === tabsState.open.length) return const nextOpen = effectiveOpenTabs(tabsState, views, builders) - setLayout({ - tabs: { - open: nextOpen, - active: nextOpen.includes(tabsState.active) ? tabsState.active : nextOpen[0] - } + setTabs({ + open: nextOpen, + active: nextOpen.includes(tabsState.active) ? tabsState.active : nextOpen[0] }) - }, [builders, setLayout, tabsState, views]) + }, [builders, setTabs, tabsState, views]) useEffect(() => { const replacements = new Map() @@ -387,8 +391,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro } if (!changed) return const active = replacements.get(tabsState.active) ?? tabsState.active - setLayout({ tabs: { open: open.length > 0 ? open : ['overview'], active } }) - }, [activeTab, builders, navigateToTab, setLayout, tabsState, views]) + setTabs({ open: open.length > 0 ? open : ['overview'], active }) + }, [activeTab, builders, navigateToTab, setTabs, tabsState, views]) useEffect(() => { if (mode !== 'fullscreen' || activeTab === 'agent') { @@ -438,7 +442,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // `moi tabs focus` — a workspace event, not an applet call: the control // server validated the target and params before publishing. useWorkspaceEvent(event => { - if (event.type === 'tab:focus' && event.workspaceId === workspaceId) { + if (event.type === 'tab:focus' && event.workspaceId === workspaceId && !collabIdentityEnabled) { openTab(event.tab, event.params) } else if ( event.type === 'view:deleted' && @@ -740,6 +744,11 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro onReorder={reorderTabs} /> + tabItemFor(tab, views, builders, false, false, () => false)} + onOpenTab={openTab} + /> {hasWorkspaceContent && canUseSplit && mode === 'fullscreen' && ( + onWorkspaceEventsReconnect(() => { + void queryClient.invalidateQueries({ queryKey: workspaceKeys.layout(workspaceId) }) + }), + [queryClient, workspaceId] + ) return useQuery({ queryKey: workspaceKeys.layout(workspaceId), queryFn: () => requestJson(`/api/workspaces/${workspaceId}`), @@ -122,7 +134,7 @@ export function useUpdateWorkspaceSkills(workspaceId: string) { export function useSaveLayout(workspaceId: string) { const queryClient = useQueryClient() - return useMutation({ + return useMutation({ mutationFn: layout => requestVoid( `/api/workspaces/${workspaceId}`, diff --git a/client/features/workspace/layout-save.test.ts b/client/features/workspace/layout-save.test.ts new file mode 100644 index 00000000..30f4952d --- /dev/null +++ b/client/features/workspace/layout-save.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' + +import type { WorkspaceTabsState, WorkspaceTheme } from '@/lib/types' +import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' +import { mergeLayoutForSave } from '@/server/layout' + +import { accumulateLayoutSave } from './layout-save' + +const initial = createDefaultWorkspaceLayout() +const selected: WorkspaceTabsState = { open: ['overview', 'view:board'], active: 'view:board' } + +describe('debounced layout saves', () => { + test('grid and theme edits omit cached tabs, preserving newer server navigation', () => { + const update = { widgetGrid: [{ i: 'board', x: 1, y: 2 }] } + const pending = accumulateLayoutSave({ ...initial, ...update }, update, null) + const theme: WorkspaceTheme = { + font: 'sans', + color: 'rose', + radius: 'square', + agent: 'dorito' + } + const payload = accumulateLayoutSave({ ...initial, ...update, theme }, { theme }, pending) + + expect('tabs' in payload).toBe(false) + const saved = mergeLayoutForSave({ ...initial, tabs: selected }, payload) + expect(saved.tabs).toEqual(selected) + expect(saved.widgetGrid).toEqual(update.widgetGrid) + expect(saved.theme).toEqual(theme) + }) + + test('navigation followed by a grid edit keeps both changes in the same save', () => { + const navigated = { ...initial, tabs: selected } + const pending = accumulateLayoutSave(navigated, { tabs: selected }, null) + const update = { widgetGrid: [{ i: 'board', x: 2, y: 3 }] } + const payload = accumulateLayoutSave({ ...navigated, ...update }, update, pending) + + expect(mergeLayoutForSave(initial, payload)).toEqual({ ...navigated, ...update }) + }) + + test('a refetch between navigation and another edit cannot erase pending navigation', () => { + const pending = accumulateLayoutSave({ ...initial, tabs: selected }, { tabs: selected }, null) + // The layout query refetched the old server state before the debounce fired. + const update = { layoutMode: 'fullscreen' as const } + const payload = accumulateLayoutSave({ ...initial, ...update }, update, pending) + + expect(payload.tabs).toEqual(selected) + expect(payload.layoutMode).toBe('fullscreen') + }) + + test('the latest explicit navigation replaces the previous pending tab selection', () => { + const pending = accumulateLayoutSave({ ...initial, tabs: selected }, { tabs: selected }, null) + const tabs: WorkspaceTabsState = { open: ['overview', 'agent'], active: 'agent' } + const payload = accumulateLayoutSave({ ...initial, tabs }, { tabs }, pending) + + expect(payload.tabs).toEqual(tabs) + expect(pending.tabs).toEqual(selected) + }) + + test('a new debounce window does not resend previously saved tabs', () => { + const saved = { ...initial, tabs: selected } + const update = { layoutMode: 'fullscreen' as const } + const payload = accumulateLayoutSave({ ...saved, ...update }, update, null) + + expect('tabs' in payload).toBe(false) + expect(payload.layoutMode).toBe('fullscreen') + }) +}) diff --git a/client/features/workspace/layout-save.ts b/client/features/workspace/layout-save.ts new file mode 100644 index 00000000..658ce463 --- /dev/null +++ b/client/features/workspace/layout-save.ts @@ -0,0 +1,13 @@ +import type { WorkspaceLayout, WorkspaceLayoutSave } from '@/lib/types' + +// Unrelated edits must omit tabs from the PUT. Keep the last explicit tab edit +// through the debounce window, even if a refetch replaces the optimistic cache. +export function accumulateLayoutSave( + next: WorkspaceLayout, + update: Partial, + pending: WorkspaceLayoutSave | null +): WorkspaceLayoutSave { + const { tabs: _tabs, ...layout } = next + const tabs = update.tabs ?? pending?.tabs + return tabs === undefined ? layout : { ...layout, tabs } +} diff --git a/client/features/workspace/moi-context.ts b/client/features/workspace/moi-context.ts index b5100cd0..30d9dc73 100644 --- a/client/features/workspace/moi-context.ts +++ b/client/features/workspace/moi-context.ts @@ -1,3 +1,4 @@ +import { WorkspaceLayoutContext } from './WorkspaceLayoutContext' // Central assembly of the moi context sent with every chat message — the one // place that snapshots the workspace's primary UI state (active tab, view // titles) and drains queued one-shot directives. The structured `MoiContext` @@ -21,7 +22,7 @@ // Adding a new ambient field (e.g. scratchpad selection): extend the // `MoiContext` type and its renderer in lib/moi-context.ts, then supply // the field in `useMoiUserMessageContext`'s builder below. -import { useCallback } from 'react' +import { useCallback, useContext } from 'react' import { useViewBuilders, useViews } from '@/client/features/views/api' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' @@ -111,6 +112,7 @@ export function useMoiUserMessageContext({ appletParams }: WorkspaceTabAddress): (options?: MoiUserMessageOptions) => MoiContext { const workspaceId = useWorkspaceId() + const collab = useContext(WorkspaceLayoutContext)?.collab const views = useViews(workspaceId).data const builders = useViewBuilders(workspaceId).data return useCallback( @@ -119,12 +121,15 @@ export function useMoiUserMessageContext({ const tabParams = envelopeTabParams(activeTab, appletParams) return { activeTab, + ...(collab?.enabled && collab.referencePath + ? { collabReference: collab.referencePath } + : {}), tabTitle: activeTabTitle(activeTab, views, builders), ...(tabParams ? { tabParams } : {}), ...(options.applet ? { applet: options.applet } : {}), ...(directives.length > 0 ? { directives } : {}) } }, - [workspaceId, activeTab, appletParams, views, builders] + [workspaceId, activeTab, appletParams, views, builders, collab] ) } diff --git a/client/features/workspace/useWorkspaceNavigation.ts b/client/features/workspace/useWorkspaceNavigation.ts index da77f744..d0ab827a 100644 --- a/client/features/workspace/useWorkspaceNavigation.ts +++ b/client/features/workspace/useWorkspaceNavigation.ts @@ -1,3 +1,4 @@ +import { usePersonalTabs } from '@/client/features/collab/personal-state' // The workspace's tab address: which tab the URL names, how to navigate // elsewhere, and the persistence that keeps a bare `/workspace/:id` landing // somewhere sensible. The URL is the live truth for the active tab; the @@ -20,6 +21,7 @@ import { resolveActiveTab } from '@/client/features/workspace/tab-resolution' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' +import { useCollabIdentityEnabled } from '@/client/features/collab/entry' import { useLatestRef } from '@/client/lib/use-latest-ref' import type { ViewBuilder, ViewInfo, WorkspaceTabId, WorkspaceTabsState } from '@/lib/types' import { @@ -41,6 +43,7 @@ type UseWorkspaceNavigationOptions = { export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceNavigationOptions) { const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx() + const personal = useCollabIdentityEnabled() const [, navigate] = useLocation() // The tab id is the route's wildcard segment, read from the matched route // instead of threaded down as a prop — so the pattern stays in AppRouter and @@ -51,7 +54,12 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN const historyState = useHistoryState() const appletParams = useMemo(() => readAppletParams(historyState), [historyState]) - const tabsState = normalizeTabsState(layout.tabs) + const [personalTabs, setPersonalTabs] = usePersonalTabs( + workspaceId, + personal, + normalizeTabsState(layout.tabs) + ) + const tabsState = normalizeTabsState(personalTabs) // Mirror for the effects below: a debounced layout PUT can still be in flight // when a `workspace:updated` refetch lands, so reading the render-time value // could persist a stale open set (and resurrect a just-closed tab). @@ -78,9 +86,10 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN const setTabs = useCallback( (tabs: WorkspaceTabsState) => { tabsStateRef.current = tabs - setLayout({ tabs }) + if (personal) setPersonalTabs(tabs) + else setLayout({ tabs }) }, - [setLayout, tabsStateRef] + [setLayout, tabsStateRef, personal, setPersonalTabs] ) // Keep the URL honest. One redirect covers every case: a bare diff --git a/lib/types.ts b/lib/types.ts index 9056132c..7914dabc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -556,6 +556,8 @@ export type WorkspaceTabsState = { export type { AgentTheme, ColorTheme, FontTheme, RadiusTheme, WorkspaceTheme } from './themes' +export type WorkspaceLayoutSave = Omit & { tabs?: WorkspaceTabsState } + export type WorkspaceLayout = { version: 1 widgetGrid: LayoutGridItem[] diff --git a/server/layout.ts b/server/layout.ts index 06cd5a6c..969e51a2 100644 --- a/server/layout.ts +++ b/server/layout.ts @@ -1,6 +1,11 @@ import { join } from 'path' -import type { AppletKind, WorkspaceLayout, WorkspacePreview } from '@/lib/types' +import type { + AppletKind, + WorkspaceLayout, + WorkspaceLayoutSave, + WorkspacePreview +} from '@/lib/types' import { createDefaultWorkspaceLayout, normalizeWorkspaceTabs } from '@/lib/workspace-layout' import { isWorkspaceIcon } from '@/lib/workspace-icon' @@ -13,6 +18,8 @@ function normalizeLayout(parsed: Record): WorkspaceLayout { layout.layoutMode = defaults.layoutMode } layout.tabs = normalizeWorkspaceTabs(layout.tabs) + delete layout.experimental + delete layout.collab if (typeof layout.icon === 'string') { layout.icon = { type: 'upload', value: layout.icon } } else if (layout.icon !== undefined && !isWorkspaceIcon(layout.icon)) { @@ -59,15 +66,19 @@ export async function saveLayout(layout: WorkspaceLayout, workspacePath: string) // as `name: undefined`. export function mergeLayoutForSave( existing: WorkspaceLayout, - body: WorkspaceLayout + body: WorkspaceLayoutSave ): WorkspaceLayout { const { name: _name, icon: _icon, ...editor } = body + delete (editor as Record).experimental + delete (editor as Record).collab // Stale clients may still round-trip the pre-`.cache` thumbnail records; // never let a layout PUT resurrect them in `.workspace.json`. delete (editor as Record).appletThumbnails return { ...editor, - tabs: normalizeWorkspaceTabs(editor.tabs), + // Personal navigation never writes tabs. Other layout edits must not + // round-trip a stale copy of the workspace's authored defaults. + tabs: editor.tabs === undefined ? existing.tabs : normalizeWorkspaceTabs(editor.tabs), ...(existing.name !== undefined && { name: existing.name }), ...(existing.icon !== undefined && { icon: existing.icon }) } diff --git a/server/test/layout.test.ts b/server/test/layout.test.ts index 5f1ecf99..2d64cfc8 100644 --- a/server/test/layout.test.ts +++ b/server/test/layout.test.ts @@ -201,6 +201,18 @@ describe('loadLayout', () => { }) }) }) + + test('drops legacy collab configuration and runtime metadata when loading a workspace', async () => { + await withWorkspaceFile( + { ...base, experimental: { collab: true }, collab: { enabled: true } }, + async dir => { + const loaded = await loadLayout(dir) + expect(loaded).toEqual(base) + expect('experimental' in loaded).toBe(false) + expect('collab' in loaded).toBe(false) + } + ) + }) }) describe('mergeLayoutForSave', () => { @@ -276,6 +288,44 @@ describe('mergeLayoutForSave', () => { expect(merged.name).toBe('Keep') }) + test('a layout save omitting tabs preserves newer authored tabs', () => { + const existing: WorkspaceLayout = { + ...base, + tabs: { open: ['overview', 'view:board'], active: 'view:board' } + } + // This client loaded base before another client changed the workspace tabs. + const { tabs: _staleTabs, ...body } = base + const merged = mergeLayoutForSave(existing, { + ...body, + widgetGrid: [{ i: 'w', x: 2, y: 3 }] + }) + expect(merged.tabs).toEqual(existing.tabs) + expect(merged.widgetGrid).toEqual([{ i: 'w', x: 2, y: 3 }]) + }) + + test('explicit undefined tabs have the same effect as an omitted JSON field', () => { + const { tabs: _tabs, ...body } = base + expect(mergeLayoutForSave(base, { ...body, tabs: undefined }).tabs).toEqual(base.tabs) + }) + + test('an explicit tab update still changes authored defaults after an unrelated save', () => { + const { tabs: _tabs, ...body } = base + const saved = mergeLayoutForSave(base, { ...body, layoutMode: 'split' }) + const tabs: WorkspaceLayout['tabs'] = { open: ['overview', 'agent'], active: 'agent' } + expect(mergeLayoutForSave(saved, { ...saved, tabs })).toEqual({ + ...saved, + tabs + }) + }) + + test('stale saves cannot restore legacy collab configuration or runtime metadata', () => { + const stale = { ...base, experimental: { collab: true }, collab: { enabled: true } } + expect(mergeLayoutForSave(stale, stale)).toEqual(base) + // Filtering the save must not mutate either caller-owned object. + expect(stale.experimental).toEqual({ collab: true }) + expect(stale.collab).toEqual({ enabled: true }) + }) + test('drops the old Widgets tab id from stale client saves', () => { const body = { ...base, From 7a96f9ba03d9b879147e2ab2619125df01257696 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 21 Sep 2026 17:36:28 +0200 Subject: [PATCH 06/86] Remember everyone who joins a workspace in a people directory A person's id now resolves to a name and face after they leave. The collab worker keeps a `people` table (schema v2, migrated from v1), upserts it on join and identity change, sends it in `welcome.people`, and announces new or changed profiles with a `people` message. The client store merges the directory with live participants. Co-Authored-By: Claude Fable 5.1 --- client/features/collab/store.test.ts | 24 +++++++++- client/features/collab/store.ts | 29 +++++++++++- lib/collab/types.ts | 9 ++-- server/collab/service.test.ts | 54 ++++++++++++++++++++++ server/collab/service.ts | 13 +++++- server/collab/storage.test.ts | 54 +++++++++++++++++++++- server/collab/storage.ts | 67 +++++++++++++++++++++++++++- 7 files changed, 238 insertions(+), 12 deletions(-) diff --git a/client/features/collab/store.test.ts b/client/features/collab/store.test.ts index a93e5a2d..257c73c8 100644 --- a/client/features/collab/store.test.ts +++ b/client/features/collab/store.test.ts @@ -17,7 +17,8 @@ function fixture(entries: Record = {}) { version: 1, connectionId: crypto.randomUUID(), identity: IDENTITY, - participants: [] + participants: [], + people: [] }) welcome() const release = store.acquireScope(SCOPE) @@ -285,4 +286,25 @@ describe('collab client state', () => { }) await result }) + + test('the people directory merges welcome, live profiles and later announcements, and survives a disconnect', () => { + const store = new CollabStore() + const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' } + const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' } + store.receive({ + type: 'welcome', + version: 1, + connectionId: 'c1', + identity: null, + participants: [{ connectionId: 'c2', identity: ken, location: null, presence: [] }], + people: [ada] + }) + expect(store.getSnapshot().people).toEqual({ ada, ken }) + const renamed = { ...ada, name: 'Ada L' } + store.receive({ type: 'people', people: [renamed] }) + expect(store.getSnapshot().people.ada).toEqual(renamed) + store.disconnect() + expect(store.getSnapshot().participants).toEqual([]) + expect(store.getSnapshot().people).toEqual({ ada: renamed, ken }) + }) }) diff --git a/client/features/collab/store.ts b/client/features/collab/store.ts index 6a38851f..94a17170 100644 --- a/client/features/collab/store.ts +++ b/client/features/collab/store.ts @@ -17,6 +17,8 @@ export type CollabConnectionState = { status: 'connecting' | 'connected' | 'disconnected' connectionId: string | null participants: CollabParticipant[] + // The people directory by id: everyone who has joined, online or not. + people: Readonly> pendingCount: number error: string | null } @@ -64,6 +66,16 @@ function applyOperations( return next } +function mergePeople( + current: Readonly>, + identities: readonly CollabIdentity[] +): Readonly> { + if (!identities.length) return current + const next = { ...current } + for (const identity of identities) next[identity.id] = identity + return next +} + function isJson(value: unknown): value is CollabJsonValue { if (value === null || typeof value === 'string' || typeof value === 'boolean') return true if (typeof value === 'number') return Number.isFinite(value) @@ -80,6 +92,7 @@ export class CollabStore { status: 'connecting', connectionId: null, participants: [], + people: {}, pendingCount: 0, error: null } @@ -222,6 +235,11 @@ export class CollabStore { status: 'connected', connectionId: message.connectionId, participants: message.participants, + // An older server sends no directory; live profiles still resolve. + people: mergePeople(this.state.people, [ + ...(message.people ?? []), + ...message.participants.map(participant => participant.identity) + ]), error: null }) for (const [name, scope] of this.scopes) { @@ -238,7 +256,16 @@ export class CollabStore { break } case 'participants': - this.publish({ participants: message.participants }) + this.publish({ + participants: message.participants, + people: mergePeople( + this.state.people, + message.participants.map(participant => participant.identity) + ) + }) + break + case 'people': + this.publish({ people: mergePeople(this.state.people, message.people) }) break case 'snapshot': { const scope = this.scope(message.scope) diff --git a/lib/collab/types.ts b/lib/collab/types.ts index 99e29a9b..f286e088 100644 --- a/lib/collab/types.ts +++ b/lib/collab/types.ts @@ -13,11 +13,6 @@ export type CollabIdentity = { color: string } -export type CollabCapability = { - enabled: boolean - referencePath?: string -} - export type CollabLocation = { page: string; title?: string } export type CollabActor = { id: string; kind: 'user' | 'agent' | 'system'; onBehalfOf?: string } @@ -77,8 +72,12 @@ export type CollabServerMessage = connectionId: string identity: CollabIdentity | null participants: CollabParticipant[] + // Everyone who has joined this workspace, so an id still resolves to a + // name and face after that person has left. + people: CollabIdentity[] } | { type: 'participants'; participants: CollabParticipant[] } + | { type: 'people'; people: CollabIdentity[] } | ({ type: 'snapshot'; subscriptionId: string } & CollabScopeSnapshot) | { type: 'update' diff --git a/server/collab/service.test.ts b/server/collab/service.test.ts index 517fede8..f112008a 100644 --- a/server/collab/service.test.ts +++ b/server/collab/service.test.ts @@ -206,4 +206,58 @@ describe('collab service', () => { ).toThrow('Reconnect') expect(() => service.receive('missing', { type: 'ping' })).toThrow('Join') }) + + test('the people directory outlives presence and announces new or changed profiles', () => { + const ada = { id: 'ada', name: 'ada', color: '#336699' } + const ken = { id: 'ken', name: 'ken', color: '#336699' } + const announced = () => + messages.flatMap(item => + item.message.type === 'people' + ? [{ to: item.connectionId, people: item.message.people }] + : [] + ) + const welcome = (connectionId: string) => { + const found = messages.find( + item => item.connectionId === connectionId && item.message.type === 'welcome' + )?.message + if (found?.type !== 'welcome') throw new Error('No welcome') + return found + } + join('a', 'ada') + expect(welcome('a').people).toEqual([ada]) + expect(announced()).toEqual([]) + + messages.length = 0 + join('b', 'ken') + expect(announced()).toEqual([{ to: 'a', people: [ken] }]) + expect( + welcome('b') + .people.map(person => person.id) + .sort() + ).toEqual(['ada', 'ken']) + + // Leaving removes presence, not the directory entry; anonymous readers get it too. + service.leave('a') + messages.length = 0 + service.receive('anon', { type: 'join', version: 1, identity: null, anonymousId: 'tab' }) + expect(welcome('anon').participants.map(person => person.identity.id)).toEqual(['ken']) + expect( + welcome('anon') + .people.map(person => person.id) + .sort() + ).toEqual(['ada', 'ken']) + + // The same profile rejoining announces nothing; a changed profile reaches everyone. + messages.length = 0 + join('c', 'ada') + expect(announced()).toEqual([]) + const renamed = { ...ada, name: 'Ada L' } + service.receive('c', { type: 'identity', identity: renamed }) + expect( + announced() + .map(item => item.to) + .sort() + ).toEqual(['anon', 'b', 'c']) + expect(announced()[0]?.people).toEqual([renamed]) + }) }) diff --git a/server/collab/service.ts b/server/collab/service.ts index abb23670..722750f4 100644 --- a/server/collab/service.ts +++ b/server/collab/service.ts @@ -54,6 +54,14 @@ export class CollabService { for (const id of this.clients.keys()) this.emit(id, message) } + // Remembers a profile in the people directory and tells everyone else when + // it is new or changed, so ids keep resolving after the person leaves. + private rememberPerson(identity: CollabIdentity, except?: string) { + if (!this.storage.upsertPerson(identity)) return + const message: CollabServerMessage = { type: 'people', people: [identity] } + for (const id of this.clients.keys()) if (id !== except) this.emit(id, message) + } + private scheduleParticipants() { this.presenceTimer ??= setTimeout(() => this.publishParticipants(), 50) } @@ -77,12 +85,14 @@ export class CollabService { : null, subscriptions: new Map() }) + if (identity) this.rememberPerson(identity, connectionId) this.emit(connectionId, { type: 'welcome', version: 1, connectionId, identity, - participants: this.participants() + participants: this.participants(), + people: this.storage.listPeople() }) this.publishParticipants() return @@ -96,6 +106,7 @@ export class CollabService { if (!participant || message.identity.id !== actor.id) throw new Error('Reconnect to change identity') participant.identity = cleanIdentity(message.identity) + this.rememberPerson(participant.identity) this.publishParticipants() return } diff --git a/server/collab/storage.test.ts b/server/collab/storage.test.ts index 13e636d9..78a11581 100644 --- a/server/collab/storage.test.ts +++ b/server/collab/storage.test.ts @@ -292,9 +292,59 @@ describe('collab storage', () => { ).toBe('delete') expect( inspection.query<{ user_version: number }, []>('PRAGMA user_version').get()?.user_version - ).toBe(1) - inspection.exec('PRAGMA user_version = 2') + ).toBe(2) + inspection.exec('PRAGMA user_version = 3') inspection.close() expect(() => openCollabStorage(path)).toThrow('newer version') }) + + test('the people directory keeps the latest profile per person and survives reopening', () => { + expect(storage.listPeople()).toEqual([]) + const ada = { id: 'ada', name: 'Ada', color: '#f59e0b' } + const ken = { id: 'ken', name: 'Ken', color: '#3b82f6' } + expect(storage.upsertPerson(ada)).toBe(true) + expect(storage.upsertPerson(ada)).toBe(false) + timestamp += 1000 + expect(storage.upsertPerson(ken)).toBe(true) + timestamp += 1000 + const renamed = { ...ada, name: 'Ada L', avatar: 'data:image/png;base64,AAAA' } + expect(storage.upsertPerson(renamed)).toBe(true) + storage.close() + storage = openCollabStorage(path, { now: () => timestamp }) + expect(storage.listPeople()).toEqual([renamed, ken]) + expect(() => storage.upsertPerson({ ...ada, avatar: 'x'.repeat(9000) })).toThrow('8 KiB') + expect(storage.listPeople()).toEqual([renamed, ken]) + }) + + test('a version 1 database gains the people directory and keeps its data', () => { + const legacyPath = join(directory, 'legacy.sqlite') + const legacy = new Database(legacyPath, { create: true }) + legacy.exec(` + CREATE TABLE scopes (scope TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0); + CREATE TABLE entries ( + scope TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, PRIMARY KEY (scope, key) + ); + CREATE TABLE receipts ( + actor_id TEXT NOT NULL, operation_id TEXT NOT NULL, request_hash TEXT NOT NULL, + scope TEXT NOT NULL, revision INTEGER NOT NULL, committed_at INTEGER NOT NULL, + PRIMARY KEY (actor_id, operation_id) + ); + INSERT INTO scopes(scope, revision) VALUES ('shared:tasks', 3); + INSERT INTO entries(scope, key, value_json) VALUES ('shared:tasks', 'task/1/title', '"Ship"'); + PRAGMA user_version = 1; + `) + legacy.close() + const upgraded = openCollabStorage(legacyPath) + try { + expect(upgraded.snapshot('shared:tasks')).toEqual({ + scope: 'shared:tasks', + revision: 3, + entries: { 'task/1/title': 'Ship' } + }) + expect(upgraded.listPeople()).toEqual([]) + expect(upgraded.upsertPerson({ id: 'ada', name: 'Ada', color: '#f59e0b' })).toBe(true) + } finally { + upgraded.close() + } + }) }) diff --git a/server/collab/storage.ts b/server/collab/storage.ts index 06da9eea..1cea5a15 100644 --- a/server/collab/storage.ts +++ b/server/collab/storage.ts @@ -2,7 +2,9 @@ import { Database } from 'bun:sqlite' import { existsSync, mkdirSync } from 'node:fs' import { dirname } from 'node:path' +import { COLLAB_MAX_AVATAR_BYTES } from '@/lib/collab/protocol' import type { + CollabIdentity, CollabJsonValue, CollabMutationResult, CollabOperation, @@ -19,11 +21,12 @@ export const COLLAB_STORAGE_LIMITS = { identifierBytes: 256, keyBytes: 1024, jsonDepth: 32, - receiptTtlMs: 24 * 60 * 60 * 1000 + receiptTtlMs: 24 * 60 * 60 * 1000, + people: 500 } as const const PRUNE_INTERVAL_MS = 60_000 -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 2 export class CollabStorageError extends Error { constructor( @@ -44,6 +47,10 @@ export type CollabStorage = { operations: readonly CollabOperation[] ) => CollabMutationResult lookupReceipts: (actorId: string, operationIds: readonly string[]) => CollabReceipt[] + // The people directory: the latest profile of everyone who has joined. + // Returns whether the stored profile changed. + upsertPerson: (identity: CollabIdentity) => boolean + listPeople: () => CollabIdentity[] pruneReceipts: () => void exportTo: (path: string) => void close: () => void @@ -182,6 +189,20 @@ export function openCollabStorage(path: string, options: CollabStorageOptions = `) })() } + if (version < 2) { + db.transaction(() => { + db.exec(` + CREATE TABLE people ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL, + avatar TEXT, + updated_at INTEGER NOT NULL + ); + PRAGMA user_version = 2; + `) + })() + } } catch (error) { db.close() throw error @@ -212,6 +233,17 @@ export function openCollabStorage(path: string, options: CollabStorageOptions = const insertReceipt = db.query(`INSERT INTO receipts (actor_id, operation_id, request_hash, scope, revision, committed_at) VALUES (?, ?, ?, ?, ?, ?)`) const deleteExpired = db.query('DELETE FROM receipts WHERE committed_at <= ?') + const selectPerson = db.query<{ name: string; color: string; avatar: string | null }, [string]>( + 'SELECT name, color, avatar FROM people WHERE id = ?' + ) + const writePerson = db.query(`INSERT INTO people(id, name, color, avatar, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, color = excluded.color, + avatar = excluded.avatar, updated_at = excluded.updated_at`) + const recentPeople = db.query< + { id: string; name: string; color: string; avatar: string | null }, + [number] + >('SELECT id, name, color, avatar FROM people ORDER BY updated_at DESC, id LIMIT ?') let closed = false function pruneReceipts() { @@ -310,6 +342,37 @@ export function openCollabStorage(path: string, options: CollabStorageOptions = : { operationId, status: 'unknown' } }) }, + upsertPerson(identity) { + identifier(identity.id, 'person id', COLLAB_STORAGE_LIMITS.identifierBytes) + identifier(identity.name, 'person name', COLLAB_STORAGE_LIMITS.identifierBytes) + identifier(identity.color, 'person color', 64) + const avatar = identity.avatar ?? null + if ( + avatar !== null && + (typeof avatar !== 'string' || Buffer.byteLength(avatar) > COLLAB_MAX_AVATAR_BYTES) + ) { + fail('LIMIT_EXCEEDED', 'Avatar exceeds 8 KiB') + } + const known = selectPerson.get(identity.id) + if ( + known && + known.name === identity.name && + known.color === identity.color && + known.avatar === avatar + ) { + return false + } + writePerson.run(identity.id, identity.name, identity.color, avatar, now()) + return true + }, + listPeople() { + return recentPeople.all(COLLAB_STORAGE_LIMITS.people).map(row => ({ + id: row.id, + name: row.name, + color: row.color, + ...(row.avatar ? { avatar: row.avatar } : {}) + })) + }, pruneReceipts, exportTo(destination) { if (existsSync(destination)) fail('EXPORT_EXISTS', 'Shared storage export already exists') From 5e1136e276a21dc473e838a37455d994fc2048db Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 21 Sep 2026 17:36:46 +0200 Subject: [PATCH 07/86] Add id-based people components on a swappable collab backend Applets refer to a person by id (`id`, or `ids` for several) and the components resolve name, face, and presence themselves: `Person` (with `avatarOnly` and `showStatus`), `Facepile`, `Cursor`, `PresenceFrame`, `PresenceGutter`, and the `usePerson` hook. `PresenceFrame`, and with it `PresenceField` and `Selection`, hugs the single element it wraps and copies its corner radius. `SyncStatus` is gone; applets render `isSaving` and `error` from the shared-state hooks. Hooks and components now talk to a `CollabBackend` contract from context instead of the socket client. The workspace provides the live backend; `createFakeBackend` is an in-memory room behind the same store. `/dev/collab-kit` runs on it: every component with made-up people, then the connected components and the exported hooks against scripted participants. The registry Avatar gains an `xs` size and expresses its default size as a variant so applet-scoped CSS cannot override it. Co-Authored-By: Claude Fable 5.1 --- client/features/collab/DevCollabKitPage.tsx | 951 ++++++++++++++++++ .../collab/WorkspaceCollabControls.tsx | 29 +- client/features/collab/backend.ts | 70 ++ client/features/collab/components.tsx | 182 +--- client/features/collab/fake-backend.test.ts | 70 ++ client/features/collab/fake-backend.ts | 159 +++ client/features/collab/hooks.tsx | 97 +- client/features/collab/index.ts | 23 +- client/features/collab/people.test.ts | 29 +- client/features/collab/people.ts | 34 +- client/features/collab/primitives.tsx | 431 ++++++++ client/features/dev/DevIndexPage.tsx | 6 + client/features/dev/DevRoutes.tsx | 8 + server/collab/applet-module.ts | 7 +- server/collab/skill/collab-env.d.ts | 56 +- server/collab/skill/index.test.ts | 12 +- .../collab/skill/references/COLLABORATIVE.md | 36 +- ui-components/avatar.tsx | 9 +- ui-components/docs/avatar.md | 10 +- 19 files changed, 2018 insertions(+), 201 deletions(-) create mode 100644 client/features/collab/DevCollabKitPage.tsx create mode 100644 client/features/collab/backend.ts create mode 100644 client/features/collab/fake-backend.test.ts create mode 100644 client/features/collab/fake-backend.ts create mode 100644 client/features/collab/primitives.tsx diff --git a/client/features/collab/DevCollabKitPage.tsx b/client/features/collab/DevCollabKitPage.tsx new file mode 100644 index 00000000..60eacc9b --- /dev/null +++ b/client/features/collab/DevCollabKitPage.tsx @@ -0,0 +1,951 @@ +import { useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' + +import { + IconChevronDown, + IconCursorText, + IconPhoto, + IconPlus, + IconPointer +} from '@tabler/icons-react' +import { Link } from 'wouter' + +import { Button } from '@/client/components/ui/button' +import { Checkbox } from '@/client/components/ui/checkbox' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from '@/client/components/ui/collapsible' +import { Input } from '@/client/components/ui/input' +import { Switch } from '@/client/components/ui/switch' +import { Textarea } from '@/client/components/ui/textarea' +import { cn } from '@/client/lib/cn' +import type { + CollabIdentity, + CollabJsonValue, + CollabParticipant, + CollabPresenceRegistration +} from '@/lib/collab/types' +import { Card, CardDescription, CardHeader, CardTitle } from '@/ui-components/card' + +import { Activity, Cursors, PresenceField, Selection } from './components' +import { createFakeBackend } from './fake-backend' +import type { FakeCollabBackend } from './fake-backend' +import { + AppletCollabProvider, + CollabBackendProvider, + presenceChannels, + useOthers, + usePerson, + usePresence, + useSelf, + useSharedState, + useSharedStore +} from './hooks' +import { Cursor, Facepile, Person, PresenceFrame, PresenceGutter } from './primitives' + +// Made-up people in an in-memory room (fake-backend.ts), so everything on this +// page runs its real code with no server: ids resolve, presence travels, and +// shared state saves. Some people are connected, the rest are only remembered. +const fig: CollabIdentity = { id: 'fig', name: 'Fig', color: '#f59e0b' } +const alex: CollabIdentity = { id: 'alex', name: 'Alex Hao', color: '#3b82f6' } +const andrea: CollabIdentity = { id: 'andrea', name: 'Andrea Lim', color: '#ec4899' } +const pierre: CollabIdentity = { id: 'pierre', name: 'Pierre', color: '#f97316' } +const DIRECTORY: CollabIdentity[] = [ + fig, + alex, + andrea, + { id: 'david', name: 'David Tibbitts', color: '#10b981' }, + { id: 'lauren', name: 'Lauren Oliveri', color: '#06b6d4' }, + { id: 'monica', name: 'Monica Perez', color: '#8b5cf6' }, + pierre, + { id: 'ada', name: 'Ada', color: '#84cc16' } +] +// The person looking at this page. +const you: CollabIdentity = { id: 'you', name: 'You', color: '#8b5cf6' } +const PAGE = 'kit' +const APPLET = { kind: 'view', name: 'kit' } as const +const SURFACE = `${APPLET.kind}:${APPLET.name}` +const SCOPE = 'demo:kit' + +type Point = { x: number; y: number } +type BotState = { cursor: Point; focusing: boolean } +function registration( + who: string, + channel: string, + value: CollabJsonValue +): CollabPresenceRegistration { + return { registrationId: `${who}:${channel}`, surface: SURFACE, channel, value } +} +// Everyone else in the room. Fig and Alex are on this page, Pierre is on +// another one, and Andrea has every tab hidden. +function everyoneElse({ cursor, focusing }: BotState): CollabParticipant[] { + return [ + { + connectionId: 'bot-fig', + identity: fig, + location: { page: PAGE }, + presence: [ + registration('fig', presenceChannels.cursor('lab'), cursor), + registration('fig', presenceChannels.custom('mood'), 'Reviewing'), + ...(focusing ? [registration('fig', presenceChannels.field('launch-note'), true)] : []) + ] + }, + { + connectionId: 'bot-alex', + identity: alex, + location: { page: PAGE }, + presence: [ + registration('alex', presenceChannels.selection('task-1'), true), + registration('alex', presenceChannels.custom('mood'), 'Ready') + ] + }, + { + connectionId: 'bot-pierre', + identity: pierre, + location: { page: 'view:board' }, + presence: [] + }, + { connectionId: 'bot-andrea', identity: andrea, location: null, presence: [] } + ] +} +const START: BotState = { cursor: { x: 140, y: 60 }, focusing: true } + +// Fig's pointer wanders and Fig steps in and out of the note field. +function useBots(room: FakeCollabBackend) { + useEffect(() => { + let at = START.cursor + let to = { x: 360, y: 150 } + let tick = 0 + const timer = setInterval(() => { + tick++ + const dx = to.x - at.x + const dy = to.y - at.y + if (Math.hypot(dx, dy) < 6) to = { x: 40 + Math.random() * 480, y: 30 + Math.random() * 190 } + else at = { x: at.x + dx * 0.12, y: at.y + dy * 0.12 } + room.setOthers(everyoneElse({ cursor: at, focusing: Math.floor(tick / 40) % 2 === 0 })) + }, 100) + return () => clearInterval(timer) + }, [room]) +} + +const IDS = DIRECTORY.map(person => person.id) +const WALKERS = ['pierre', 'fig', 'alex', 'andrea'] +const TASKS = ['Confirm the venue', 'Draft the announcement', 'Book the studio'] +const BLOCKS = [ + 'Q3 launch checklist', + 'Confirm the venue and the catering count by Friday.', + 'Draft the announcement post and the email to customers.', + 'Collect screenshots from the design team for the press kit.', + 'Book the recording studio for the walkthrough video.' +] + +// Approximate applet usage, one snippet per section. +const CODE = { + person: [ + "import { Person, usePerson } from 'moi/collab'", + '', + '// A person is an id. Name, face, and the presence dot resolve on their own,', + '// also for people who have left. An unknown id renders as "Unknown person".', + '', + '', + '', + '', + '// Just the face, in four sizes.', + '', + '', + '// Compact keeps one line and puts the detail inline.', + '', + '', + '// The green dot means the person has the workspace open in a visible tab', + '// right now. It is on by default; turn it off where it adds nothing.', + '', + '', + '// The same lookup as a hook: { id, identity | null, status }.', + 'const { identity, status } = usePerson(task.assigneeId)' + ].join('\n'), + facepile: [ + "import { Facepile, useOthers } from 'moi/collab'", + '', + '// Deduplicates, shows `max` faces (3 by default), then a count.', + '', + '', + '', + '// The green dots are off in a pile. Turn them on when they matter.', + '', + '', + "const others = useOthers({ scope: 'workspace' })", + ' other.identity.id)} size="xs" />' + ].join('\n'), + frames: [ + "import { PresenceFrame, PresenceField, Selection } from 'moi/collab'", + '', + '// Wrap anything and say who is there. The frame hugs the element inside and', + '// takes its corner radius. The first person’s color draws it.', + '', + ' setNote(event.target.value)} />', + '', + '', + ' ', + '', + '', + ' ', + '', + '', + '// Connected: the workspace reports who is focused on or has selected a target.', + '', + ' setNote(event.target.value)} />', + '', + '', + ' ', + '' + ].join('\n'), + gutter: [ + "import { PresenceGutter } from 'moi/collab'", + '', + '// A face beside the block each person is on. Blocks carry data-collab-target;', + '// a face glides to the next block instead of reappearing there.', + '', + ' {blocks.map(block => (', + '

    ', + ' {block.text}', + '

    ', + ' ))}', + '' + ].join('\n'), + connected: [ + "import { Activity, Cursors, PresenceField, Selection } from 'moi/collab'", + '', + '// No data props: each one reports you and shows everyone else by itself.', + '', + '', + ' ', + ' setNote(event.target.value)} />', + ' ', + ' ', + ' ', + ' ', + '' + ].join('\n'), + hooks: [ + "import { useSelf, useOthers, usePerson, usePresence, useSharedState, useSharedStore } from 'moi/collab'", + '', + 'const self = useSelf() // participant | null', + "const others = useOthers({ scope: 'workspace' }) // connections, not people", + "const { identity, status } = usePerson('alex')", + '', + '// Temporary, gone when you leave.', + "const mood = usePresence('mood', 'Exploring')", + "mood.setValue('Ready')", + 'mood.others // [{ participant, value }]', + '', + '// Saved for everyone.', + "const note = useSharedState('note', { scope: 'demo:kit', defaultValue: '' })", + "await note.setValue('Ship it') // { status: 'committed', revision }", + "const tasks = useSharedStore('task/', { scope: 'demo:kit' })", + "await tasks.set('42/title', 'Ship demo')" + ].join('\n'), + cursors: [ + "import { Cursor, Cursors } from 'moi/collab'", + '', + '// One pointer at x, y inside a relative parent. Updates glide over 100 ms,', + '// so 20 updates a second look continuous.', + '
    ', + ' {pointers.map(pointer => (', + ' ', + ' ))}', + '
    ', + '', + '// Connected: everyone else’s pointer on this surface. Pointers over a', + '// data-collab-target element follow that element on every screen.', + '', + ' ', + '' + ].join('\n') +} + +type CodeExampleProps = { code: string } +function CodeExample({ code }: CodeExampleProps) { + return ( + + + } + > + + Code + + +
    +          {code}
    +        
    +
    +
    + ) +} + +type SectionProps = { title: string; hint: string; code: string; children: ReactNode } +function Section({ title, hint, code, children }: SectionProps) { + return ( +
    +
    +

    {title}

    +

    {hint}

    +
    + {children} + +
    + ) +} + +function PeopleDemo() { + const [selected, setSelected] = useState('alex') + return ( +
    +
    + + + + + +
    +
    +
    +

    Select a person

    + {IDS.slice(0, 6).map(id => ( + + ))} +
    +
    + + + + + + +
    +
    +
    +

    Compact

    +
    + + + + +
    +

    + Last edited by a minute ago. +

    +
    +
    + ) +} + +const TEXT_ICON = +const POINTER_ICON = + +type FrameExampleProps = { label: string; children: ReactNode } +function FrameExample({ label, children }: FrameExampleProps) { + return ( +
    + {/* Room above the element for the name tag. */} +
    {children}
    +

    {label}

    +
    + ) +} + +// None of the frames below is told a size or a radius. +function FramesDemo() { + const [shown, setShown] = useState(true) + const [note, setNote] = useState('Q3 launch checklist') + const [details, setDetails] = useState('Confirm the venue and the catering count by Friday.') + const [done, setDone] = useState([true, false, false]) + const at = (...ids: string[]) => (shown ? ids : []) + return ( +
    +
    + Frames + +
    +
    + + + setNote(event.target.value)} + /> + + + + + + + + + +