@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 +26 -2
- package/lib/index.js +87 -0
- package/lib/snapshot.js +37 -2
- package/package.json +1 -1
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) {
|