@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 +26 -2
- package/lib/index.js +88 -0
- package/lib/snapshot.js +83 -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
|
@@ -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) {
|