@goodandready/dsh-time-machine 0.1.4 → 0.1.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/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
@@ -66,6 +66,18 @@ export function apply(ctx, config) {
66
66
  autoSnap(`auto:approval:${tool}`, sid);
67
67
  }));
68
68
  } catch {}
69
+ try {
70
+ offs.push(ctx.events.on('turn/end', (ev) => {
71
+ if (!getConfig().autoSnapshotEnabled) return;
72
+ const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
73
+ const sid = sessionIdOf(ev, ctx);
74
+ if (!sid) return;
75
+ if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
76
+ engine.pruneSnapshots(sid, 3).catch(() => {});
77
+ }
78
+ // failure/blocked: keep all checkpoints for review
79
+ }));
80
+ } catch {}
69
81
  return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
70
82
  }, 'dsh-time-machine: auto snapshot events');
71
83
  }
@@ -143,6 +155,40 @@ export function apply(ctx, config) {
143
155
  return { success: true, ...res };
144
156
  },
145
157
  });
158
+
159
+ tctx.tools.register({
160
+ name: 'time_machine_checkpoint_delete',
161
+ description: 'Delete a single checkpoint. Requires confirm:true.',
162
+ parameters: {
163
+ type: 'object',
164
+ properties: {
165
+ id: { type: 'string', description: 'Snapshot id to delete' },
166
+ confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
167
+ },
168
+ required: ['id', 'confirm'],
169
+ },
170
+ execute: async ({ id, confirm } = {}) => {
171
+ const res = await engine.deleteSnapshot(String(id ?? ''), { confirm });
172
+ return { success: true, ...res };
173
+ },
174
+ });
175
+
176
+ tctx.tools.register({
177
+ name: 'time_machine_checkpoint_prune',
178
+ description: 'Prune session checkpoints, keeping only the newest N (default 3).',
179
+ parameters: {
180
+ type: 'object',
181
+ properties: {
182
+ sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
183
+ keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
184
+ },
185
+ },
186
+ execute: async (args = {}, execution) => {
187
+ const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
188
+ const out = await engine.pruneSnapshots(sid, args.keep);
189
+ return { success: true, sessionId: sid, ...out };
190
+ },
191
+ });
146
192
  };
147
193
 
148
194
  try {
@@ -204,6 +250,47 @@ export function apply(ctx, config) {
204
250
  });
205
251
  },
206
252
  }));
253
+ disposals.push(ctx.webServer.register({
254
+ kind: 'exact',
255
+ path: '/dsh-time-machine/delete',
256
+ handler: async (req, res) => {
257
+ let body = '';
258
+ req.on('data', (c) => body += c);
259
+ req.on('end', async () => {
260
+ try {
261
+ const parsed = body ? JSON.parse(body) : {};
262
+ const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
263
+ res.setHeader('content-type', 'application/json');
264
+ res.end(JSON.stringify({ success: true, ...out }));
265
+ } catch (e) {
266
+ res.statusCode = 400;
267
+ res.setHeader('content-type', 'application/json');
268
+ res.end(JSON.stringify({ success: false, error: String(e.message || e), code: e.code || '' }));
269
+ }
270
+ });
271
+ },
272
+ }));
273
+ disposals.push(ctx.webServer.register({
274
+ kind: 'exact',
275
+ path: '/dsh-time-machine/prune',
276
+ handler: async (req, res) => {
277
+ let body = '';
278
+ req.on('data', (c) => body += c);
279
+ req.on('end', async () => {
280
+ try {
281
+ const parsed = body ? JSON.parse(body) : {};
282
+ const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
283
+ const out = await engine.pruneSnapshots(sid, parsed.keep);
284
+ res.setHeader('content-type', 'application/json');
285
+ res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
286
+ } catch (e) {
287
+ res.statusCode = 400;
288
+ res.setHeader('content-type', 'application/json');
289
+ res.end(JSON.stringify({ success: false, error: String(e.message || e) }));
290
+ }
291
+ });
292
+ },
293
+ }));
207
294
  disposals.push(ctx.webServer.register({
208
295
  kind: 'exact',
209
296
  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(); }
@@ -69,7 +70,7 @@ export class ShadowSnapshotEngine {
69
70
  ref = null;
70
71
  }
71
72
  }
72
- const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref };
73
+ const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref, seq: ++this.seq };
73
74
  this.snapshots.push(snap);
74
75
  this._trimFor(sid);
75
76
  return snap;
@@ -78,7 +79,7 @@ export class ShadowSnapshotEngine {
78
79
  listSnapshots(sessionId) {
79
80
  const sid = sessionId != null ? String(sessionId).trim() : undefined;
80
81
  const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
81
- return [...list].sort((a, b) => b.createdAt - a.createdAt);
82
+ return [...list].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq);
82
83
  }
83
84
 
84
85
  getSnapshot(id) {
@@ -109,6 +110,40 @@ export class ShadowSnapshotEngine {
109
110
  return { rolledBack: true, snapshot: snap };
110
111
  }
111
112
 
113
+ async deleteSnapshot(id, { confirm } = {}) {
114
+ if (confirm !== true) {
115
+ const err = new Error('confirm:true required to delete');
116
+ err.code = 'CONFIRM_REQUIRED';
117
+ throw err;
118
+ }
119
+ const snap = this.getSnapshot(id);
120
+ if (!snap) {
121
+ const err = new Error(`snapshot ${id} not found`);
122
+ err.code = 'NOT_FOUND';
123
+ throw err;
124
+ }
125
+ if (snap.ref) {
126
+ try {
127
+ await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
128
+ } catch {}
129
+ }
130
+ const idx = this.snapshots.indexOf(snap);
131
+ if (idx !== -1) this.snapshots.splice(idx, 1);
132
+ return { deleted: true, snapshot: snap };
133
+ }
134
+
135
+ async pruneSnapshots(sessionId, keep = 3) {
136
+ const sid = sessionId != null ? String(sessionId).trim() : undefined;
137
+ const list = this.listSnapshots(sid); // newest first
138
+ const minKeep = Math.max(0, Number(keep) || 3);
139
+ const toRemove = list.slice(minKeep);
140
+ const removed = [];
141
+ for (const snap of toRemove) {
142
+ try { await this.deleteSnapshot(snap.id, { confirm: true }); removed.push(snap.id); } catch {}
143
+ }
144
+ return { removed, kept: list.length - removed.length };
145
+ }
146
+
112
147
  async diff(fromId, toId) {
113
148
  const from = this.getSnapshot(fromId);
114
149
  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.5",
4
4
  "description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",