UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

401 lines (398 loc) 11.6 kB
import { TrellisVcsEngine, init_engine } from "./chunk-O56VT7VP.js"; import { IdbOpLog } from "./chunk-MPXUVGT3.js"; import { JsonOpLog, init_op_log } from "./chunk-Q4FKTPX4.js"; import { PartyKitRoomTransport, TrellisVcsSyncPeer } from "./chunk-44NB2G5E.js"; import { PROVENANCE, init_canonical_op } from "./chunk-RUMOVKR4.js"; import { Signal } from "./chunk-UBGUDMUV.js"; // src/client/vcs-client.ts init_engine(); init_op_log(); init_canonical_op(); var DEFAULT_SYNC_STATUS = { connected: false, pending: 0, synced: false, lastSyncAt: null, lastError: null }; var MemoryOpLog = class { ops = []; hashes = /* @__PURE__ */ new Set(); load() { } append(op) { if (this.hashes.has(op.hash)) return; this.ops.push(op); this.hashes.add(op.hash); } readAll() { return [...this.ops]; } getLastOp() { return this.ops[this.ops.length - 1]; } count() { return this.ops.length; } }; var TrellisClient = class _TrellisClient { _engine; _syncPeer; _peerId = ""; _opLog; _roomId = "room"; _pushDebounceMs = 200; _snapshotMaxOps = 500; _pushTimer; _closed = false; /** Raw causal log. Updated on every local or remote op. */ _ops = new Signal([]); /** Sync connection state. */ _syncStatus = new Signal({ ...DEFAULT_SYNC_STATUS }); _opHandlers = []; _topicSubs = /* @__PURE__ */ new Map(); constructor() { } /** Factory — opens the repo, loads ops, and optionally connects to sync. */ static async open(opts) { const client = new _TrellisClient(); client._peerId = opts.agentId ?? `client:${crypto.randomUUID()}`; client._opLog = client._createOpLog(opts); if ("load" in client._opLog && typeof client._opLog.load === "function") { const maybePromise = client._opLog.load(); if (maybePromise instanceof Promise) await maybePromise; } client._engine = new TrellisVcsEngine({ rootPath: opts.repo, agentId: client._peerId, opLog: client._opLog, provenance: PROVENANCE.sdk }); client._engine.open(); client._refreshState(); if (opts.sync) { client._roomId = opts.sync.roomId ?? "room"; client._pushDebounceMs = opts.sync.pushDebounceMs ?? 200; client._snapshotMaxOps = opts.sync.snapshotMaxOps ?? 500; const transport = client._createTransport(opts.sync, client); client._syncPeer = new TrellisVcsSyncPeer({ peerId: client._peerId, engine: client._engine, transport, onIntegrate: () => { client._refreshState(); client._setSyncStatus({ synced: true, lastError: null }); } }); if (opts.sync.connectOnOpen !== false) { try { await client._connectAndCatchUp(); } catch { } } } return client; } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- /** Subscribe to reactive topics. Callback receives current value immediately. */ subscribe(topic, callback) { if (!this._topicSubs.has(topic)) { this._topicSubs.set(topic, /* @__PURE__ */ new Set()); } const subs = this._topicSubs.get(topic); subs.add(callback); const current = this._getTopicValue(topic); if (current !== void 0) { try { callback(current); } catch { } } return () => { subs.delete(callback); }; } /** Listen for raw op events (local or remote). */ on(event, handler) { this._opHandlers.push(handler); return () => { const idx = this._opHandlers.indexOf(handler); if (idx >= 0) this._opHandlers.splice(idx, 1); }; } /** Create an issue and emit reactive updates. */ async createIssue(title, opts) { const createOpts = { ...opts }; if (opts?.lane) { createOpts.laneId = opts.lane; } const op = await this._engine.createIssue(title, createOpts); this._refreshState(); this._schedulePush(); return op; } /** Force a push/pull sync with the room. */ async sync() { if (!this._syncPeer) { throw new Error( "No sync configured. Pass sync.url to TrellisClient.open() to enable multiplayer sync." ); } await this._connectAndCatchUp(); } /** Close (complete) an issue. */ async closeIssue(id, opts) { const result = await this._engine.closeIssue(id, opts); if (result.op) { this._refreshState(); this._schedulePush(); } return result.op ?? null; } /** Reopen a closed issue. */ async reopenIssue(id) { const op = await this._engine.reopenIssue(id); this._refreshState(); this._schedulePush(); return op; } /** Read current ops without subscribing. */ getOps() { return this._engine.getOps(); } /** Read current issues without subscribing. */ listIssues() { return this._engine.listIssues(); } /** Clean up resources. */ close() { if (this._closed) return; this._closed = true; if (this._pushTimer !== void 0) { clearTimeout(this._pushTimer); this._pushTimer = void 0; } this._syncPeer?.close(); if ("close" in this._opLog && typeof this._opLog.close === "function") { void this._opLog.close(); } this._engine.stop(); this._setSyncStatus({ connected: false }); } // ------------------------------------------------------------------------- // Reactive accessors (for framework adapters) // ------------------------------------------------------------------------- /** Signal exposing the full causal op log. */ get opsSignal() { return this._ops; } /** Signal exposing sync connection status. */ get syncStatusSignal() { return this._syncStatus; } /** Underlying VCS engine (for advanced sync wiring and queries). */ get engine() { return this._engine; } // ------------------------------------------------------------------------- // Private // ------------------------------------------------------------------------- _createTransport(sync, client) { if (sync.transport) return sync.transport; if (!sync.url) { throw new Error( "sync.transport or sync.url is required when sync is configured." ); } return new PartyKitRoomTransport({ peerId: this._peerId, roomUrl: sync.url, auth: sync.auth, roomId: sync.roomId, reconnect: sync.reconnect !== false, onReconnect: () => client._onTransportReconnect(), onDisconnect: () => client._setSyncStatus({ connected: false }) }); } _createOpLog(opts) { switch (opts.persist) { case "indexeddb": return new IdbOpLog({ dbName: opts.repo }); case "memory": return new MemoryOpLog(); case "opfs": throw new Error( 'OPFS persistence not yet implemented. Use "indexeddb" or "memory".' ); default: if (typeof globalThis !== "undefined" && "indexedDB" in globalThis) { return new IdbOpLog({ dbName: opts.repo }); } return new JsonOpLog(`${opts.repo}/.trellis/ops.json`); } } async _connectTransport() { const transport = this._syncPeer?.getTransport(); if (transport && "connect" in transport && typeof transport.connect === "function") { await transport.connect(); this._setSyncStatus({ connected: true, lastError: null }); } else if (transport) { this._setSyncStatus({ connected: true, lastError: null }); } } async _connectAndCatchUp() { if (!this._syncPeer) return; this._setSyncStatus({ pending: this._syncStatus.value.pending + 1, synced: false }); try { await this._connectTransport(); await this._requestSnapshot(); const result = await this._syncPeer.syncWith(this._roomId); this._setSyncStatus({ connected: true, synced: result.rejected === 0, lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: result.rejected > 0 ? `${result.rejected} op(s) rejected during sync` : null }); } catch (err) { const message = err instanceof Error ? err.message : String(err); this._setSyncStatus({ connected: false, synced: false, lastError: message }); throw err; } finally { this._setSyncStatus({ pending: Math.max(0, this._syncStatus.value.pending - 1) }); } } async _onTransportReconnect() { if (this._closed || !this._syncPeer) return; try { await this._connectAndCatchUp(); } catch { } } async _requestSnapshot() { if (!this._syncPeer || this._snapshotMaxOps <= 0) return; await this._syncPeer.requestSnapshot(this._roomId, this._snapshotMaxOps); await new Promise((resolve) => { queueMicrotask(() => resolve()); }); } _schedulePush() { if (!this._syncPeer || this._pushDebounceMs <= 0 || this._closed) return; if (this._pushTimer !== void 0) { clearTimeout(this._pushTimer); } this._pushTimer = setTimeout(() => { this._pushTimer = void 0; void this._pushToRoom(); }, this._pushDebounceMs); } async _pushToRoom() { if (!this._syncPeer || this._closed) return; this._setSyncStatus({ pending: this._syncStatus.value.pending + 1, synced: false }); try { await this._connectTransport(); const result = await this._syncPeer.pushTo(this._roomId); this._setSyncStatus({ connected: true, synced: result.remoteRejected.length === 0, lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: result.remoteRejected.length > 0 ? `${result.remoteRejected.length} op(s) rejected by room` : null }); } catch (err) { const message = err instanceof Error ? err.message : String(err); this._setSyncStatus({ connected: false, synced: false, lastError: message }); } finally { this._setSyncStatus({ pending: Math.max(0, this._syncStatus.value.pending - 1) }); } } _refreshState() { const ops = this._engine.getOps(); const previousHashes = new Set(this._ops.value.map((o) => o.hash)); this._ops.value = ops; for (const op of ops) { if (!previousHashes.has(op.hash)) { this._emitOp(op); } } this._broadcastTopic("ops", ops); this._broadcastTopic("issues", this._engine.listIssues()); this._broadcastTopic("milestones", this._engine.listMilestones()); this._broadcastTopic("branches", this._engine.listBranches()); } _emitOp(op) { for (const h of this._opHandlers) { try { h(op); } catch { } } } _broadcastTopic(topic, data) { const subs = this._topicSubs.get(topic); if (!subs) return; for (const fn of subs) { try { fn(data); } catch { } } } _getTopicValue(topic) { switch (topic) { case "ops": return this._ops.value; case "syncStatus": return this._syncStatus.value; case "issues": return this._engine.listIssues(); case "milestones": return this._engine.listMilestones(); case "branches": return this._engine.listBranches(); default: return void 0; } } _setSyncStatus(partial) { this._syncStatus.value = { ...this._syncStatus.value, ...partial }; this._broadcastTopic("syncStatus", this._syncStatus.value); } }; export { TrellisClient };