trellis 1.0.7 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +533 -82
- package/bin/trellis.mjs +2 -0
- package/dist/cli/index.js +4718 -0
- package/dist/core/index.js +12 -0
- package/dist/decisions/index.js +19 -0
- package/dist/embeddings/index.js +43 -0
- package/dist/index-1j1anhmr.js +4038 -0
- package/dist/index-3s0eak0p.js +1556 -0
- package/dist/index-8pce39mh.js +272 -0
- package/dist/index-a76rekgs.js +67 -0
- package/dist/index-cy9k1g6v.js +684 -0
- package/dist/index-fd4e26s4.js +69 -0
- package/dist/{store/eav-store.js → index-gkvhzm9f.js} +4 -6
- package/dist/index-gnw8d7d6.js +51 -0
- package/dist/index-vkpkfwhq.js +817 -0
- package/dist/index.js +118 -2876
- package/dist/links/index.js +55 -0
- package/dist/transformers-m9je15kg.js +32491 -0
- package/dist/vcs/index.js +110 -0
- package/logo.png +0 -0
- package/logo.svg +9 -0
- package/package.json +79 -76
- package/src/cli/index.ts +2340 -0
- package/src/core/index.ts +35 -0
- package/src/core/kernel/middleware.ts +44 -0
- package/src/core/persist/backend.ts +64 -0
- package/src/core/store/eav-store.ts +467 -0
- package/src/decisions/auto-capture.ts +136 -0
- package/src/decisions/hooks.ts +163 -0
- package/src/decisions/index.ts +261 -0
- package/src/decisions/types.ts +103 -0
- package/src/embeddings/chunker.ts +327 -0
- package/src/embeddings/index.ts +41 -0
- package/src/embeddings/model.ts +95 -0
- package/src/embeddings/search.ts +305 -0
- package/src/embeddings/store.ts +313 -0
- package/src/embeddings/types.ts +85 -0
- package/src/engine.ts +1083 -0
- package/src/garden/cluster.ts +330 -0
- package/src/garden/garden.ts +306 -0
- package/src/garden/index.ts +29 -0
- package/src/git/git-exporter.ts +286 -0
- package/src/git/git-importer.ts +329 -0
- package/src/git/git-reader.ts +189 -0
- package/src/git/index.ts +22 -0
- package/src/identity/governance.ts +211 -0
- package/src/identity/identity.ts +224 -0
- package/src/identity/index.ts +30 -0
- package/src/identity/signing-middleware.ts +97 -0
- package/src/index.ts +20 -0
- package/src/links/index.ts +49 -0
- package/src/links/lifecycle.ts +400 -0
- package/src/links/parser.ts +484 -0
- package/src/links/ref-index.ts +186 -0
- package/src/links/resolver.ts +314 -0
- package/src/links/types.ts +108 -0
- package/src/mcp/index.ts +22 -0
- package/src/mcp/server.ts +1278 -0
- package/src/semantic/csharp-parser.ts +493 -0
- package/src/semantic/go-parser.ts +585 -0
- package/src/semantic/index.ts +34 -0
- package/src/semantic/java-parser.ts +456 -0
- package/src/semantic/python-parser.ts +659 -0
- package/src/semantic/ruby-parser.ts +446 -0
- package/src/semantic/rust-parser.ts +784 -0
- package/src/semantic/semantic-merge.ts +210 -0
- package/src/semantic/ts-parser.ts +681 -0
- package/src/semantic/types.ts +175 -0
- package/src/sync/index.ts +32 -0
- package/src/sync/memory-transport.ts +66 -0
- package/src/sync/reconciler.ts +237 -0
- package/src/sync/sync-engine.ts +258 -0
- package/src/sync/types.ts +104 -0
- package/src/vcs/blob-store.ts +124 -0
- package/src/vcs/branch.ts +150 -0
- package/src/vcs/checkpoint.ts +64 -0
- package/src/vcs/decompose.ts +469 -0
- package/src/vcs/diff.ts +409 -0
- package/src/vcs/engine-context.ts +26 -0
- package/src/vcs/index.ts +23 -0
- package/src/vcs/issue.ts +800 -0
- package/src/vcs/merge.ts +425 -0
- package/src/vcs/milestone.ts +124 -0
- package/src/vcs/ops.ts +59 -0
- package/src/vcs/types.ts +213 -0
- package/src/vcs/vcs-middleware.ts +81 -0
- package/src/watcher/fs-watcher.ts +217 -0
- package/src/watcher/index.ts +9 -0
- package/src/watcher/ingestion.ts +116 -0
- package/dist/ai/index.js +0 -688
- package/dist/cli/server.js +0 -3321
- package/dist/cli/tql.js +0 -5282
- package/dist/client/tql-client.js +0 -108
- package/dist/graph/index.js +0 -2248
- package/dist/kernel/logic-middleware.js +0 -179
- package/dist/kernel/middleware.js +0 -0
- package/dist/kernel/operations.js +0 -32
- package/dist/kernel/schema-middleware.js +0 -34
- package/dist/kernel/security-middleware.js +0 -53
- package/dist/kernel/trellis-kernel.js +0 -2239
- package/dist/kernel/workspace.js +0 -91
- package/dist/persist/backend.js +0 -0
- package/dist/persist/sqlite-backend.js +0 -123
- package/dist/query/index.js +0 -1643
- package/dist/server/index.js +0 -3309
- package/dist/workflows/index.js +0 -3160
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Engine
|
|
3
|
+
*
|
|
4
|
+
* DESIGN.md §10.5 — Peer sync protocol.
|
|
5
|
+
* Coordinates push/pull of ops between peers using a transport layer.
|
|
6
|
+
* Supports both linear (fast-forward only) and CRDT (concurrent append)
|
|
7
|
+
* branch modes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { VcsOp } from '../vcs/types.js';
|
|
11
|
+
import type {
|
|
12
|
+
SyncTransport,
|
|
13
|
+
SyncMessage,
|
|
14
|
+
SyncState,
|
|
15
|
+
PeerId,
|
|
16
|
+
BranchPolicy,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
import { reconcile, findForkPoint, type ReconcileResult } from './reconciler.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Sync Engine
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export class SyncEngine {
|
|
25
|
+
private localPeerId: string;
|
|
26
|
+
private state: SyncState;
|
|
27
|
+
private transport: SyncTransport;
|
|
28
|
+
private getLocalOps: () => VcsOp[];
|
|
29
|
+
private onOpsReceived: (ops: VcsOp[]) => void;
|
|
30
|
+
private branchPolicy: BranchPolicy;
|
|
31
|
+
|
|
32
|
+
constructor(opts: {
|
|
33
|
+
localPeerId: string;
|
|
34
|
+
transport: SyncTransport;
|
|
35
|
+
getLocalOps: () => VcsOp[];
|
|
36
|
+
onOpsReceived: (ops: VcsOp[]) => void;
|
|
37
|
+
branchPolicy?: BranchPolicy;
|
|
38
|
+
}) {
|
|
39
|
+
this.localPeerId = opts.localPeerId;
|
|
40
|
+
this.transport = opts.transport;
|
|
41
|
+
this.getLocalOps = opts.getLocalOps;
|
|
42
|
+
this.onOpsReceived = opts.onOpsReceived;
|
|
43
|
+
this.branchPolicy = opts.branchPolicy ?? { linear: true };
|
|
44
|
+
|
|
45
|
+
this.state = {
|
|
46
|
+
localPeerId: opts.localPeerId,
|
|
47
|
+
peerHeads: new Map(),
|
|
48
|
+
pendingAcks: new Set(),
|
|
49
|
+
lastSync: new Map(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Register message handler
|
|
53
|
+
this.transport.onMessage((msg) => this.handleMessage(msg));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// Public API
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Initiate a sync with a specific peer.
|
|
62
|
+
* Sends a 'have' message advertising our heads.
|
|
63
|
+
*/
|
|
64
|
+
async pushTo(peerId: string): Promise<void> {
|
|
65
|
+
const ops = this.getLocalOps();
|
|
66
|
+
const heads: Record<string, string> = {};
|
|
67
|
+
if (ops.length > 0) {
|
|
68
|
+
heads['main'] = ops[ops.length - 1].hash;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await this.transport.send(peerId, {
|
|
72
|
+
type: 'have',
|
|
73
|
+
peerId: this.localPeerId,
|
|
74
|
+
heads,
|
|
75
|
+
opCount: ops.length,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Request ops from a peer.
|
|
81
|
+
*/
|
|
82
|
+
async pullFrom(peerId: string): Promise<void> {
|
|
83
|
+
const ops = this.getLocalOps();
|
|
84
|
+
const lastHash = ops.length > 0 ? ops[ops.length - 1].hash : undefined;
|
|
85
|
+
|
|
86
|
+
await this.transport.send(peerId, {
|
|
87
|
+
type: 'want',
|
|
88
|
+
peerId: this.localPeerId,
|
|
89
|
+
wantHashes: [],
|
|
90
|
+
afterHash: lastHash,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send all our ops to a peer (full push).
|
|
96
|
+
*/
|
|
97
|
+
async sendOps(peerId: string, ops?: VcsOp[]): Promise<void> {
|
|
98
|
+
const opsToSend = ops ?? this.getLocalOps();
|
|
99
|
+
await this.transport.send(peerId, {
|
|
100
|
+
type: 'ops',
|
|
101
|
+
peerId: this.localPeerId,
|
|
102
|
+
ops: opsToSend,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reconcile our ops with a remote peer's ops.
|
|
108
|
+
*/
|
|
109
|
+
reconcileWith(remoteOps: VcsOp[]): ReconcileResult {
|
|
110
|
+
const localOps = this.getLocalOps();
|
|
111
|
+
return reconcile(localOps, remoteOps);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get current sync state.
|
|
116
|
+
*/
|
|
117
|
+
getState(): SyncState {
|
|
118
|
+
return this.state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get branch policy.
|
|
123
|
+
*/
|
|
124
|
+
getBranchPolicy(): BranchPolicy {
|
|
125
|
+
return this.branchPolicy;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Set branch policy.
|
|
130
|
+
*/
|
|
131
|
+
setBranchPolicy(policy: BranchPolicy): void {
|
|
132
|
+
this.branchPolicy = policy;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* List known peers.
|
|
137
|
+
*/
|
|
138
|
+
listPeers(): PeerId[] {
|
|
139
|
+
return this.transport.peers();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Message handling
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
private handleMessage(msg: SyncMessage): void {
|
|
147
|
+
switch (msg.type) {
|
|
148
|
+
case 'have':
|
|
149
|
+
this.handleHave(msg);
|
|
150
|
+
break;
|
|
151
|
+
case 'want':
|
|
152
|
+
this.handleWant(msg);
|
|
153
|
+
break;
|
|
154
|
+
case 'ops':
|
|
155
|
+
this.handleOps(msg);
|
|
156
|
+
break;
|
|
157
|
+
case 'ack':
|
|
158
|
+
this.handleAck(msg);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private handleHave(msg: Extract<SyncMessage, { type: 'have' }>): void {
|
|
164
|
+
// Store peer heads
|
|
165
|
+
this.state.peerHeads.set(msg.peerId, msg.heads);
|
|
166
|
+
|
|
167
|
+
// Compare with our state — determine what we need
|
|
168
|
+
const localOps = this.getLocalOps();
|
|
169
|
+
const localHashes = new Set(localOps.map((o) => o.hash));
|
|
170
|
+
|
|
171
|
+
// Check if peer has ops we don't
|
|
172
|
+
for (const [, hash] of Object.entries(msg.heads)) {
|
|
173
|
+
if (!localHashes.has(hash)) {
|
|
174
|
+
// Peer is ahead — request their ops
|
|
175
|
+
this.transport.send(msg.peerId, {
|
|
176
|
+
type: 'want',
|
|
177
|
+
peerId: this.localPeerId,
|
|
178
|
+
wantHashes: [],
|
|
179
|
+
afterHash: localOps.length > 0 ? localOps[localOps.length - 1].hash : undefined,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check if we have ops they don't — push them
|
|
186
|
+
const peerOpCount = msg.opCount;
|
|
187
|
+
if (localOps.length > peerOpCount) {
|
|
188
|
+
// Send ops they might be missing
|
|
189
|
+
const opsToSend = localOps.slice(peerOpCount);
|
|
190
|
+
this.transport.send(msg.peerId, {
|
|
191
|
+
type: 'ops',
|
|
192
|
+
peerId: this.localPeerId,
|
|
193
|
+
ops: opsToSend,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private handleWant(msg: Extract<SyncMessage, { type: 'want' }>): void {
|
|
199
|
+
const localOps = this.getLocalOps();
|
|
200
|
+
|
|
201
|
+
let opsToSend: VcsOp[];
|
|
202
|
+
if (msg.afterHash) {
|
|
203
|
+
const idx = localOps.findIndex((o) => o.hash === msg.afterHash);
|
|
204
|
+
opsToSend = idx >= 0 ? localOps.slice(idx + 1) : localOps;
|
|
205
|
+
} else if (msg.wantHashes.length > 0) {
|
|
206
|
+
const wanted = new Set(msg.wantHashes);
|
|
207
|
+
opsToSend = localOps.filter((o) => wanted.has(o.hash));
|
|
208
|
+
} else {
|
|
209
|
+
opsToSend = localOps;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (opsToSend.length > 0) {
|
|
213
|
+
this.transport.send(msg.peerId, {
|
|
214
|
+
type: 'ops',
|
|
215
|
+
peerId: this.localPeerId,
|
|
216
|
+
ops: opsToSend,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private handleOps(msg: Extract<SyncMessage, { type: 'ops' }>): void {
|
|
222
|
+
if (msg.ops.length === 0) return;
|
|
223
|
+
|
|
224
|
+
if (this.branchPolicy.linear) {
|
|
225
|
+
// Linear mode: only accept fast-forward appends
|
|
226
|
+
const localOps = this.getLocalOps();
|
|
227
|
+
const localHashes = new Set(localOps.map((o) => o.hash));
|
|
228
|
+
|
|
229
|
+
// Filter to only new ops
|
|
230
|
+
const newOps = msg.ops.filter((o) => !localHashes.has(o.hash));
|
|
231
|
+
if (newOps.length > 0) {
|
|
232
|
+
this.onOpsReceived(newOps);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// CRDT mode: reconcile divergent streams
|
|
236
|
+
const result = this.reconcileWith(msg.ops);
|
|
237
|
+
if (result.uniqueToB.length > 0) {
|
|
238
|
+
this.onOpsReceived(result.uniqueToB);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Acknowledge
|
|
243
|
+
this.transport.send(msg.peerId, {
|
|
244
|
+
type: 'ack',
|
|
245
|
+
peerId: this.localPeerId,
|
|
246
|
+
integrated: msg.ops.map((o) => o.hash),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
this.state.lastSync.set(msg.peerId, new Date().toISOString());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private handleAck(msg: Extract<SyncMessage, { type: 'ack' }>): void {
|
|
253
|
+
for (const hash of msg.integrated) {
|
|
254
|
+
this.state.pendingAcks.delete(hash);
|
|
255
|
+
}
|
|
256
|
+
this.state.lastSync.set(msg.peerId, new Date().toISOString());
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer Sync — Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* DESIGN.md §3.5, §10.5 — Peer sync + CRDTs.
|
|
5
|
+
* Types for peer identity, sync messages, causal DAG, and
|
|
6
|
+
* branch concurrency modes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { VcsOp } from '../vcs/types.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Peer Identity
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface PeerId {
|
|
16
|
+
/** Unique peer identifier (typically derived from identity DID). */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Human-readable display name. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Last seen timestamp. */
|
|
21
|
+
lastSeen?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Sync Messages
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export type SyncMessage =
|
|
29
|
+
| SyncHaveMessage
|
|
30
|
+
| SyncWantMessage
|
|
31
|
+
| SyncOpsMessage
|
|
32
|
+
| SyncAckMessage;
|
|
33
|
+
|
|
34
|
+
/** Advertise which op hashes we have. */
|
|
35
|
+
export interface SyncHaveMessage {
|
|
36
|
+
type: 'have';
|
|
37
|
+
peerId: string;
|
|
38
|
+
/** Our head op hashes (one per branch). */
|
|
39
|
+
heads: Record<string, string>;
|
|
40
|
+
/** Total op count for quick comparison. */
|
|
41
|
+
opCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Request ops we're missing. */
|
|
45
|
+
export interface SyncWantMessage {
|
|
46
|
+
type: 'want';
|
|
47
|
+
peerId: string;
|
|
48
|
+
/** Op hashes we need (those the remote has but we don't). */
|
|
49
|
+
wantHashes: string[];
|
|
50
|
+
/** Alternatively: request all ops after a given hash. */
|
|
51
|
+
afterHash?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Send a batch of ops. */
|
|
55
|
+
export interface SyncOpsMessage {
|
|
56
|
+
type: 'ops';
|
|
57
|
+
peerId: string;
|
|
58
|
+
ops: VcsOp[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Acknowledge receipt. */
|
|
62
|
+
export interface SyncAckMessage {
|
|
63
|
+
type: 'ack';
|
|
64
|
+
peerId: string;
|
|
65
|
+
/** Hashes of ops we've integrated. */
|
|
66
|
+
integrated: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Sync State
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export interface SyncState {
|
|
74
|
+
/** Our peer identity. */
|
|
75
|
+
localPeerId: string;
|
|
76
|
+
/** Known peers and their head hashes. */
|
|
77
|
+
peerHeads: Map<string, Record<string, string>>;
|
|
78
|
+
/** Ops we've sent but not yet acknowledged. */
|
|
79
|
+
pendingAcks: Set<string>;
|
|
80
|
+
/** Last sync timestamp per peer. */
|
|
81
|
+
lastSync: Map<string, string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Branch Concurrency Policy
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface BranchPolicy {
|
|
89
|
+
/** If true, only fast-forward appends (one writer). Default. */
|
|
90
|
+
linear: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Sync Transport (abstract interface)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface SyncTransport {
|
|
98
|
+
/** Send a message to a specific peer. */
|
|
99
|
+
send(peerId: string, message: SyncMessage): Promise<void>;
|
|
100
|
+
/** Register a handler for incoming messages. */
|
|
101
|
+
onMessage(handler: (message: SyncMessage) => void): void;
|
|
102
|
+
/** List connected peers. */
|
|
103
|
+
peers(): PeerId[];
|
|
104
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Addressable Blob Store
|
|
3
|
+
*
|
|
4
|
+
* Stores file content indexed by SHA-256 hash. Provides the source of truth
|
|
5
|
+
* for file reconstruction at any point in history. The EAV graph stores
|
|
6
|
+
* structural metadata; the blob store stores byte-exact content.
|
|
7
|
+
*
|
|
8
|
+
* Storage format: `.trellis/blobs/{hash}` files on disk.
|
|
9
|
+
* Future: migrate to SQLite `blobs(hash TEXT PRIMARY KEY, content BLOB)`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
export class BlobStore {
|
|
16
|
+
private blobDir: string;
|
|
17
|
+
|
|
18
|
+
constructor(trellisDir: string) {
|
|
19
|
+
this.blobDir = join(trellisDir, 'blobs');
|
|
20
|
+
if (!existsSync(this.blobDir)) {
|
|
21
|
+
mkdirSync(this.blobDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Store content and return its SHA-256 hash.
|
|
27
|
+
* Idempotent — storing the same content twice is a no-op.
|
|
28
|
+
*/
|
|
29
|
+
async put(content: Buffer | Uint8Array): Promise<string> {
|
|
30
|
+
const hash = await this.hash(content);
|
|
31
|
+
const blobPath = join(this.blobDir, hash);
|
|
32
|
+
if (!existsSync(blobPath)) {
|
|
33
|
+
writeFileSync(blobPath, content);
|
|
34
|
+
}
|
|
35
|
+
return hash;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Synchronous put — uses Bun's sync crypto if available.
|
|
40
|
+
*/
|
|
41
|
+
putSync(content: Buffer | Uint8Array): string {
|
|
42
|
+
const hash = this.hashSync(content);
|
|
43
|
+
const blobPath = join(this.blobDir, hash);
|
|
44
|
+
if (!existsSync(blobPath)) {
|
|
45
|
+
writeFileSync(blobPath, content);
|
|
46
|
+
}
|
|
47
|
+
return hash;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Retrieve content by hash. Returns null if not found.
|
|
52
|
+
*/
|
|
53
|
+
get(hash: string): Buffer | null {
|
|
54
|
+
const blobPath = join(this.blobDir, hash);
|
|
55
|
+
if (!existsSync(blobPath)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return readFileSync(blobPath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a blob exists.
|
|
63
|
+
*/
|
|
64
|
+
has(hash: string): boolean {
|
|
65
|
+
return existsSync(join(this.blobDir, hash));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute SHA-256 hash of content (async).
|
|
70
|
+
*/
|
|
71
|
+
async hash(content: Buffer | Uint8Array): Promise<string> {
|
|
72
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
73
|
+
'SHA-256',
|
|
74
|
+
content as unknown as ArrayBuffer,
|
|
75
|
+
);
|
|
76
|
+
return this.hexFromBuffer(hashBuffer);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compute SHA-256 hash of content (sync, using Bun's CryptoHasher).
|
|
81
|
+
*/
|
|
82
|
+
hashSync(content: Buffer | Uint8Array): string {
|
|
83
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
84
|
+
hasher.update(content);
|
|
85
|
+
return hasher.digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns the number of blobs stored.
|
|
90
|
+
*/
|
|
91
|
+
count(): number {
|
|
92
|
+
try {
|
|
93
|
+
const { readdirSync } = require('fs');
|
|
94
|
+
return readdirSync(this.blobDir).length;
|
|
95
|
+
} catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns the total size of all blobs in bytes.
|
|
102
|
+
*/
|
|
103
|
+
totalSize(): number {
|
|
104
|
+
try {
|
|
105
|
+
const { readdirSync, statSync } = require('fs');
|
|
106
|
+
const files: string[] = readdirSync(this.blobDir);
|
|
107
|
+
return files.reduce((sum: number, f: string) => {
|
|
108
|
+
try {
|
|
109
|
+
return sum + statSync(join(this.blobDir, f)).size;
|
|
110
|
+
} catch {
|
|
111
|
+
return sum;
|
|
112
|
+
}
|
|
113
|
+
}, 0);
|
|
114
|
+
} catch {
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private hexFromBuffer(buffer: ArrayBuffer): string {
|
|
120
|
+
return Array.from(new Uint8Array(buffer))
|
|
121
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
122
|
+
.join('');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch Management Module
|
|
3
|
+
*
|
|
4
|
+
* Extracted from engine.ts per DESIGN.md §8.1.
|
|
5
|
+
* Handles create, switch, list, delete branch operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { createVcsOp } from './ops.js';
|
|
11
|
+
import type { VcsOp } from './types.js';
|
|
12
|
+
import type { EngineContext } from './engine-context.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface BranchInfo {
|
|
19
|
+
name: string;
|
|
20
|
+
isCurrent: boolean;
|
|
21
|
+
createdAt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BranchState {
|
|
25
|
+
currentBranch: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Operations
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a new branch forked from the current branch.
|
|
34
|
+
*/
|
|
35
|
+
export async function createBranch(
|
|
36
|
+
ctx: EngineContext,
|
|
37
|
+
name: string,
|
|
38
|
+
currentBranch: string,
|
|
39
|
+
): Promise<VcsOp> {
|
|
40
|
+
const existing = ctx.store
|
|
41
|
+
.getFactsByAttribute('type')
|
|
42
|
+
.filter((f) => f.v === 'Branch' && f.e === `branch:${name}`);
|
|
43
|
+
if (existing.length > 0) {
|
|
44
|
+
throw new Error(`Branch '${name}' already exists`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const op = await createVcsOp('vcs:branchCreate', {
|
|
48
|
+
agentId: ctx.agentId,
|
|
49
|
+
previousHash: ctx.getLastOp()?.hash,
|
|
50
|
+
vcs: {
|
|
51
|
+
branchName: name,
|
|
52
|
+
baseBranch: currentBranch,
|
|
53
|
+
targetOpHash: ctx.getLastOp()?.hash,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
ctx.applyOp(op);
|
|
57
|
+
return op;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Switch to an existing branch.
|
|
62
|
+
*/
|
|
63
|
+
export function switchBranch(
|
|
64
|
+
ctx: EngineContext,
|
|
65
|
+
name: string,
|
|
66
|
+
): void {
|
|
67
|
+
const branchFacts = ctx.store
|
|
68
|
+
.getFactsByEntity(`branch:${name}`)
|
|
69
|
+
.filter((f) => f.a === 'type' && f.v === 'Branch');
|
|
70
|
+
if (branchFacts.length === 0) {
|
|
71
|
+
throw new Error(`Branch '${name}' does not exist`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* List all branches.
|
|
77
|
+
*/
|
|
78
|
+
export function listBranches(
|
|
79
|
+
ctx: EngineContext,
|
|
80
|
+
currentBranch: string,
|
|
81
|
+
): BranchInfo[] {
|
|
82
|
+
const branchFacts = ctx.store
|
|
83
|
+
.getFactsByAttribute('type')
|
|
84
|
+
.filter((f) => f.v === 'Branch');
|
|
85
|
+
|
|
86
|
+
return branchFacts.map((f) => {
|
|
87
|
+
const nameFact = ctx.store
|
|
88
|
+
.getFactsByEntity(f.e)
|
|
89
|
+
.find((ef) => ef.a === 'name');
|
|
90
|
+
const createdFact = ctx.store
|
|
91
|
+
.getFactsByEntity(f.e)
|
|
92
|
+
.find((ef) => ef.a === 'createdAt');
|
|
93
|
+
const name = (nameFact?.v as string) ?? f.e.replace('branch:', '');
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
isCurrent: name === currentBranch,
|
|
97
|
+
createdAt: createdFact?.v as string | undefined,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Delete a branch (cannot delete the current branch).
|
|
104
|
+
*/
|
|
105
|
+
export async function deleteBranch(
|
|
106
|
+
ctx: EngineContext,
|
|
107
|
+
name: string,
|
|
108
|
+
currentBranch: string,
|
|
109
|
+
): Promise<VcsOp> {
|
|
110
|
+
if (name === currentBranch) {
|
|
111
|
+
throw new Error(`Cannot delete the current branch '${name}'`);
|
|
112
|
+
}
|
|
113
|
+
const branchFacts = ctx.store
|
|
114
|
+
.getFactsByEntity(`branch:${name}`)
|
|
115
|
+
.filter((f) => f.a === 'type' && f.v === 'Branch');
|
|
116
|
+
if (branchFacts.length === 0) {
|
|
117
|
+
throw new Error(`Branch '${name}' does not exist`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const op = await createVcsOp('vcs:branchDelete', {
|
|
121
|
+
agentId: ctx.agentId,
|
|
122
|
+
previousHash: ctx.getLastOp()?.hash,
|
|
123
|
+
vcs: { branchName: name },
|
|
124
|
+
});
|
|
125
|
+
ctx.applyOp(op);
|
|
126
|
+
return op;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Persistence
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export function saveBranchState(rootPath: string, state: BranchState): void {
|
|
134
|
+
const statePath = join(rootPath, '.trellis', 'state.json');
|
|
135
|
+
writeFileSync(statePath, JSON.stringify(state));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function loadBranchState(rootPath: string): BranchState {
|
|
139
|
+
const statePath = join(rootPath, '.trellis', 'state.json');
|
|
140
|
+
if (existsSync(statePath)) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = readFileSync(statePath, 'utf-8');
|
|
143
|
+
const state = JSON.parse(raw);
|
|
144
|
+
if (state.currentBranch) {
|
|
145
|
+
return { currentBranch: state.currentBranch };
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
return { currentBranch: 'main' };
|
|
150
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint Module
|
|
3
|
+
*
|
|
4
|
+
* Extracted from engine.ts per DESIGN.md §8.1.
|
|
5
|
+
* Handles checkpoint creation, listing, and auto-checkpoint logic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createVcsOp } from './ops.js';
|
|
9
|
+
import type { VcsOp } from './types.js';
|
|
10
|
+
import type { EngineContext } from './engine-context.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export type CheckpointTrigger = 'manual' | 'op-count' | 'interval' | 'green-build';
|
|
17
|
+
|
|
18
|
+
export interface CheckpointInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
trigger?: string;
|
|
22
|
+
atOpHash?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Operations
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a checkpoint at the current position in the causal stream.
|
|
31
|
+
*/
|
|
32
|
+
export async function createCheckpoint(
|
|
33
|
+
ctx: EngineContext,
|
|
34
|
+
trigger: CheckpointTrigger = 'manual',
|
|
35
|
+
): Promise<VcsOp> {
|
|
36
|
+
const op = await createVcsOp('vcs:checkpointCreate', {
|
|
37
|
+
agentId: ctx.agentId,
|
|
38
|
+
previousHash: ctx.getLastOp()?.hash,
|
|
39
|
+
vcs: { trigger },
|
|
40
|
+
});
|
|
41
|
+
ctx.applyOp(op);
|
|
42
|
+
return op;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List all checkpoints from the EAV store.
|
|
47
|
+
*/
|
|
48
|
+
export function listCheckpoints(ctx: EngineContext): CheckpointInfo[] {
|
|
49
|
+
const cpFacts = ctx.store
|
|
50
|
+
.getFactsByAttribute('type')
|
|
51
|
+
.filter((f) => f.v === 'Checkpoint');
|
|
52
|
+
|
|
53
|
+
return cpFacts.map((f) => {
|
|
54
|
+
const facts = ctx.store.getFactsByEntity(f.e);
|
|
55
|
+
const get = (attr: string) =>
|
|
56
|
+
facts.find((ef) => ef.a === attr)?.v as string | undefined;
|
|
57
|
+
return {
|
|
58
|
+
id: f.e,
|
|
59
|
+
createdAt: get('createdAt'),
|
|
60
|
+
trigger: get('trigger'),
|
|
61
|
+
atOpHash: get('atOpHash'),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|