@goodandready/dsh-time-machine 0.1.4 → 0.1.6

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/lib/client.js CHANGED
@@ -53,6 +53,8 @@ window.__ModuleLoader__.load({
53
53
  empty: 'No checkpoints yet.',
54
54
  diff: 'Diff',
55
55
  rollback: 'Rollback',
56
+ delete: 'Delete',
57
+ confirmDelete: 'Delete this checkpoint?',
56
58
  confirmRollback: 'Rollback to this checkpoint? This will reset the workspace.',
57
59
  close: 'Close',
58
60
  save: 'Save',
@@ -73,6 +75,8 @@ window.__ModuleLoader__.load({
73
75
  empty: 'Чекпоинтов пока нет.',
74
76
  diff: 'Diff',
75
77
  rollback: 'Откатить',
78
+ delete: 'Удалить',
79
+ confirmDelete: 'Удалить этот чекпоинт?',
76
80
  confirmRollback: 'Откатиться к этому чекпоинту? Рабочая копия будет сброшена.',
77
81
  close: 'Закрыть',
78
82
  save: 'Сохранить',
@@ -164,6 +168,15 @@ window.__ModuleLoader__.load({
164
168
  fetchList();
165
169
  }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
166
170
  };
171
+ const onDelete = (id) => {
172
+ if (!window.confirm(t.confirmDelete)) return;
173
+ setBusy(true); setErr('');
174
+ fetch('/dsh-time-machine/delete', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
175
+ .then(r => r.json()).then(j => {
176
+ if (!j.success) throw new Error(j.error || 'delete failed');
177
+ fetchList();
178
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
179
+ };
167
180
 
168
181
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
169
182
  React.createElement('div', { style: { display: 'flex', gap: 8 } },
@@ -180,7 +193,8 @@ window.__ModuleLoader__.load({
180
193
  React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
181
194
  ),
182
195
  React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
183
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback)
196
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback),
197
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDelete(s.id) }, t.delete)
184
198
  ))
185
199
  ),
186
200
  diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
@@ -277,6 +291,15 @@ window.__ModuleLoader__.load({
277
291
  fetchList();
278
292
  }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
279
293
  };
294
+ const onDelete = (id) => {
295
+ if (!window.confirm(t.confirmDelete)) return;
296
+ setBusy(true); setErr('');
297
+ fetch('/dsh-time-machine/delete', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
298
+ .then(r => r.json()).then(j => {
299
+ if (!j.success) throw new Error(j.error || 'delete failed');
300
+ fetchList();
301
+ }).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
302
+ };
280
303
  const onSave = async () => {
281
304
  if (!scope) return;
282
305
  setSaving(true); setErr('');
@@ -338,7 +361,8 @@ window.__ModuleLoader__.load({
338
361
  React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
339
362
  ),
340
363
  React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
341
- React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback)
364
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onRollback(s.id) }, t.rollback),
365
+ React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDelete(s.id) }, t.delete)
342
366
  ))
343
367
  )
344
368
  ),
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ function sessionIdOf(execution, ctx) {
29
29
  export function apply(ctx, config) {
30
30
  let getConfig = () => config;
31
31
  let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
32
+ engine.loadFromRefs().catch(() => {});
32
33
 
33
34
  ctx.inject(['settings'], (sctx) => {
34
35
  const scope = sctx.settings.register(NS, Config, { base: config });
@@ -66,6 +67,18 @@ export function apply(ctx, config) {
66
67
  autoSnap(`auto:approval:${tool}`, sid);
67
68
  }));
68
69
  } catch {}
70
+ try {
71
+ offs.push(ctx.events.on('turn/end', (ev) => {
72
+ if (!getConfig().autoSnapshotEnabled) return;
73
+ const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
74
+ const sid = sessionIdOf(ev, ctx);
75
+ if (!sid) return;
76
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
77
+ engine.pruneSnapshots(sid, 3).catch(() => {});
78
+ }
79
+ // failure/blocked: keep all checkpoints for review
80
+ }));
81
+ } catch {}
69
82
  return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
70
83
  }, 'dsh-time-machine: auto snapshot events');
71
84
  }
@@ -143,6 +156,40 @@ export function apply(ctx, config) {
143
156
  return { success: true, ...res };
144
157
  },
145
158
  });
