@pellux/goodvibes-sdk 0.36.0 → 0.37.1
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/dist/contracts/artifacts/operator-contract.json +1 -1
- package/dist/platform/core/compaction-sections.d.ts +8 -0
- package/dist/platform/core/compaction-sections.d.ts.map +1 -1
- package/dist/platform/core/compaction-sections.js +71 -2
- package/dist/platform/core/context-compaction.d.ts +13 -0
- package/dist/platform/core/context-compaction.d.ts.map +1 -1
- package/dist/platform/core/context-compaction.js +24 -2
- package/dist/platform/core/orchestrator-context-runtime.d.ts.map +1 -1
- package/dist/platform/core/orchestrator-context-runtime.js +1 -2
- package/dist/platform/core/orchestrator-tool-runtime.d.ts.map +1 -1
- package/dist/platform/core/orchestrator-tool-runtime.js +30 -2
- package/dist/platform/permissions/manager.d.ts.map +1 -1
- package/dist/platform/permissions/manager.js +3 -2
- package/dist/platform/permissions/prompt.d.ts +8 -1
- package/dist/platform/permissions/prompt.d.ts.map +1 -1
- package/dist/platform/permissions/types.d.ts +6 -0
- package/dist/platform/permissions/types.d.ts.map +1 -1
- package/dist/platform/runtime/services.d.ts +2 -0
- package/dist/platform/runtime/services.d.ts.map +1 -1
- package/dist/platform/runtime/services.js +15 -0
- package/dist/platform/tools/index.d.ts +1 -0
- package/dist/platform/tools/index.d.ts.map +1 -1
- package/dist/platform/version.js +1 -1
- package/dist/platform/workspace/checkpoint/index.d.ts +9 -0
- package/dist/platform/workspace/checkpoint/index.d.ts.map +1 -0
- package/dist/platform/workspace/checkpoint/index.js +7 -0
- package/dist/platform/workspace/checkpoint/manager.d.ts +214 -0
- package/dist/platform/workspace/checkpoint/manager.d.ts.map +1 -0
- package/dist/platform/workspace/checkpoint/manager.js +543 -0
- package/dist/platform/workspace/checkpoint/side-git.d.ts +130 -0
- package/dist/platform/workspace/checkpoint/side-git.d.ts.map +1 -0
- package/dist/platform/workspace/checkpoint/side-git.js +264 -0
- package/dist/platform/workspace/checkpoint/types.d.ts +96 -0
- package/dist/platform/workspace/checkpoint/types.d.ts.map +1 -0
- package/dist/platform/workspace/checkpoint/types.js +20 -0
- package/dist/platform/workspace/index.d.ts +1 -0
- package/dist/platform/workspace/index.d.ts.map +1 -1
- package/dist/platform/workspace/index.js +1 -0
- package/package.json +9 -9
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manager.ts
|
|
3
|
+
*
|
|
4
|
+
* WorkspaceCheckpointManager — the coarse, whole-workspace rewind layer.
|
|
5
|
+
*
|
|
6
|
+
* Complements (does not replace) FileUndoManager (../../state/file-undo.ts),
|
|
7
|
+
* which stays as the fine-grained, in-memory, per-file /undo layer. This
|
|
8
|
+
* manager persists across sessions, snapshots the ENTIRE workspace tree, and
|
|
9
|
+
* survives process restarts (backed by git objects + a JSON manifest on
|
|
10
|
+
* disk), which is the wrong shape for FileUndoManager but exactly the shape
|
|
11
|
+
* needed for "revert everything turn N did" or "restore the workspace to how
|
|
12
|
+
* it looked an hour ago".
|
|
13
|
+
*
|
|
14
|
+
* Storage layout (workspace-local, see side-git.ts for the git mechanics):
|
|
15
|
+
* <workspaceRoot>/.goodvibes/checkpoints/git — side GIT_DIR
|
|
16
|
+
* <workspaceRoot>/.goodvibes/checkpoints/index.json — manifest (JsonFileStore)
|
|
17
|
+
*
|
|
18
|
+
* Checkpoint refs live at refs/goodvibes/checkpoints/<id> inside the side
|
|
19
|
+
* repo, entirely separate from the user's real git refs and from compaction's
|
|
20
|
+
* `cpt_` boundary commits (which are conversation snapshots, not filesystem
|
|
21
|
+
* snapshots, and are not stored in git at all — see types.ts's header comment
|
|
22
|
+
* for the full disambiguation).
|
|
23
|
+
*
|
|
24
|
+
* Automatic snapshots subscribe to EXISTING runtime bus events
|
|
25
|
+
* (TURN_COMPLETED / TURN_ERROR / TURN_CANCEL / AGENT_COMPLETED) — no new
|
|
26
|
+
* event contract is introduced by this module.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
30
|
+
import { stat } from 'node:fs/promises';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { logger } from '../../utils/logger.js';
|
|
33
|
+
import { summarizeError } from '../../utils/error-display.js';
|
|
34
|
+
import { JsonFileStore } from '../../state/json-file-store.js';
|
|
35
|
+
import { RetentionPolicy, } from '../../runtime/retention/index.js';
|
|
36
|
+
import { SideGitRunner, CHECKPOINT_REF_PREFIX, EMPTY_TREE_HASH } from './side-git.js';
|
|
37
|
+
const ID_PREFIX = 'wcp';
|
|
38
|
+
function generateCheckpointId(now) {
|
|
39
|
+
const ts = now().toString(36);
|
|
40
|
+
const rand = randomUUID().slice(0, 8);
|
|
41
|
+
return `${ID_PREFIX}_${ts}_${rand}`;
|
|
42
|
+
}
|
|
43
|
+
/** Default retention class per checkpoint kind when the caller does not specify one. */
|
|
44
|
+
function defaultRetentionClassFor(kind) {
|
|
45
|
+
return kind === 'manual' ? 'forensic' : 'standard';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `Pruner` implementation for `RetentionPolicy` that deletes checkpoint REFS
|
|
49
|
+
* (not filesystem paths — that is what `SnapshotPruner`, the compaction-side
|
|
50
|
+
* pruner, does, and reusing it here would be a no-op at best since our
|
|
51
|
+
* "artifacts" are refs+objects, not files). Actual object reclamation is a
|
|
52
|
+
* single `git gc --prune=now` run once by `WorkspaceCheckpointManager.gc()`
|
|
53
|
+
* after refs are deleted, not per-record here.
|
|
54
|
+
*/
|
|
55
|
+
class WorkspaceCheckpointPruner {
|
|
56
|
+
sideGit;
|
|
57
|
+
onDeleted;
|
|
58
|
+
constructor(sideGit, onDeleted) {
|
|
59
|
+
this.sideGit = sideGit;
|
|
60
|
+
this.onDeleted = onDeleted;
|
|
61
|
+
}
|
|
62
|
+
async delete(candidates, options) {
|
|
63
|
+
const dryRun = options?.dryRun ?? false;
|
|
64
|
+
const deletedIds = [];
|
|
65
|
+
const failedIds = [];
|
|
66
|
+
const errors = {};
|
|
67
|
+
let reclaimedBytes = 0;
|
|
68
|
+
const byClass = {
|
|
69
|
+
short: { deletedCount: 0, reclaimedBytes: 0, deletedIds: [], candidateIds: [], failedIds: [] },
|
|
70
|
+
standard: { deletedCount: 0, reclaimedBytes: 0, deletedIds: [], candidateIds: [], failedIds: [] },
|
|
71
|
+
forensic: { deletedCount: 0, reclaimedBytes: 0, deletedIds: [], candidateIds: [], failedIds: [] },
|
|
72
|
+
};
|
|
73
|
+
for (const record of candidates) {
|
|
74
|
+
byClass[record.retentionClass].candidateIds.push(record.id);
|
|
75
|
+
}
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
return {
|
|
78
|
+
deletedCount: 0,
|
|
79
|
+
reclaimedBytes: 0,
|
|
80
|
+
deletedIds: [],
|
|
81
|
+
candidateIds: candidates.map((c) => c.id),
|
|
82
|
+
failedIds: [],
|
|
83
|
+
errors: {},
|
|
84
|
+
dryRun: true,
|
|
85
|
+
byClass,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
for (const record of candidates) {
|
|
89
|
+
try {
|
|
90
|
+
await this.sideGit.deleteRef(`${CHECKPOINT_REF_PREFIX}${record.id}`);
|
|
91
|
+
deletedIds.push(record.id);
|
|
92
|
+
reclaimedBytes += record.sizeBytes;
|
|
93
|
+
byClass[record.retentionClass].deletedCount += 1;
|
|
94
|
+
byClass[record.retentionClass].reclaimedBytes += record.sizeBytes;
|
|
95
|
+
byClass[record.retentionClass].deletedIds.push(record.id);
|
|
96
|
+
this.onDeleted(record.id);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
failedIds.push(record.id);
|
|
100
|
+
errors[record.id] = summarizeError(err);
|
|
101
|
+
byClass[record.retentionClass].failedIds.push(record.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
deletedCount: deletedIds.length,
|
|
106
|
+
reclaimedBytes,
|
|
107
|
+
deletedIds,
|
|
108
|
+
candidateIds: [],
|
|
109
|
+
failedIds,
|
|
110
|
+
errors,
|
|
111
|
+
dryRun: false,
|
|
112
|
+
byClass,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* WorkspaceCheckpointManager — create/list/diff/restore/gc for whole-workspace
|
|
118
|
+
* git-backed snapshots, plus automatic snapshotting on existing turn/agent
|
|
119
|
+
* lifecycle events.
|
|
120
|
+
*/
|
|
121
|
+
export class WorkspaceCheckpointManager {
|
|
122
|
+
workspaceRoot;
|
|
123
|
+
checkpointRootDir;
|
|
124
|
+
sideGit;
|
|
125
|
+
manifestStore;
|
|
126
|
+
retentionPolicy;
|
|
127
|
+
now;
|
|
128
|
+
runtimeBus;
|
|
129
|
+
unsubscribers = [];
|
|
130
|
+
checkpoints = new Map();
|
|
131
|
+
initialized = false;
|
|
132
|
+
initPromise = null;
|
|
133
|
+
/**
|
|
134
|
+
* Promise-chain mutex serializing every public operation that touches the
|
|
135
|
+
* side repo's shared index or shared object store: `create`, `restore`,
|
|
136
|
+
* `gc`, and `diff` (see each method's `*Internal` body below). Without
|
|
137
|
+
* this, an auto-snapshot `create()` firing on a bus event (TURN_COMPLETED,
|
|
138
|
+
* TURN_ERROR, TURN_CANCEL, or AGENT_COMPLETED) could run its `git add -A`
|
|
139
|
+
* in between `restore()`'s
|
|
140
|
+
* `read-tree --reset` and `checkout-index -a -f`, silently corrupting the
|
|
141
|
+
* restore; two concurrent `create()` calls share the same hazard on the
|
|
142
|
+
* index, and a same-tick `gc()` could treat a not-yet-ref'd loose commit
|
|
143
|
+
* from an in-flight `create()` as unreachable and prune it out from under
|
|
144
|
+
* it. `diff()` is included too: the single-argument `git diff <tree-ish>`
|
|
145
|
+
* form (diffing a checkpoint against the live working tree) refreshes the
|
|
146
|
+
* index's stat cache as a side effect, which is itself a write.
|
|
147
|
+
*
|
|
148
|
+
* Each public method below only does `await this.init()` (idempotent, safe
|
|
149
|
+
* to race) before calling `withLock`; the actual git-touching work lives in
|
|
150
|
+
* a same-named `*Internal` method. Internal callers that need another
|
|
151
|
+
* locked operation's behavior (e.g. `restore()`'s safety checkpoint) call
|
|
152
|
+
* the `*Internal` method directly — never the public wrapper — so a single
|
|
153
|
+
* logical operation never tries to re-enter its own lock.
|
|
154
|
+
*/
|
|
155
|
+
lockChain = Promise.resolve();
|
|
156
|
+
withLock(fn) {
|
|
157
|
+
// `lockChain` is constructed below so it never itself rejects (its
|
|
158
|
+
// continuation always swallows both outcomes) — chaining with a single
|
|
159
|
+
// `.then(fn)` is enough to guarantee `fn` only starts after every
|
|
160
|
+
// previously-queued operation has settled, regardless of whether that
|
|
161
|
+
// prior operation resolved or rejected.
|
|
162
|
+
const result = this.lockChain.then(fn);
|
|
163
|
+
this.lockChain = result.then(() => undefined, () => undefined);
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
constructor(opts) {
|
|
167
|
+
this.workspaceRoot = opts.workspaceRoot;
|
|
168
|
+
this.checkpointRootDir = opts.checkpointDir ?? join(opts.workspaceRoot, '.goodvibes', 'checkpoints');
|
|
169
|
+
this.sideGit = new SideGitRunner({
|
|
170
|
+
workspaceRoot: opts.workspaceRoot,
|
|
171
|
+
gitDir: join(this.checkpointRootDir, 'git'),
|
|
172
|
+
});
|
|
173
|
+
this.manifestStore = new JsonFileStore(join(this.checkpointRootDir, 'index.json'));
|
|
174
|
+
this.now = opts.now ?? Date.now;
|
|
175
|
+
this.runtimeBus = opts.runtimeBus;
|
|
176
|
+
this.retentionPolicy = new RetentionPolicy(opts.retention, this.now, new WorkspaceCheckpointPruner(this.sideGit, (id) => this.checkpoints.delete(id)));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Idempotent setup: init the side repo, load the manifest (re-hydrating the
|
|
180
|
+
* in-memory RetentionPolicy so retention state survives process restarts),
|
|
181
|
+
* and subscribe to automatic-snapshot events if a runtime bus was provided.
|
|
182
|
+
* Safe to call multiple times; concurrent callers share one in-flight init.
|
|
183
|
+
*/
|
|
184
|
+
async init() {
|
|
185
|
+
if (this.initialized)
|
|
186
|
+
return;
|
|
187
|
+
if (!this.initPromise) {
|
|
188
|
+
this.initPromise = this._init();
|
|
189
|
+
}
|
|
190
|
+
await this.initPromise;
|
|
191
|
+
}
|
|
192
|
+
async _init() {
|
|
193
|
+
await this.sideGit.init();
|
|
194
|
+
const manifest = await this.manifestStore.load();
|
|
195
|
+
this.checkpoints = new Map((manifest?.checkpoints ?? []).map((c) => [c.id, c]));
|
|
196
|
+
for (const checkpoint of this.checkpoints.values()) {
|
|
197
|
+
this.retentionPolicy.register({
|
|
198
|
+
id: checkpoint.id,
|
|
199
|
+
createdAt: checkpoint.createdAt,
|
|
200
|
+
sizeBytes: checkpoint.sizeBytes,
|
|
201
|
+
retentionClass: checkpoint.retentionClass,
|
|
202
|
+
path: this.sideGit.gitDir,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (this.runtimeBus) {
|
|
206
|
+
this.subscribeToAutomaticSnapshots(this.runtimeBus);
|
|
207
|
+
}
|
|
208
|
+
this.initialized = true;
|
|
209
|
+
}
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Automatic snapshots
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
/**
|
|
214
|
+
* Subscribes to the EXISTING turn/agent lifecycle events — no new event
|
|
215
|
+
* types are introduced. A snapshot is taken at the boundary AFTER each
|
|
216
|
+
* turn/agent-run finishes, meaning "revert everything turn N did" means
|
|
217
|
+
* restoring the checkpoint captured BEFORE turn N, i.e. checkpoint[N-1] in
|
|
218
|
+
* `list()`'s (newest-first) ordering — an intentional off-by-one, documented
|
|
219
|
+
* here rather than hidden: this module always snapshots "where things ended
|
|
220
|
+
* up", never "where things started".
|
|
221
|
+
*
|
|
222
|
+
* Listener bodies are wrapped so a failure NEVER throws back into the bus:
|
|
223
|
+
* `RuntimeEventBus.emit()` only catches synchronous throws from a listener,
|
|
224
|
+
* not rejections from a returned (but un-awaited) promise, so every
|
|
225
|
+
* auto-snapshot call here is deliberately `.catch()`-guarded.
|
|
226
|
+
*/
|
|
227
|
+
subscribeToAutomaticSnapshots(bus) {
|
|
228
|
+
const snapshotTurn = (turnId, reason) => {
|
|
229
|
+
this.create({ kind: 'turn', turnId, retentionClass: 'standard', label: `turn ${reason}` }).catch((err) => {
|
|
230
|
+
logger.warn('WorkspaceCheckpointManager: automatic turn snapshot failed', {
|
|
231
|
+
turnId,
|
|
232
|
+
reason,
|
|
233
|
+
error: summarizeError(err),
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
const snapshotAgentRun = (agentId) => {
|
|
238
|
+
this.create({ kind: 'agent-run', agentId, retentionClass: 'standard', label: 'agent run' }).catch((err) => {
|
|
239
|
+
logger.warn('WorkspaceCheckpointManager: automatic agent-run snapshot failed', {
|
|
240
|
+
agentId,
|
|
241
|
+
error: summarizeError(err),
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
this.unsubscribers.push(bus.on('TURN_COMPLETED', ({ payload }) => {
|
|
246
|
+
snapshotTurn(payload.turnId, 'completed');
|
|
247
|
+
}), bus.on('TURN_ERROR', ({ payload }) => {
|
|
248
|
+
snapshotTurn(payload.turnId, 'error');
|
|
249
|
+
}), bus.on('TURN_CANCEL', ({ payload }) => {
|
|
250
|
+
snapshotTurn(payload.turnId, 'cancelled');
|
|
251
|
+
}), bus.on('AGENT_COMPLETED', ({ payload }) => {
|
|
252
|
+
snapshotAgentRun(payload.agentId);
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Public API
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Create a new checkpoint. Returns `null` (a cheap no-op) when the current
|
|
260
|
+
* workspace tree is identical to the parent checkpoint's tree — no commit,
|
|
261
|
+
* no ref, no manifest entry is created in that case.
|
|
262
|
+
*
|
|
263
|
+
* Serialized against every other index-touching operation on this manager
|
|
264
|
+
* — see `withLock`.
|
|
265
|
+
*/
|
|
266
|
+
async create(opts) {
|
|
267
|
+
await this.init();
|
|
268
|
+
return this.withLock(() => this.createInternal(opts));
|
|
269
|
+
}
|
|
270
|
+
async createInternal(opts) {
|
|
271
|
+
await this.sideGit.stageAll(opts.paths);
|
|
272
|
+
const treeHash = await this.sideGit.writeTree();
|
|
273
|
+
const parent = this.mostRecentCheckpoint();
|
|
274
|
+
const parentTree = parent ? await this.sideGit.treeOf(parent.commit) : EMPTY_TREE_HASH;
|
|
275
|
+
if (parentTree === treeHash) {
|
|
276
|
+
logger.debug('WorkspaceCheckpointManager.create: tree unchanged since parent, no-op', {
|
|
277
|
+
kind: opts.kind,
|
|
278
|
+
parentId: parent?.id ?? null,
|
|
279
|
+
});
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const id = generateCheckpointId(this.now);
|
|
283
|
+
const kind = opts.kind;
|
|
284
|
+
const retentionClass = opts.retentionClass ?? defaultRetentionClassFor(kind);
|
|
285
|
+
const label = opts.label ?? this.defaultLabel(kind, id);
|
|
286
|
+
const message = `wcp: ${kind} ${label}`.trim();
|
|
287
|
+
const commit = await this.sideGit.commitTree(treeHash, message);
|
|
288
|
+
await this.sideGit.updateRef(`${CHECKPOINT_REF_PREFIX}${id}`, commit);
|
|
289
|
+
const changedPaths = parent
|
|
290
|
+
? await this.sideGit.diffNameOnly(parent.commit, commit)
|
|
291
|
+
: await this.sideGit.listTrackedFiles(commit);
|
|
292
|
+
const sizeBytes = await this.computeSizeBytes(changedPaths);
|
|
293
|
+
const checkpoint = {
|
|
294
|
+
id,
|
|
295
|
+
kind,
|
|
296
|
+
label,
|
|
297
|
+
createdAt: this.now(),
|
|
298
|
+
parentId: parent?.id ?? null,
|
|
299
|
+
turnId: opts.turnId,
|
|
300
|
+
agentId: opts.agentId,
|
|
301
|
+
retentionClass,
|
|
302
|
+
commit,
|
|
303
|
+
sizeBytes,
|
|
304
|
+
};
|
|
305
|
+
this.checkpoints.set(id, checkpoint);
|
|
306
|
+
this.retentionPolicy.register({
|
|
307
|
+
id,
|
|
308
|
+
createdAt: checkpoint.createdAt,
|
|
309
|
+
sizeBytes,
|
|
310
|
+
retentionClass,
|
|
311
|
+
path: this.sideGit.gitDir,
|
|
312
|
+
});
|
|
313
|
+
await this.persistManifest();
|
|
314
|
+
logger.debug('WorkspaceCheckpointManager.create: checkpoint created', { id, kind, parentId: checkpoint.parentId });
|
|
315
|
+
return checkpoint;
|
|
316
|
+
}
|
|
317
|
+
/** List checkpoints, newest-first. `list()[0]` is always the most recent checkpoint. */
|
|
318
|
+
async list(filter) {
|
|
319
|
+
await this.init();
|
|
320
|
+
let results = Array.from(this.checkpoints.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
321
|
+
if (filter?.kind)
|
|
322
|
+
results = results.filter((c) => c.kind === filter.kind);
|
|
323
|
+
if (filter?.since != null)
|
|
324
|
+
results = results.filter((c) => c.createdAt >= filter.since);
|
|
325
|
+
if (filter?.limit != null)
|
|
326
|
+
results = results.slice(0, filter.limit);
|
|
327
|
+
return results;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Diff two checkpoints, or a checkpoint against the live working tree when
|
|
331
|
+
* `b` is omitted.
|
|
332
|
+
*
|
|
333
|
+
* Serialized against every other index-touching operation on this manager
|
|
334
|
+
* — see `withLock`. The single-argument form (`b` omitted, diffing against
|
|
335
|
+
* the live working tree) refreshes the side index's stat cache as a side
|
|
336
|
+
* effect, so it is not purely read-only.
|
|
337
|
+
*/
|
|
338
|
+
async diff(a, b) {
|
|
339
|
+
await this.init();
|
|
340
|
+
return this.withLock(() => this.diffInternal(a, b));
|
|
341
|
+
}
|
|
342
|
+
async diffInternal(a, b) {
|
|
343
|
+
const fromCheckpoint = this.requireCheckpoint(a);
|
|
344
|
+
const toCheckpoint = b ? this.requireCheckpoint(b) : undefined;
|
|
345
|
+
const [unifiedDiff, stat_, files] = await Promise.all([
|
|
346
|
+
this.sideGit.diff(fromCheckpoint.commit, toCheckpoint?.commit),
|
|
347
|
+
this.sideGit.diffStat(fromCheckpoint.commit, toCheckpoint?.commit),
|
|
348
|
+
this.sideGit.diffNameOnly(fromCheckpoint.commit, toCheckpoint?.commit),
|
|
349
|
+
]);
|
|
350
|
+
return {
|
|
351
|
+
from: a,
|
|
352
|
+
to: b ?? 'WORKING',
|
|
353
|
+
files,
|
|
354
|
+
unifiedDiff,
|
|
355
|
+
stat: stat_,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Restore the workspace to the state captured by checkpoint `id`.
|
|
360
|
+
*
|
|
361
|
+
* By default (`safetyCheckpoint: true`) takes a checkpoint of the CURRENT
|
|
362
|
+
* state first, so a restore is itself undoable via another restore.
|
|
363
|
+
*
|
|
364
|
+
* Whole-workspace restore (no `opts.paths`):
|
|
365
|
+
* 1. Snapshot the current tracked-file set (via the safety checkpoint, or
|
|
366
|
+
* a transient write-tree when `safetyCheckpoint: false`) — this is the
|
|
367
|
+
* "before" set.
|
|
368
|
+
* 2. Reset the side index to the target checkpoint's tree and check every
|
|
369
|
+
* file in it out to disk (re-creates anything the checkpoint had that
|
|
370
|
+
* is currently missing or modified).
|
|
371
|
+
* 3. Remove exactly the files that were in the "before" set but are NOT
|
|
372
|
+
* in the target checkpoint's tree (files created/tracked after the
|
|
373
|
+
* checkpoint). Anything NOT in the "before" set — i.e. any untracked
|
|
374
|
+
* path outside what this engine has ever snapshotted — is never
|
|
375
|
+
* touched, by construction: it never appears in either set.
|
|
376
|
+
*
|
|
377
|
+
* Scoped restore (`opts.paths` provided) only checks out those paths from
|
|
378
|
+
* the target tree; it never removes files outside the given paths.
|
|
379
|
+
*
|
|
380
|
+
* Serialized against every other index-touching operation on this manager
|
|
381
|
+
* — see `withLock`. Without this, an auto-snapshot `create()` firing on a
|
|
382
|
+
* bus event could run its `git add -A` in between the `read-tree --reset`
|
|
383
|
+
* and `checkout-index -a -f` calls below, silently corrupting the restore.
|
|
384
|
+
*/
|
|
385
|
+
async restore(id, opts) {
|
|
386
|
+
await this.init();
|
|
387
|
+
return this.withLock(() => this.restoreInternal(id, opts));
|
|
388
|
+
}
|
|
389
|
+
async restoreInternal(id, opts) {
|
|
390
|
+
const target = this.requireCheckpoint(id);
|
|
391
|
+
const wantSafety = opts?.safetyCheckpoint ?? true;
|
|
392
|
+
let beforeFiles;
|
|
393
|
+
let safetyCheckpointId = null;
|
|
394
|
+
if (wantSafety) {
|
|
395
|
+
// Calls createInternal directly (not the public, lock-acquiring
|
|
396
|
+
// `create()`) — this whole method already holds the lock.
|
|
397
|
+
const safety = await this.createInternal({ kind: 'manual', label: `pre-restore safety (before ${id})`, retentionClass: 'forensic' });
|
|
398
|
+
safetyCheckpointId = safety?.id ?? null;
|
|
399
|
+
const current = safety ?? this.mostRecentCheckpoint();
|
|
400
|
+
beforeFiles = current ? await this.sideGit.listTrackedFiles(current.commit) : [];
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
await this.sideGit.stageAll();
|
|
404
|
+
const transientTree = await this.sideGit.writeTree();
|
|
405
|
+
beforeFiles = await this.sideGit.listTrackedFiles(transientTree);
|
|
406
|
+
}
|
|
407
|
+
if (opts?.paths && opts.paths.length > 0) {
|
|
408
|
+
const restoredFiles = [];
|
|
409
|
+
for (const path of opts.paths) {
|
|
410
|
+
try {
|
|
411
|
+
await this.sideGit.raw(['checkout', target.commit, '--', path]);
|
|
412
|
+
restoredFiles.push(path);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
logger.warn('WorkspaceCheckpointManager.restore: scoped path checkout failed', {
|
|
416
|
+
id,
|
|
417
|
+
path,
|
|
418
|
+
error: summarizeError(err),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { checkpointId: id, safetyCheckpointId, restoredFiles, removedFiles: [] };
|
|
423
|
+
}
|
|
424
|
+
const targetFiles = await this.sideGit.listTrackedFiles(target.commit);
|
|
425
|
+
const targetFileSet = new Set(targetFiles);
|
|
426
|
+
const removedFiles = beforeFiles.filter((path) => !targetFileSet.has(path));
|
|
427
|
+
await this.sideGit.readTreeReset(target.commit);
|
|
428
|
+
await this.sideGit.checkoutIndexAll();
|
|
429
|
+
for (const path of removedFiles) {
|
|
430
|
+
try {
|
|
431
|
+
rmSync(join(this.workspaceRoot, path), { force: true });
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
logger.warn('WorkspaceCheckpointManager.restore: failed to remove file added after checkpoint', {
|
|
435
|
+
id,
|
|
436
|
+
path,
|
|
437
|
+
error: summarizeError(err),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
logger.debug('WorkspaceCheckpointManager.restore: restored', {
|
|
442
|
+
id,
|
|
443
|
+
safetyCheckpointId,
|
|
444
|
+
restoredCount: targetFiles.length,
|
|
445
|
+
removedCount: removedFiles.length,
|
|
446
|
+
});
|
|
447
|
+
return { checkpointId: id, safetyCheckpointId, restoredFiles: targetFiles, removedFiles };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Apply retention limits: `RetentionPolicy` selects prune candidates,
|
|
451
|
+
* `WorkspaceCheckpointPruner` deletes their refs, then (only if anything
|
|
452
|
+
* was actually deleted) a single `git gc --prune=now` reclaims the now-
|
|
453
|
+
* unreachable objects. This never touches compaction's boundary commits —
|
|
454
|
+
* they are tracked in an entirely separate `RetentionPolicy` instance
|
|
455
|
+
* (../../runtime/compaction) with no shared state.
|
|
456
|
+
*
|
|
457
|
+
* Reclamation only works because checkpoint commits are parentless (see
|
|
458
|
+
* `SideGitRunner.commitTree`): a pruned ref's commit has no descendant
|
|
459
|
+
* keeping it reachable via a git parent pointer, so once its ref is
|
|
460
|
+
* deleted it is genuinely unreachable and `--prune=now` frees it.
|
|
461
|
+
*
|
|
462
|
+
* Serialized against every other index/object-store-touching operation on
|
|
463
|
+
* this manager — see `withLock`. Without this, a `create()` racing this
|
|
464
|
+
* method could write a loose commit object that isn't ref'd yet at the
|
|
465
|
+
* moment `--prune=now` runs, and lose it.
|
|
466
|
+
*/
|
|
467
|
+
async gc() {
|
|
468
|
+
await this.init();
|
|
469
|
+
return this.withLock(() => this.gcInternal());
|
|
470
|
+
}
|
|
471
|
+
async gcInternal() {
|
|
472
|
+
const result = await this.retentionPolicy.prune();
|
|
473
|
+
if (result.deletedCount > 0) {
|
|
474
|
+
await this.persistManifest();
|
|
475
|
+
await this.sideGit.gc();
|
|
476
|
+
}
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
/** Unsubscribe from the runtime bus. Does not touch anything on disk. */
|
|
480
|
+
dispose() {
|
|
481
|
+
for (const unsub of this.unsubscribers) {
|
|
482
|
+
try {
|
|
483
|
+
unsub();
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// best-effort
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
this.unsubscribers.length = 0;
|
|
490
|
+
}
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Private helpers
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
mostRecentCheckpoint() {
|
|
495
|
+
let latest = null;
|
|
496
|
+
for (const checkpoint of this.checkpoints.values()) {
|
|
497
|
+
if (!latest || checkpoint.createdAt > latest.createdAt) {
|
|
498
|
+
latest = checkpoint;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return latest;
|
|
502
|
+
}
|
|
503
|
+
requireCheckpoint(id) {
|
|
504
|
+
const checkpoint = this.checkpoints.get(id);
|
|
505
|
+
if (!checkpoint) {
|
|
506
|
+
throw new Error(`WorkspaceCheckpointManager: no checkpoint found with id "${id}"`);
|
|
507
|
+
}
|
|
508
|
+
return checkpoint;
|
|
509
|
+
}
|
|
510
|
+
defaultLabel(kind, id) {
|
|
511
|
+
if (kind === 'manual')
|
|
512
|
+
return id;
|
|
513
|
+
return `${kind} snapshot`;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Approximate incremental bytes introduced by a checkpoint: sum of on-disk
|
|
517
|
+
* sizes of the changed paths, read immediately after they were staged and
|
|
518
|
+
* committed (so they still reflect exactly the content just captured).
|
|
519
|
+
* Deleted paths (no longer on disk) contribute 0. This is deliberately not
|
|
520
|
+
* exact git object-store accounting — it exists for retention's `maxSizeBytes`
|
|
521
|
+
* bookkeeping, not for a byte-perfect audit.
|
|
522
|
+
*/
|
|
523
|
+
async computeSizeBytes(paths) {
|
|
524
|
+
let total = 0;
|
|
525
|
+
for (const path of paths) {
|
|
526
|
+
const absolute = join(this.workspaceRoot, path);
|
|
527
|
+
if (!existsSync(absolute))
|
|
528
|
+
continue;
|
|
529
|
+
try {
|
|
530
|
+
const info = await stat(absolute);
|
|
531
|
+
if (info.isFile())
|
|
532
|
+
total += info.size;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// Ignore races (file removed between listing and stat).
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return total;
|
|
539
|
+
}
|
|
540
|
+
async persistManifest() {
|
|
541
|
+
await this.manifestStore.save({ checkpoints: Array.from(this.checkpoints.values()) });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* side-git.ts
|
|
3
|
+
*
|
|
4
|
+
* SideGitRunner — a hidden git repository ("side repo") whose object store
|
|
5
|
+
* (GIT_DIR) lives under <workspaceRoot>/.goodvibes/checkpoints/git while its
|
|
6
|
+
* GIT_WORK_TREE is the live workspace itself.
|
|
7
|
+
*
|
|
8
|
+
* This is the dotfiles-bare-repo trick (`git --git-dir=X --work-tree=Y`):
|
|
9
|
+
* git tracks arbitrary files in Y using an object store rooted at X,
|
|
10
|
+
* completely independent of whatever real .git directory Y may or may not
|
|
11
|
+
* already have. It gives us, for free:
|
|
12
|
+
* - content-addressed, deduped storage (unchanged files cost ~nothing)
|
|
13
|
+
* - `git diff` / `git diff --stat` between any two snapshots
|
|
14
|
+
* - whole-tree restore via `read-tree` + `checkout-index`
|
|
15
|
+
* - correct behavior in a workspace that is NOT itself a git repo
|
|
16
|
+
*
|
|
17
|
+
* DO NOT reuse GitService (../../git/service.ts) for this: it binds a single
|
|
18
|
+
* baseDir with no GIT_DIR support, and it fires Pre/Post hook events on
|
|
19
|
+
* every commit/add — automatic silent snapshots must never trigger a user's
|
|
20
|
+
* PreCommit/PostCommit hooks. AgentWorktree (../../agents/worktree.ts) already
|
|
21
|
+
* proves the `simpleGit(...).raw([...])` + explicit env pattern used here.
|
|
22
|
+
*
|
|
23
|
+
* Checkpoints are addressed entirely through our own ref namespace
|
|
24
|
+
* (refs/goodvibes/checkpoints/<id>) and through commit objects created via
|
|
25
|
+
* `commit-tree`, never through the side repo's HEAD/branch. There is no
|
|
26
|
+
* meaningful "current branch" in this design — parent/lineage is tracked in
|
|
27
|
+
* the manifest (manager.ts), not via git HEAD — so there is nothing to leave
|
|
28
|
+
* "detached" and no branch state to pollute.
|
|
29
|
+
*/
|
|
30
|
+
/** Git's well-known empty-tree object hash, valid in every repository. */
|
|
31
|
+
export declare const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
32
|
+
/** Ref namespace all workspace checkpoints live under. */
|
|
33
|
+
export declare const CHECKPOINT_REF_PREFIX = "refs/goodvibes/checkpoints/";
|
|
34
|
+
export interface SideGitRunnerOptions {
|
|
35
|
+
/** Absolute path to the live workspace (GIT_WORK_TREE). */
|
|
36
|
+
readonly workspaceRoot: string;
|
|
37
|
+
/** Absolute path to the side repo's object store (GIT_DIR). */
|
|
38
|
+
readonly gitDir: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Thin runner around a `simple-git` instance permanently scoped (via `.env()`)
|
|
42
|
+
* to an isolated GIT_DIR/GIT_WORK_TREE pair. Every method here is a small,
|
|
43
|
+
* named wrapper around a raw git invocation — no hook emission, no shared
|
|
44
|
+
* state with the user's real repository.
|
|
45
|
+
*/
|
|
46
|
+
export declare class SideGitRunner {
|
|
47
|
+
readonly workspaceRoot: string;
|
|
48
|
+
readonly gitDir: string;
|
|
49
|
+
private readonly git;
|
|
50
|
+
constructor(opts: SideGitRunnerOptions);
|
|
51
|
+
/** Run an arbitrary raw git command against the side repo, returning stdout. */
|
|
52
|
+
raw(args: string[]): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Idempotently initialize the side repo: `git init` the GIT_DIR if it does
|
|
55
|
+
* not already look initialized, set a local (side-repo-only) fallback
|
|
56
|
+
* identity so commits never depend on the user's global git config, and
|
|
57
|
+
* ensure `.goodvibes` is gitignored from the user's own repo's perspective.
|
|
58
|
+
*/
|
|
59
|
+
init(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Ensure `<workspaceRoot>/.goodvibes/.gitignore` contains a bare `*` line so
|
|
62
|
+
* the side repo's own storage (and every other tool's state under
|
|
63
|
+
* `.goodvibes`) is invisible to the user's OWN git repo (if any). Without
|
|
64
|
+
* this, `git status`/`git add -A` in the user's real repo would see our
|
|
65
|
+
* GIT_DIR as an untracked directory and could accidentally stage it.
|
|
66
|
+
*
|
|
67
|
+
* This intentionally only ever writes inside `.goodvibes/` — it never
|
|
68
|
+
* touches the workspace's own top-level `.gitignore`.
|
|
69
|
+
*/
|
|
70
|
+
private ensureGoodvibesIgnored;
|
|
71
|
+
/**
|
|
72
|
+
* Stage changes into the side index.
|
|
73
|
+
*
|
|
74
|
+
* @param paths When provided (non-empty), stage only these pathspecs
|
|
75
|
+
* (scoped snapshot). Otherwise sweep the whole work tree, always excluding
|
|
76
|
+
* `.goodvibes` (our own storage) via pathspec magic — the same
|
|
77
|
+
* `:(exclude)` pattern AgentWorktree already uses for the same reason.
|
|
78
|
+
* Beyond that exclusion, git's normal `.gitignore` handling already applies
|
|
79
|
+
* here: `.gitignore` matching is a work-tree-relative feature of git and
|
|
80
|
+
* works identically regardless of where GIT_DIR points, so the workspace's
|
|
81
|
+
* own `.gitignore` (node_modules, build output, etc.) is honored with no
|
|
82
|
+
* extra configuration.
|
|
83
|
+
*/
|
|
84
|
+
stageAll(paths?: string[]): Promise<void>;
|
|
85
|
+
/** Write the currently-staged index out as a tree object, without committing. Returns the tree hash. */
|
|
86
|
+
writeTree(): Promise<string>;
|
|
87
|
+
/** Resolve `<commit>^{tree}` for an existing commit hash. */
|
|
88
|
+
treeOf(commit: string): Promise<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Create a commit object from a tree, deliberately WITHOUT a git parent
|
|
91
|
+
* (no `-p`) and WITHOUT moving any branch/HEAD.
|
|
92
|
+
*
|
|
93
|
+
* Checkpoint lineage is tracked exclusively via the manifest's `parentId`
|
|
94
|
+
* field (manager.ts) — never via git ancestry. This isn't just a style
|
|
95
|
+
* choice: if checkpoint commits chained via `-p` the way a normal git
|
|
96
|
+
* history does, deleting an OLD checkpoint's ref in `gc()` would free
|
|
97
|
+
* nothing, because that commit stays reachable through every NEWER
|
|
98
|
+
* checkpoint's parent pointer — the ref is gone but the commit (and any
|
|
99
|
+
* tree/blob objects unique to it) is still walkable from every surviving
|
|
100
|
+
* descendant. Parentless commits mean a checkpoint's own ref is the ONLY
|
|
101
|
+
* thing keeping its commit reachable, so once that ref is deleted,
|
|
102
|
+
* `git gc --prune=now` can genuinely reclaim it.
|
|
103
|
+
*
|
|
104
|
+
* The returned hash is only reachable once a ref is pointed at it via
|
|
105
|
+
* `updateRef`.
|
|
106
|
+
*/
|
|
107
|
+
commitTree(treeHash: string, message: string): Promise<string>;
|
|
108
|
+
updateRef(refName: string, commit: string): Promise<void>;
|
|
109
|
+
deleteRef(refName: string): Promise<void>;
|
|
110
|
+
/** List every ref under CHECKPOINT_REF_PREFIX as `{ id, commit }`. */
|
|
111
|
+
listCheckpointRefs(): Promise<{
|
|
112
|
+
id: string;
|
|
113
|
+
commit: string;
|
|
114
|
+
}[]>;
|
|
115
|
+
/** Files tracked in a commit's tree (recursive, name-only). */
|
|
116
|
+
listTrackedFiles(commitOrTree: string): Promise<string[]>;
|
|
117
|
+
/** Reset the side index to exactly match a commit's tree (does not touch the working tree). */
|
|
118
|
+
readTreeReset(commit: string): Promise<void>;
|
|
119
|
+
/** Write every file currently in the side index out to the working tree, overwriting existing files. */
|
|
120
|
+
checkoutIndexAll(): Promise<void>;
|
|
121
|
+
/** `git diff` between two commit-ish values. Omit `to` to diff against the live working tree. */
|
|
122
|
+
diff(from: string, to?: string): Promise<string>;
|
|
123
|
+
/** `git diff --stat` between two commit-ish values. Omit `to` to diff against the live working tree. */
|
|
124
|
+
diffStat(from: string, to?: string): Promise<string>;
|
|
125
|
+
/** `git diff --name-only` between two commit-ish values. Omit `to` to diff against the live working tree. */
|
|
126
|
+
diffNameOnly(from: string, to?: string): Promise<string[]>;
|
|
127
|
+
/** `git gc --prune=now` on the side repo. */
|
|
128
|
+
gc(): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=side-git.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"side-git.d.ts","sourceRoot":"","sources":["../../../../src/platform/workspace/checkpoint/side-git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAQH,0EAA0E;AAC1E,eAAO,MAAM,eAAe,6CAA6C,CAAC;AAE1E,0DAA0D;AAC1D,eAAO,MAAM,qBAAqB,gCAAgC,CAAC;AA8CnE,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,qBAAa,aAAa;IACxB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAY;gBAEpB,IAAI,EAAE,oBAAoB;IAUtC,gFAAgF;IAC1E,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAI1C;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAkB3B;;;;;;;;;OASG;IACH,OAAO,CAAC,sBAAsB;IAuB9B;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/C,wGAAwG;IAClG,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;IAIlC,6DAA6D;IACvD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI7C;;;;;;;;;;;;;;;;;OAiBG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI9D,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzD,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/C,sEAAsE;IAChE,kBAAkB,IAAI,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAmBrE,+DAA+D;IACzD,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAK/D,+FAA+F;IACzF,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD,wGAAwG;IAClG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAIvC,iGAAiG;IAC3F,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAItD,wGAAwG;IAClG,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI1D,6GAA6G;IACvG,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAKhE,6CAA6C;IACvC,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;CAG1B"}
|