@pellux/goodvibes-sdk 0.36.0 → 0.37.0

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.
Files changed (39) hide show
  1. package/dist/contracts/artifacts/operator-contract.json +1 -1
  2. package/dist/platform/core/compaction-sections.d.ts +8 -0
  3. package/dist/platform/core/compaction-sections.d.ts.map +1 -1
  4. package/dist/platform/core/compaction-sections.js +71 -2
  5. package/dist/platform/core/context-compaction.d.ts +13 -0
  6. package/dist/platform/core/context-compaction.d.ts.map +1 -1
  7. package/dist/platform/core/context-compaction.js +24 -2
  8. package/dist/platform/core/orchestrator-context-runtime.d.ts.map +1 -1
  9. package/dist/platform/core/orchestrator-context-runtime.js +1 -2
  10. package/dist/platform/core/orchestrator-tool-runtime.d.ts.map +1 -1
  11. package/dist/platform/core/orchestrator-tool-runtime.js +30 -2
  12. package/dist/platform/permissions/manager.d.ts.map +1 -1
  13. package/dist/platform/permissions/manager.js +3 -2
  14. package/dist/platform/permissions/prompt.d.ts +8 -1
  15. package/dist/platform/permissions/prompt.d.ts.map +1 -1
  16. package/dist/platform/permissions/types.d.ts +6 -0
  17. package/dist/platform/permissions/types.d.ts.map +1 -1
  18. package/dist/platform/runtime/services.d.ts +2 -0
  19. package/dist/platform/runtime/services.d.ts.map +1 -1
  20. package/dist/platform/runtime/services.js +15 -0
  21. package/dist/platform/tools/index.d.ts +1 -0
  22. package/dist/platform/tools/index.d.ts.map +1 -1
  23. package/dist/platform/version.js +1 -1
  24. package/dist/platform/workspace/checkpoint/index.d.ts +9 -0
  25. package/dist/platform/workspace/checkpoint/index.d.ts.map +1 -0
  26. package/dist/platform/workspace/checkpoint/index.js +7 -0
  27. package/dist/platform/workspace/checkpoint/manager.d.ts +163 -0
  28. package/dist/platform/workspace/checkpoint/manager.d.ts.map +1 -0
  29. package/dist/platform/workspace/checkpoint/manager.js +473 -0
  30. package/dist/platform/workspace/checkpoint/side-git.d.ts +117 -0
  31. package/dist/platform/workspace/checkpoint/side-git.d.ts.map +1 -0
  32. package/dist/platform/workspace/checkpoint/side-git.js +254 -0
  33. package/dist/platform/workspace/checkpoint/types.d.ts +96 -0
  34. package/dist/platform/workspace/checkpoint/types.d.ts.map +1 -0
  35. package/dist/platform/workspace/checkpoint/types.js +20 -0
  36. package/dist/platform/workspace/index.d.ts +1 -0
  37. package/dist/platform/workspace/index.d.ts.map +1 -1
  38. package/dist/platform/workspace/index.js +1 -0
  39. package/package.json +9 -9