159
+
160
+ tctx.tools.register({
161
+ name: 'time_machine_checkpoint_delete',
162
+ description: 'Delete a single checkpoint. Requires confirm:true.',
163
+ parameters: {
164
+ type: 'object',
165
+ properties: {
166
+ id: { type: 'string', description: 'Snapshot id to delete' },
167
+ confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
168
+ },
169
+ required: ['id', 'confirm'],
170
+ },
171
+ execute: async ({ id, confirm } = {}) => {
172
+ const res = await engine.deleteSnapshot(String(id ?? ''), { confirm });
173
+ return { success: true, ...res };
174
+ },
175
+ });
176
+
177
+ tctx.tools.register({
178
+ name: 'time_machine_checkpoint_prune',
179
+ description: 'Prune session checkpoints, keeping only the newest N (default 3).',
180
+ parameters: {
181
+ type: 'object',
182
+ properties: {
183
+ sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
184
+ keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
185
+ },
186
+ },
187
+ execute: async (args = {}, execution) => {
188
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
189
+ const out = await engine.pruneSnapshots(sid, args.keep);
190
+ return { success: true, sessionId: sid, ...out };
191
+ },
192
+ });
146
193
  };
147
194
 
148
195
  try {
@@ -204,6 +251,47 @@ export function apply(ctx, config) {
204
251
  });
205
252
  },
206
253
  }));
