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
JavaScript
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
};