@@ -0,0 +1,163 @@
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 type { RuntimeEventBus } from '../../runtime/events/index.js';
29
+ import { type RetentionConfig, type RetentionClass, type PruneResult } from '../../runtime/retention/index.js';
30
+ import type { WorkspaceCheckpoint, CheckpointKind, CheckpointDiff, RestoreResult } from './types.js';
31
+ export interface CreateCheckpointOptions {
32
+ readonly kind: CheckpointKind;
33
+ readonly label?: string | undefined;
34
+ readonly retentionClass?: RetentionClass | undefined;
35
+ readonly turnId?: string | undefined;
36
+ readonly agentId?: string | undefined;
37
+ /** Scope the snapshot to these paths instead of sweeping the whole workspace. */
38
+ readonly paths?: string[] | undefined;
39
+ }
40
+ export interface RestoreOptions {
41
+ /** Restrict restore to these paths instead of the whole workspace. */
42
+ readonly paths?: string[] | undefined;
43
+ /** Take a safety checkpoint of the current state before restoring. Defaults to true. */
44
+ readonly safetyCheckpoint?: boolean | undefined;
45
+ }
46
+ export interface ListCheckpointsFilter {
47
+ readonly kind?: CheckpointKind | undefined;
48
+ readonly since?: number | undefined;
49
+ readonly limit?: number | undefined;
50
+ }
51
+ export interface WorkspaceCheckpointManagerOptions {
52
+ readonly workspaceRoot: string;
53
+ /** Override the side repo's GIT_DIR. Defaults to `<workspaceRoot>/.goodvibes/checkpoints/git`. */
54
+ readonly checkpointDir?: string | undefined;
55
+ /** When provided, the manager subscribes to TURN_COMPLETED/TURN_ERROR/TURN_CANCEL/AGENT_COMPLETED for automatic snapshots. */
56
+ readonly runtimeBus?: RuntimeEventBus | undefined;
57
+ readonly retention?: Partial<RetentionConfig> | undefined;
58
+ /** Clock override for deterministic tests. */
59
+ readonly now?: (() => number) | undefined;
60
+ }
61
+ /**
62
+ * WorkspaceCheckpointManager — create/list/diff/restore/gc for whole-workspace
63
+ * git-backed snapshots, plus automatic snapshotting on existing turn/agent
64
+ * lifecycle events.
65
+ */
66
+ export declare class WorkspaceCheckpointManager {
67
+ readonly workspaceRoot: string;
68
+ private readonly checkpointRootDir;
69
+ private readonly sideGit;
70
+ private readonly manifestStore;
71
+ private readonly retentionPolicy;
72
+ private readonly now;
73
+ private readonly runtimeBus;
74
+ private readonly unsubscribers;
75
+ private checkpoints;
76
+ private initialized;
77
+ private initPromise;
78
+ constructor(opts: WorkspaceCheckpointManagerOptions);
79
+ /**
80
+ * Idempotent setup: init the side repo, load the manifest (re-hydrating the
81
+ * in-memory RetentionPolicy so retention state survives process restarts),
82
+ * and subscribe to automatic-snapshot events if a runtime bus was provided.
83
+ * Safe to call multiple times; concurrent callers share one in-flight init.
84
+ */
85
+ init(): Promise<void>;
86
+ private _init;
87
+ /**
88
+ * Subscribes to the EXISTING turn/agent lifecycle events — no new event
89
+ * types are introduced. A snapshot is taken at the boundary AFTER each
90
+ * turn/agent-run finishes, meaning "revert everything turn N did" means
91
+ * restoring the checkpoint captured BEFORE turn N, i.e. checkpoint[N-1] in
92
+ * `list()`'s (newest-first) ordering — an intentional off-by-one, documented
93
+ * here rather than hidden: this module always snapshots "where things ended
94
+ * up", never "where things started".
95
+ *
96
+ * Listener bodies are wrapped so a failure NEVER throws back into the bus:
97
+ * `RuntimeEventBus.emit()` only catches synchronous throws from a listener,
98
+ * not rejections from a returned (but un-awaited) promise, so every
99
+ * auto-snapshot call here is deliberately `.catch()`-guarded.
100
+ */
101
+ private subscribeToAutomaticSnapshots;
102
+ /**
103
+ * Create a new checkpoint. Returns `null` (a cheap no-op) when the current
104
+ * workspace tree is identical to the parent checkpoint's tree — no commit,
105
+ * no ref, no manifest entry is created in that case.
106
+ */
107
+ create(opts: CreateCheckpointOptions): Promise<WorkspaceCheckpoint | null>;
108
+ /** List checkpoints, newest-first. `list()[0]` is always the most recent checkpoint. */
109
+ list(filter?: ListCheckpointsFilter): Promise<WorkspaceCheckpoint[]>;
110
+ /**
111
+ * Diff two checkpoints, or a checkpoint against the live working tree when
112
+ * `b` is omitted.
113
+ */
114
+ diff(a: string, b?: string): Promise<CheckpointDiff>;
115
+ /**
116
+ * Restore the workspace to the state captured by checkpoint `id`.
117
+ *
118
+ * By default (`safetyCheckpoint: true`) takes a checkpoint of the CURRENT
119
+ * state first, so a restore is itself undoable via another restore.
120
+ *
121
+ * Whole-workspace restore (no `opts.paths`):
122
+ * 1. Snapshot the current tracked-file set (via the safety checkpoint, or
123
+ * a transient write-tree when `safetyCheckpoint: false`) — this is the
124
+ * "before" set.
125
+ * 2. Reset the side index to the target checkpoint's tree and check every
126
+ * file in it out to disk (re-creates anything the checkpoint had that
127
+ * is currently missing or modified).
128
+ * 3. Remove exactly the files that were in the "before" set but are NOT
129
+ * in the target checkpoint's tree (files created/tracked after the
130
+ * checkpoint). Anything NOT in the "before" set — i.e. any untracked
131
+ * path outside what this engine has ever snapshotted — is never
132
+ * touched, by construction: it never appears in either set.
133
+ *
134
+ * Scoped restore (`opts.paths` provided) only checks out those paths from
135
+ * the target tree; it never removes files outside the given paths.
136
+ */
137
+ restore(id: string, opts?: RestoreOptions): Promise<RestoreResult>;
138
+ /**
139
+ * Apply retention limits: `RetentionPolicy` selects prune candidates,
140
+ * `WorkspaceCheckpointPruner` deletes their refs, then (only if anything
141
+ * was actually deleted) a single `git gc --prune=now` reclaims the now-
142
+ * unreachable objects. This never touches compaction's boundary commits —
143
+ * they are tracked in an entirely separate `RetentionPolicy` instance
144
+ * (../../runtime/compaction) with no shared state.
145
+ */
146
+ gc(): Promise<PruneResult>;
147
+ /** Unsubscribe from the runtime bus. Does not touch anything on disk. */
148
+ dispose(): void;
149
+ private mostRecentCheckpoint;
150
+ private requireCheckpoint;
151
+ private defaultLabel;
152
+ /**
153
+ * Approximate incremental bytes introduced by a checkpoint: sum of on-disk
154
+ * sizes of the changed paths, read immediately after they were staged and
155
+ * committed (so they still reflect exactly the content just captured).
156
+ * Deleted paths (no longer on disk) contribute 0. This is deliberately not
157
+ * exact git object-store accounting — it exists for retention's `maxSizeBytes`
158
+ * bookkeeping, not for a byte-perfect audit.
159
+ */
160
+ private computeSizeBytes;
161
+ private persistManifest;
162
+ }
163
+ //# sourceMappingURL=manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../../../src/platform/workspace/checkpoint/manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AASH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAGrE,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,cAAc,EAGnB,KAAK,WAAW,EAEjB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAuFrG,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IACrD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,iFAAiF;IACjF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IACtC,wFAAwF;IACxF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACjD;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;IAC3C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED,MAAM,WAAW,iCAAiC;IAChD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,kGAAkG;IAClG,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,8HAA8H;IAC9H,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC;IAClD,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,GAAG,SAAS,CAAC;IAC1D,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;CAC3C;AAED;;;;GAIG;AACH,qBAAa,0BAA0B;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA0B;IACxD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkB;IAClD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA8B;IACzD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAsB;IAEpD,OAAO,CAAC,WAAW,CAA0C;IAC7D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAA8B;gBAErC,IAAI,EAAE,iCAAiC;IAiBnD;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAQb,KAAK;IAuBnB;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,6BAA6B;IAuCrC;;;;OAIG;IACG,MAAM,CAAC,IAAI,EAAE,uBAAuB,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAwDhF,wFAAwF;IAClF,IAAI,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAS1E;;;OAGG;IACG,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAoB1D;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAgExE;;;;;;;OAOG;IACG,EAAE,IAAI,OAAO,CAAC,WAAW,CAAC;IAUhC,yEAAyE;IACzE,OAAO,IAAI,IAAI;IAef,OAAO,CAAC,oBAAoB;IAU5B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,YAAY;IAKpB;;;;;;;OAOG;YACW,gBAAgB;YAehB,eAAe;CAG9B"}
@@ -0,0 +1,473 @@
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
+ constructor(opts) {
134
+ this.workspaceRoot = opts.workspaceRoot;
135
+ this.checkpointRootDir = opts.checkpointDir ?? join(opts.workspaceRoot, '.goodvibes', 'checkpoints');
136
+ this.sideGit = new SideGitRunner({
137
+ workspaceRoot: opts.workspaceRoot,
138
+ gitDir: join(this.checkpointRootDir, 'git'),
139
+ });
140
+ this.manifestStore = new JsonFileStore(join(this.checkpointRootDir, 'index.json'));
141
+ this.now = opts.now ?? Date.now;
142
+ this.runtimeBus = opts.runtimeBus;
143
+ this.retentionPolicy = new RetentionPolicy(opts.retention, this.now, new WorkspaceCheckpointPruner(this.sideGit, (id) => this.checkpoints.delete(id)));
144
+ }
145
+ /**
146
+ * Idempotent setup: init the side repo, load the manifest (re-hydrating the
147
+ * in-memory RetentionPolicy so retention state survives process restarts),
148
+ * and subscribe to automatic-snapshot events if a runtime bus was provided.
149
+ * Safe to call multiple times; concurrent callers share one in-flight init.
150
+ */
151
+ async init() {
152
+ if (this.initialized)
153
+ return;
154
+ if (!this.initPromise) {
155
+ this.initPromise = this._init();
156
+ }
157
+ await this.initPromise;
158
+ }
159
+ async _init() {
160
+ await this.sideGit.init();
161
+ const manifest = await this.manifestStore.load();
162
+ this.checkpoints = new Map((manifest?.checkpoints ?? []).map((c) => [c.id, c]));
163
+ for (const checkpoint of this.checkpoints.values()) {
164
+ this.retentionPolicy.register({
165
+ id: checkpoint.id,
166
+ createdAt: checkpoint.createdAt,
167
+ sizeBytes: checkpoint.sizeBytes,
168
+ retentionClass: checkpoint.retentionClass,
169
+ path: this.sideGit.gitDir,
170
+ });
171
+ }
172
+ if (this.runtimeBus) {
173
+ this.subscribeToAutomaticSnapshots(this.runtimeBus);
174
+ }
175
+ this.initialized = true;
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Automatic snapshots
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * Subscribes to the EXISTING turn/agent lifecycle events — no new event
182
+ * types are introduced. A snapshot is taken at the boundary AFTER each
183
+ * turn/agent-run finishes, meaning "revert everything turn N did" means
184
+ * restoring the checkpoint captured BEFORE turn N, i.e. checkpoint[N-1] in
185
+ * `list()`'s (newest-first) ordering — an intentional off-by-one, documented
186
+ * here rather than hidden: this module always snapshots "where things ended
187
+ * up", never "where things started".
188
+ *
189
+ * Listener bodies are wrapped so a failure NEVER throws back into the bus:
190
+ * `RuntimeEventBus.emit()` only catches synchronous throws from a listener,
191
+ * not rejections from a returned (but un-awaited) promise, so every
192
+ * auto-snapshot call here is deliberately `.catch()`-guarded.
193
+ */
194
+ subscribeToAutomaticSnapshots(bus) {
195
+ const snapshotTurn = (turnId, reason) => {
196
+ this.create({ kind: 'turn', turnId, retentionClass: 'standard', label: `turn ${reason}` }).catch((err) => {
197
+ logger.warn('WorkspaceCheckpointManager: automatic turn snapshot failed', {
198
+ turnId,
199
+ reason,
200
+ error: summarizeError(err),
201
+ });
202
+ });
203
+ };
204
+ const snapshotAgentRun = (agentId) => {
205
+ this.create({ kind: 'agent-run', agentId, retentionClass: 'standard', label: 'agent run' }).catch((err) => {
206
+ logger.warn('WorkspaceCheckpointManager: automatic agent-run snapshot failed', {
207
+ agentId,
208
+ error: summarizeError(err),
209
+ });
210
+ });
211
+ };
212
+ this.unsubscribers.push(bus.on('TURN_COMPLETED', ({ payload }) => {
213
+ snapshotTurn(payload.turnId, 'completed');
214
+ }), bus.on('TURN_ERROR', ({ payload }) => {
215
+ snapshotTurn(payload.turnId, 'error');
216
+ }), bus.on('TURN_CANCEL', ({ payload }) => {
217
+ snapshotTurn(payload.turnId, 'cancelled');
218
+ }), bus.on('AGENT_COMPLETED', ({ payload }) => {
219
+ snapshotAgentRun(payload.agentId);
220
+ }));
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Public API
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * Create a new checkpoint. Returns `null` (a cheap no-op) when the current
227
+ * workspace tree is identical to the parent checkpoint's tree — no commit,
228
+ * no ref, no manifest entry is created in that case.
229
+ */
230
+ async create(opts) {
231
+ await this.init();
232
+ await this.sideGit.stageAll(opts.paths);
233
+ const treeHash = await this.sideGit.writeTree();
234
+ const parent = this.mostRecentCheckpoint();
235
+ const parentTree = parent ? await this.sideGit.treeOf(parent.commit) : EMPTY_TREE_HASH;
236
+ if (parentTree === treeHash) {
237
+ logger.debug('WorkspaceCheckpointManager.create: tree unchanged since parent, no-op', {
238
+ kind: opts.kind,
239
+ parentId: parent?.id ?? null,
240
+ });
241
+ return null;
242
+ }
243
+ const id = generateCheckpointId(this.now);
244
+ const kind = opts.kind;
245
+ const retentionClass = opts.retentionClass ?? defaultRetentionClassFor(kind);
246
+ const label = opts.label ?? this.defaultLabel(kind, id);
247
+ const message = `wcp: ${kind} ${label}`.trim();
248
+ const commit = await this.sideGit.commitTree(treeHash, message, parent?.commit ?? null);
249
+ await this.sideGit.updateRef(`${CHECKPOINT_REF_PREFIX}${id}`, commit);
250
+ const changedPaths = parent
251
+ ? await this.sideGit.diffNameOnly(parent.commit, commit)
252
+ : await this.sideGit.listTrackedFiles(commit);
253
+ const sizeBytes = await this.computeSizeBytes(changedPaths);
254
+ const checkpoint = {
255
+ id,
256
+ kind,
257
+ label,
258
+ createdAt: this.now(),
259
+ parentId: parent?.id ?? null,
260
+ turnId: opts.turnId,
261
+ agentId: opts.agentId,
262
+ retentionClass,
263
+ commit,
264
+ sizeBytes,
265
+ };
266
+ this.checkpoints.set(id, checkpoint);
267
+ this.retentionPolicy.register({
268
+ id,
269
+ createdAt: checkpoint.createdAt,
270
+ sizeBytes,
271
+ retentionClass,
272
+ path: this.sideGit.gitDir,
273
+ });
274
+ await this.persistManifest();
275
+ logger.debug('WorkspaceCheckpointManager.create: checkpoint created', { id, kind, parentId: checkpoint.parentId });
276
+ return checkpoint;
277
+ }
278
+ /** List checkpoints, newest-first. `list()[0]` is always the most recent checkpoint. */
279
+ async list(filter) {
280
+ await this.init();
281
+ let results = Array.from(this.checkpoints.values()).sort((a, b) => b.createdAt - a.createdAt);
282
+ if (filter?.kind)
283
+ results = results.filter((c) => c.kind === filter.kind);
284
+ if (filter?.since != null)
285
+ results = results.filter((c) => c.createdAt >= filter.since);
286
+ if (filter?.limit != null)
287
+ results = results.slice(0, filter.limit);
288
+ return results;
289
+ }
290
+ /**
291
+ * Diff two checkpoints, or a checkpoint against the live working tree when
292
+ * `b` is omitted.
293
+ */
294
+ async diff(a, b) {
295
+ await this.init();
296
+ const fromCheckpoint = this.requireCheckpoint(a);
297
+ const toCheckpoint = b ? this.requireCheckpoint(b) : undefined;
298
+ const [unifiedDiff, stat_, files] = await Promise.all([
299
+ this.sideGit.diff(fromCheckpoint.commit, toCheckpoint?.commit),
300
+ this.sideGit.diffStat(fromCheckpoint.commit, toCheckpoint?.commit),
301
+ this.sideGit.diffNameOnly(fromCheckpoint.commit, toCheckpoint?.commit),
302
+ ]);
303
+ return {
304
+ from: a,
305
+ to: b ?? 'WORKING',
306
+ files,
307
+ unifiedDiff,
308
+ stat: stat_,
309
+ };
310
+ }
311
+ /**
312
+ * Restore the workspace to the state captured by checkpoint `id`.
313
+ *
314
+ * By default (`safetyCheckpoint: true`) takes a checkpoint of the CURRENT
315
+ * state first, so a restore is itself undoable via another restore.
316
+ *
317
+ * Whole-workspace restore (no `opts.paths`):
318
+ * 1. Snapshot the current tracked-file set (via the safety checkpoint, or
319
+ * a transient write-tree when `safetyCheckpoint: false`) — this is the
320
+ * "before" set.
321
+ * 2. Reset the side index to the target checkpoint's tree and check every
322
+ * file in it out to disk (re-creates anything the checkpoint had that
323
+ * is currently missing or modified).
324
+ * 3. Remove exactly the files that were in the "before" set but are NOT
325
+ * in the target checkpoint's tree (files created/tracked after the
326
+ * checkpoint). Anything NOT in the "before" set — i.e. any untracked
327
+ * path outside what this engine has ever snapshotted — is never
328
+ * touched, by construction: it never appears in either set.
329
+ *
330
+ * Scoped restore (`opts.paths` provided) only checks out those paths from
331
+ * the target tree; it never removes files outside the given paths.
332
+ */
333
+ async restore(id, opts) {
334
+ await this.init();
335
+ const target = this.requireCheckpoint(id);
336
+ const wantSafety = opts?.safetyCheckpoint ?? true;
337
+ let beforeFiles;
338
+ let safetyCheckpointId = null;
339
+ if (wantSafety) {
340
+ const safety = await this.create({ kind: 'manual', label: `pre-restore safety (before ${id})`, retentionClass: 'forensic' });
341
+ safetyCheckpointId = safety?.id ?? null;
342
+ const current = safety ?? this.mostRecentCheckpoint();
343
+ beforeFiles = current ? await this.sideGit.listTrackedFiles(current.commit) : [];
344
+ }
345
+ else {
346
+ await this.sideGit.stageAll();
347
+ const transientTree = await this.sideGit.writeTree();
348
+ beforeFiles = await this.sideGit.listTrackedFiles(transientTree);
349
+ }
350
+ if (opts?.paths && opts.paths.length > 0) {
351
+ const restoredFiles = [];
352
+ for (const path of opts.paths) {
353
+ try {
354
+ await this.sideGit.raw(['checkout', target.commit, '--', path]);
355
+ restoredFiles.push(path);
356
+ }
357
+ catch (err) {
358
+ logger.warn('WorkspaceCheckpointManager.restore: scoped path checkout failed', {
359
+ id,
360
+ path,
361
+ error: summarizeError(err),
362
+ });
363
+ }
364
+ }
365
+ return { checkpointId: id, safetyCheckpointId, restoredFiles, removedFiles: [] };
366
+ }
367
+ const targetFiles = await this.sideGit.listTrackedFiles(target.commit);
368
+ const targetFileSet = new Set(targetFiles);
369
+ const removedFiles = beforeFiles.filter((path) => !targetFileSet.has(path));
370
+ await this.sideGit.readTreeReset(target.commit);
371
+ await this.sideGit.checkoutIndexAll();
372
+ for (const path of removedFiles) {
373
+ try {
374
+ rmSync(join(this.workspaceRoot, path), { force: true });
375
+ }
376
+ catch (err) {
377
+ logger.warn('WorkspaceCheckpointManager.restore: failed to remove file added after checkpoint', {
378
+ id,
379
+ path,
380
+ error: summarizeError(err),
381
+ });
382
+ }
383
+ }
384
+ logger.debug('WorkspaceCheckpointManager.restore: restored', {
385
+ id,
386
+ safetyCheckpointId,
387
+ restoredCount: targetFiles.length,
388
+ removedCount: removedFiles.length,
389
+ });
390
+ return { checkpointId: id, safetyCheckpointId, restoredFiles: targetFiles, removedFiles };
391
+ }
392
+ /**
393
+ * Apply retention limits: `RetentionPolicy` selects prune candidates,
394
+ * `WorkspaceCheckpointPruner` deletes their refs, then (only if anything
395
+ * was actually deleted) a single `git gc --prune=now` reclaims the now-
396
+ * unreachable objects. This never touches compaction's boundary commits —
397
+ * they are tracked in an entirely separate `RetentionPolicy` instance
398
+ * (../../runtime/compaction) with no shared state.
399
+ */
400
+ async gc() {
401
+ await this.init();
402
+ const result = await this.retentionPolicy.prune();
403
+ if (result.deletedCount > 0) {
404
+ await this.persistManifest();
405
+ await this.sideGit.gc();
406
+ }
407
+ return result;
408
+ }
409
+ /** Unsubscribe from the runtime bus. Does not touch anything on disk. */
410
+ dispose() {
411
+ for (const unsub of this.unsubscribers) {
412
+ try {
413
+ unsub();
414
+ }
415
+ catch {
416
+ // best-effort
417
+ }
418
+ }
419
+ this.unsubscribers.length = 0;
420
+ }
421
+ // ---------------------------------------------------------------------------
422
+ // Private helpers
423
+ // ---------------------------------------------------------------------------
424
+ mostRecentCheckpoint() {
425
+ let latest = null;
426
+ for (const checkpoint of this.checkpoints.values()) {
427
+ if (!latest || checkpoint.createdAt > latest.createdAt) {
428
+ latest = checkpoint;
429
+ }
430
+ }
431
+ return latest;
432
+ }
433
+ requireCheckpoint(id) {
434
+ const checkpoint = this.checkpoints.get(id);
435
+ if (!checkpoint) {
436
+ throw new Error(`WorkspaceCheckpointManager: no checkpoint found with id "${id}"`);
437
+ }
438
+ return checkpoint;
439
+ }
440
+ defaultLabel(kind, id) {
441
+ if (kind === 'manual')
442
+ return id;
443
+ return `${kind} snapshot`;
444
+ }
445
+ /**
446
+ * Approximate incremental bytes introduced by a checkpoint: sum of on-disk
447
+ * sizes of the changed paths, read immediately after they were staged and
448
+ * committed (so they still reflect exactly the content just captured).
449
+ * Deleted paths (no longer on disk) contribute 0. This is deliberately not
450
+ * exact git object-store accounting — it exists for retention's `maxSizeBytes`
451
+ * bookkeeping, not for a byte-perfect audit.
452
+ */
453
+ async computeSizeBytes(paths) {
454
+ let total = 0;
455
+ for (const path of paths) {
456
+ const absolute = join(this.workspaceRoot, path);
457
+ if (!existsSync(absolute))
458
+ continue;
459
+ try {
460
+ const info = await stat(absolute);
461
+ if (info.isFile())
462
+ total += info.size;
463
+ }
464
+ catch {
465
+ // Ignore races (file removed between listing and stat).
466
+ }
467
+ }
468
+ return total;
469
+ }
470
+ async persistManifest() {
471
+ await this.manifestStore.save({ checkpoints: Array.from(this.checkpoints.values()) });
472
+ }
473
+ }