254
+ disposals.push(ctx.webServer.register({
255
+ kind: 'exact',
256
+ path: '/dsh-time-machine/delete',
257
+ handler: async (req, res) => {
258
+ let body = '';
259
+ req.on('data', (c) => body += c);
260
+ req.on('end', async () => {
261
+ try {
262
+ const parsed = body ? JSON.parse(body) : {};
263
+ const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
264
+ res.setHeader('content-type', 'application/json');
265
+ res.end(JSON.stringify({ success: true, ...out }));
266
+ } catch (e) {
267
+ res.statusCode = 400;
268
+ res.setHeader('content-type', 'application/json');
269
+ res.end(JSON.stringify({ success: false, error: String(e.message || e), code: e.code || '' }));
270
+ }
271
+ });
272
+ },
273
+ }));
274
+ disposals.push(ctx.webServer.register({
275
+ kind: 'exact',
276
+ path: '/dsh-time-machine/prune',
277
+ handler: async (req, res) => {
278
+ let body = '';
279
+ req.on('data', (c) => body += c);
280
+ req.on('end', async () => {
281
+ try {
282
+ const parsed = body ? JSON.parse(body) : {};
283
+ const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
284
+ const out = await engine.pruneSnapshots(sid, parsed.keep);
285
+ res.setHeader('content-type', 'application/json');
286
+ res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
287
+ } catch (e) {
288
+ res.statusCode = 400;
289
+ res.setHeader('content-type', 'application/json');
290
+ res.end(JSON.stringify({ success: false, error: String(e.message || e) }));
291
+ }
292
+ });
293
+ },
294
+ }));
207
295
  disposals.push(ctx.webServer.register({
208
296
  kind: 'exact',
209
297
  path: '/dsh-time-machine/rollback',
package/lib/snapshot.js CHANGED
@@ -23,6 +23,7 @@ export class ShadowSnapshotEngine {
23
23
  this.maxSnapshots = maxSnapshots;
24
24
  this.cwd = cwd;
25
25
  this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
26
+ this.seq = 0;
26
27
  }
27
28
 
28
29
  _now() { return Date.now(); }
@@ -47,6 +48,52 @@ export class ShadowSnapshotEngine {
47
48
  } catch { return false; }
48
49
  }
49
50
 
51
+ // Restore checkpoint metadata from git shadow refs after a restart.
52
+ // git is the durable store; memory is rebuilt from refs/dsh-time-machine/*.
53
+ async loadFromRefs() {
54
+ if (!(await this._isGitRepo())) return 0;
55
+ let refs = [];
56
+ try {
57
+ const { stdout } = await this.exec(
58
+ 'git',
59
+ ['for-each-ref', '--format=%(refname)%00%(objectname)', 'refs/dsh-time-machine/'],
60
+ this.cwd ? { cwd: this.cwd } : {},
61
+ );
62
+ refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
63
+ } catch { return 0; }
64
+ const restored = [];
65
+ for (const line of refs) {
66
+ const [ref, commit] = line.split('\0');
67
+ if (!ref || !commit) continue;
68
+ if (this.snapshots.some(s => s.ref === ref)) continue;
69
+ let createdAt = 0;
70
+ let label = ref.split('/').pop();
71
+ try {
72
+ const { stdout } = await this.exec(
73
+ 'git',
74
+ ['log', '-1', '--format=%ct%00%s', ref],
75
+ this.cwd ? { cwd: this.cwd } : {},
76
+ );
77
+ const [ct, msg] = String(stdout ?? '').split('\0');
78
+ createdAt = Number(ct) * 1000 || 0;
79
+ if (msg && String(msg).trim()) label = String(msg).trim();
80
+ } catch {}
81
+ const short = ref.replace(/^refs\/dsh-time-machine\//, '');
82
+ const slash = short.indexOf('/');
83
+ const sessionId = slash === -1 ? '' : short.slice(0, slash);
84
+ const id = slash === -1 ? short : short.slice(slash + 1);
85
+ restored.push({ id: id || short, label, sessionId, createdAt, commit, ref, seq: 0 });
86
+ }
87
+ // stable order: oldest first, tie-break by ref name
88
+ restored.sort((a, b) => a.createdAt - b.createdAt || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
89
+ for (const snap of restored) snap.seq = ++this.seq;
90
+ this.snapshots.push(...restored);
91
+ const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
92
+ for (const sid of sessions) this._trimFor(sid);
93
+ if (sessions.length === 0) while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
94
+ return restored.length;
95
+ }
96
+
50
97
  async createSnapshot(label = '', { sessionId = '' } = {}) {
51
98
  const id = crypto.randomUUID().slice(0, 8);
52
99
  const createdAt = this._now();
@@ -69,7 +116,7 @@ export class ShadowSnapshotEngine {
69
116
  ref = null;
70
117
  }
71
118
  }
72
- const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref };
119
+ const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref, seq: ++this.seq };
73
120
  this.snapshots.push(snap);
74
121
  this._trimFor(sid);
75
122
  return snap;
@@ -78,7 +125,7 @@ export class ShadowSnapshotEngine {
78
125
  listSnapshots(sessionId) {
79
126
  const sid = sessionId != null ? String(sessionId).trim() : undefined;
80
127
  const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
81
- return [...list].sort((a, b) => b.createdAt - a.createdAt);
128
+ return [...list].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq);
82
129
  }
83
130
 
84
131
  getSnapshot(id) {
@@ -109,6 +156,40 @@ export class ShadowSnapshotEngine {
109
156
  return { rolledBack: true, snapshot: snap };
110
157
  }
111
158
 
159
+ async deleteSnapshot(id, { confirm } = {}) {
160
+ if (confirm !== true) {
161
+ const err = new Error('confirm:true required to delete');
162
+ err.code = 'CONFIRM_REQUIRED';
163
+ throw err;
164
+ }
165
+ const snap = this.getSnapshot(id);
166
+ if (!snap) {
167
+ const err = new Error(`snapshot ${id} not found`);
168
+ err.code = 'NOT_FOUND';
169
+ throw err;
170
+ }
171
+ if (snap.ref) {
172
+ try {
173
+ await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
174
+ } catch {}
175
+ }
176
+ const idx = this.snapshots.indexOf(snap);
177
+ if (idx !== -1) this.snapshots.splice(idx, 1);
178
+ return { deleted: true, snapshot: snap };
179
+ }
180
+
181
+ async pruneSnapshots(sessionId, keep = 3) {
182
+ const sid = sessionId != null ? String(sessionId).trim() : undefined;
183
+ const list = this.listSnapshots(sid); // newest first
184
+ const minKeep = Math.max(0, Number(keep) || 3);
185
+ const toRemove = list.slice(minKeep);
186
+ const removed = [];
187
+ for (const snap of toRemove) {
188
+ try { await this.deleteSnapshot(snap.id, { confirm: true }); removed.push(snap.id); } catch {}
189
+ }
190
+ return { removed, kept: list.length - removed.length };
191
+ }
192
+
112
193
  async diff(fromId, toId) {
113
194
  const from = this.getSnapshot(fromId);
114
195
  if (!from) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-time-machine",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",