@goodandready/dsh-time-machine 0.1.3 → 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 +54 -31
- package/lib/index.js +161 -13
- package/lib/snapshot.js +63 -15
- 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: 'Сохранить',
|
|
@@ -104,7 +108,24 @@ window.__ModuleLoader__.load({
|
|
|
104
108
|
return lang;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
function
|
|
111
|
+
function sessionIdOf(ctx) {
|
|
112
|
+
try {
|
|
113
|
+
if (!ctx) return '';
|
|
114
|
+
if (ctx.scope) {
|
|
115
|
+
if (ctx.scope.session) return String(ctx.scope.session.id || ctx.scope.session.header?.id || '');
|
|
116
|
+
if (typeof ctx.scope.id === 'string') return ctx.scope.id;
|
|
117
|
+
}
|
|
118
|
+
if (ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
|
|
119
|
+
if (ctx.get && typeof ctx.get === 'function') {
|
|
120
|
+
const s = ctx.get('session');
|
|
121
|
+
if (s) return String(s.id || s.header?.id || '');
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Timeline({ t, ctx }) {
|
|
128
|
+
const sid = sessionIdOf(ctx);
|
|
108
129
|
const [snapshots, setSnapshots] = React.useState([]);
|
|
109
130
|
const [loading, setLoading] = React.useState(false);
|
|
110
131
|
const [label, setLabel] = React.useState('');
|
|
@@ -114,17 +135,18 @@ window.__ModuleLoader__.load({
|
|
|
114
135
|
|
|
115
136
|
const fetchList = React.useCallback(() => {
|
|
116
137
|
setLoading(true); setErr('');
|
|
117
|
-
|
|
138
|
+
const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
|
|
139
|
+
fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
|
|
118
140
|
if (j.success) setSnapshots(j.snapshots || []);
|
|
119
141
|
else setErr(j.error || 'load failed');
|
|
120
142
|
}).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
|
|
121
|
-
}, []);
|
|
143
|
+
}, [sid]);
|
|
122
144
|
|
|
123
145
|
React.useEffect(() => { fetchList(); }, [fetchList]);
|
|
124
146
|
|
|
125
147
|
const onCreate = () => {
|
|
126
148
|
setBusy(true); setErr('');
|
|
127
|
-
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
|
|
149
|
+
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
|
|
128
150
|
.then(r => r.json()).then(j => {
|
|
129
151
|
if (!j.success) throw new Error(j.error || 'create failed');
|
|
130
152
|
setLabel(''); fetchList();
|
|
@@ -146,6 +168,15 @@ window.__ModuleLoader__.load({
|
|
|
146
168
|
fetchList();
|
|
147
169
|
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
148
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
|
+
};
|
|
149
180
|
|
|
150
181
|
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
|
|
151
182
|
React.createElement('div', { style: { display: 'flex', gap: 8 } },
|
|
@@ -162,7 +193,8 @@ window.__ModuleLoader__.load({
|
|
|
162
193
|
React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
|
|
163
194
|
),
|
|
164
195
|
React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
|
|
165
|
-
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)
|
|
166
198
|
))
|
|
167
199
|
),
|
|
168
200
|
diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
|
|
@@ -179,6 +211,7 @@ window.__ModuleLoader__.load({
|
|
|
179
211
|
|
|
180
212
|
function PluginCard(props) {
|
|
181
213
|
const ctx = props.ctx;
|
|
214
|
+
const sid = sessionIdOf(ctx);
|
|
182
215
|
const [expanded, setExpanded] = React.useState(false);
|
|
183
216
|
const lang = useLocale(ctx);
|
|
184
217
|
const t = lang === 'ru' ? ru : en;
|
|
@@ -225,17 +258,18 @@ window.__ModuleLoader__.load({
|
|
|
225
258
|
|
|
226
259
|
const fetchList = React.useCallback(() => {
|
|
227
260
|
setLoading(true); setErr('');
|
|
228
|
-
|
|
261
|
+
const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
|
|
262
|
+
fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
|
|
229
263
|
if (j.success) setSnapshots(j.snapshots || []);
|
|
230
264
|
else setErr(j.error || 'load failed');
|
|
231
265
|
}).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
|
|
232
|
-
}, []);
|
|
266
|
+
}, [sid]);
|
|
233
267
|
|
|
234
268
|
React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
|
|
235
269
|
|
|
236
270
|
const onCreate = () => {
|
|
237
271
|
setBusy(true); setErr('');
|
|
238
|
-
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
|
|
272
|
+
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
|
|
239
273
|
.then(r => r.json()).then(j => {
|
|
240
274
|
if (!j.success) throw new Error(j.error || 'create failed');
|
|
241
275
|
setLabel(''); fetchList();
|
|
@@ -257,6 +291,15 @@ window.__ModuleLoader__.load({
|
|
|
257
291
|
fetchList();
|
|
258
292
|
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
259
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
|
+
};
|
|
260
303
|
const onSave = async () => {
|
|
261
304
|
if (!scope) return;
|
|
262
305
|
setSaving(true); setErr('');
|
|
@@ -318,7 +361,8 @@ window.__ModuleLoader__.load({
|
|
|
318
361
|
React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
|
|
319
362
|
),
|
|
320
363
|
React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
|
|
321
|
-
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)
|
|
322
366
|
))
|
|
323
367
|
)
|
|
324
368
|
),
|
|
@@ -342,31 +386,10 @@ window.__ModuleLoader__.load({
|
|
|
342
386
|
return React.createElement('div', { className: 'tm-tab' },
|
|
343
387
|
React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
|
|
344
388
|
React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
|
|
345
|
-
React.createElement(Timeline, { t })
|
|
389
|
+
React.createElement(Timeline, { t, ctx })
|
|
346
390
|
);
|
|
347
391
|
}
|
|
348
392
|
|
|
349
|
-
function registerBetterSidebar(ctx) {
|
|
350
|
-
const svc = (ctx.get && (()=>{ try{return ctx.get('betterSidebar')}catch{return null}})()) || ctx.betterSidebar;
|
|
351
|
-
if (!svc || typeof svc.registerTab !== 'function') return;
|
|
352
|
-
try {
|
|
353
|
-
svc.registerTab({
|
|
354
|
-
id: 'time-machine',
|
|
355
|
-
title: () => {
|
|
356
|
-
const a = ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active || 'en';
|
|
357
|
-
return a.startsWith('ru') ? 'Машина времени' : 'Time Machine';
|
|
358
|
-
},
|
|
359
|
-
order: 30,
|
|
360
|
-
single: true,
|
|
361
|
-
icon: (size) => React.createElement('svg', { width: size||16, height: size||16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
362
|
-
React.createElement('circle', { cx:12, cy:12, r:10 }),
|
|
363
|
-
React.createElement('polyline', { points:'12 6 12 12 16 14' })
|
|
364
|
-
),
|
|
365
|
-
component: (p) => React.createElement(TimeMachineTab, { ctx, ...p })
|
|
366
|
-
});
|
|
367
|
-
} catch (e) { console.warn('[dsh-time-machine] betterSidebar tab failed', e); }
|
|
368
|
-
}
|
|
369
|
-
|
|
370
393
|
module.exports.inject = ['slots', 'locale'];
|
|
371
394
|
module.exports.apply = function apply(ctx) {
|
|
372
395
|
ensureStyles();
|
package/lib/index.js
CHANGED
|
@@ -12,6 +12,20 @@ export const Config = Schema.object({
|
|
|
12
12
|
|
|
13
13
|
const NS = '@goodandready/dsh-time-machine';
|
|
14
14
|
|
|
15
|
+
function sessionIdOf(execution, ctx) {
|
|
16
|
+
try {
|
|
17
|
+
if (execution && execution.sessionId) return String(execution.sessionId);
|
|
18
|
+
if (execution && execution.agent && execution.agent.session) {
|
|
19
|
+
return String(execution.agent.session.id || execution.agent.session.header?.id || '');
|
|
20
|
+
}
|
|
21
|
+
if (ctx && ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
|
|
22
|
+
if (ctx && ctx.get && typeof ctx.get === 'function') {
|
|
23
|
+
try { const s = ctx.get('session'); if (s) return String(s.id || s.header?.id || ''); } catch {}
|
|
24
|
+
}
|
|
25
|
+
} catch {}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
export function apply(ctx, config) {
|
|
16
30
|
let getConfig = () => config;
|
|
17
31
|
let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20 });
|
|
@@ -25,30 +39,86 @@ export function apply(ctx, config) {
|
|
|
25
39
|
sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
|
|
26
40
|
});
|
|
27
41
|
|
|
28
|
-
//
|
|
42
|
+
// auto-snapshot on turn/start and approval/asked (session-scoped)
|
|
43
|
+
const autoSnap = async (label, sessId) => {
|
|
44
|
+
if (!getConfig().autoSnapshotEnabled) return;
|
|
45
|
+
try {
|
|
46
|
+
const sid = String(sessId || '').trim();
|
|
47
|
+
await engine.createSnapshot(label, { sessionId: sid });
|
|
48
|
+
} catch {}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// listen to DSH events if available
|
|
52
|
+
try {
|
|
53
|
+
if (ctx.events && typeof ctx.events.on === 'function') {
|
|
54
|
+
ctx.effect(() => {
|
|
55
|
+
const offs = [];
|
|
56
|
+
try {
|
|
57
|
+
offs.push(ctx.events.on('turn/start', (ev) => {
|
|
58
|
+
const sid = sessionIdOf(ev, ctx);
|
|
59
|
+
autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
|
|
60
|
+
}));
|
|
61
|
+
} catch {}
|
|
62
|
+
try {
|
|
63
|
+
offs.push(ctx.events.on('approval/asked', (ev) => {
|
|
64
|
+
const sid = sessionIdOf(ev, ctx);
|
|
65
|
+
const tool = ev?.tool || ev?.name || 'approval';
|
|
66
|
+
autoSnap(`auto:approval:${tool}`, sid);
|
|
67
|
+
}));
|
|
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 {}
|
|
81
|
+
return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
|
|
82
|
+
}, 'dsh-time-machine: auto snapshot events');
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
// tools (session-aware)
|
|
29
87
|
const registerTools = (tctx) => {
|
|
30
88
|
tctx.tools.register({
|
|
31
89
|
name: 'time_machine_checkpoint_create',
|
|
32
|
-
description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label.',
|
|
90
|
+
description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label. Auto-creates per session if autoSnapshotEnabled.',
|
|
33
91
|
parameters: {
|
|
34
92
|
type: 'object',
|
|
35
93
|
properties: {
|
|
36
94
|
label: { type: 'string', description: 'Human label for checkpoint' },
|
|
95
|
+
sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
|
|
37
96
|
},
|
|
38
97
|
},
|
|
39
|
-
execute: async (
|
|
40
|
-
const
|
|
98
|
+
execute: async (args = {}, execution) => {
|
|
99
|
+
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || sessionIdOf(args, tctx) || '').trim();
|
|
100
|
+
const snap = await engine.createSnapshot(args.label, { sessionId: sid });
|
|
41
101
|
return { success: true, snapshot: snap };
|
|
42
102
|
},
|
|
43
103
|
});
|
|
44
104
|
|
|
45
105
|
tctx.tools.register({
|
|
46
106
|
name: 'time_machine_checkpoint_list',
|
|
47
|
-
description: 'List recent workspace checkpoints newest first.',
|
|
48
|
-
parameters: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
107
|
+
description: 'List recent workspace checkpoints newest first. Filter by sessionId if given.',
|
|
108
|
+
parameters: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
sessionId: { type: 'string', description: 'Filter by session id' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
execute: async (args = {}, execution) => {
|
|
115
|
+
const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
|
|
116
|
+
const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
|
|
117
|
+
// if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
|
|
118
|
+
// For backward compat, if no sid provided and engine has sessionIds, return all
|
|
119
|
+
// If sid was auto-detected (execution has session), use it
|
|
120
|
+
const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
|
|
121
|
+
return { success: true, snapshots: finalList, sessionId: sid || '' };
|
|
52
122
|
},
|
|
53
123
|
});
|
|
54
124
|
|
|
@@ -85,6 +155,40 @@ export function apply(ctx, config) {
|
|
|
85
155
|
return { success: true, ...res };
|
|
86
156
|
},
|
|
87
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
|
+
});
|
|
88
192
|
};
|
|
89
193
|
|
|
90
194
|
try {
|
|
@@ -92,7 +196,7 @@ export function apply(ctx, config) {
|
|
|
92
196
|
else ctx.inject(['tools'], (tctx) => registerTools(tctx));
|
|
93
197
|
} catch {}
|
|
94
198
|
|
|
95
|
-
// web routes for UI
|
|
199
|
+
// web routes for UI (session-aware via ?sessionId=)
|
|
96
200
|
ctx.effect(() => {
|
|
97
201
|
const disposals = [];
|
|
98
202
|
try {
|
|
@@ -100,8 +204,11 @@ export function apply(ctx, config) {
|
|
|
100
204
|
kind: 'exact',
|
|
101
205
|
path: '/dsh-time-machine/snapshots',
|
|
102
206
|
handler: (req, res) => {
|
|
207
|
+
const url = new URL(req.url, 'http://localhost');
|
|
208
|
+
const sid = url.searchParams.get('sessionId');
|
|
209
|
+
const list = sid != null ? engine.listSnapshots(String(sid)) : engine.listSnapshots();
|
|
103
210
|
res.setHeader('content-type', 'application/json');
|
|
104
|
-
res.end(JSON.stringify({ success: true, snapshots:
|
|
211
|
+
res.end(JSON.stringify({ success: true, snapshots: list }));
|
|
105
212
|
},
|
|
106
213
|
}));
|
|
107
214
|
disposals.push(ctx.webServer.register({
|
|
@@ -131,7 +238,8 @@ export function apply(ctx, config) {
|
|
|
131
238
|
req.on('end', async () => {
|
|
132
239
|
try {
|
|
133
240
|
const parsed = body ? JSON.parse(body) : {};
|
|
134
|
-
const
|
|
241
|
+
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
|
|
242
|
+
const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
|
|
135
243
|
res.setHeader('content-type', 'application/json');
|
|
136
244
|
res.end(JSON.stringify({ success: true, snapshot: snap }));
|
|
137
245
|
} catch (e) {
|
|
@@ -142,6 +250,47 @@ export function apply(ctx, config) {
|
|
|
142
250
|
});
|
|
143
251
|
},
|
|
144
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
|
+
}));
|
|
145
294
|
disposals.push(ctx.webServer.register({
|
|
146
295
|
kind: 'exact',
|
|
147
296
|
path: '/dsh-time-machine/rollback',
|
|
@@ -166,6 +315,5 @@ export function apply(ctx, config) {
|
|
|
166
315
|
return () => { for (const d of disposals) try { typeof d === 'function' && d(); } catch {} };
|
|
167
316
|
}, 'dsh-time-machine: web routes');
|
|
168
317
|
|
|
169
|
-
// expose engine for tests
|
|
170
318
|
ctx.provide?.('timeMachineEngine', engine);
|
|
171
319
|
}
|
package/lib/snapshot.js
CHANGED
|
@@ -16,19 +16,29 @@ async function defaultExec(cmd, args, opts = {}) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// ponytail: in-memory engine, git shadow refs if repo present; O(n) scan,
|
|
19
|
+
// ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, per-session trim
|
|
20
20
|
export class ShadowSnapshotEngine {
|
|
21
21
|
constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
|
|
22
22
|
this.exec = exec;
|
|
23
23
|
this.maxSnapshots = maxSnapshots;
|
|
24
24
|
this.cwd = cwd;
|
|
25
|
-
this.snapshots = []; //
|
|
25
|
+
this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
|
|
26
|
+
this.seq = 0;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
_now() { return Date.now(); }
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
_trimFor(sessionId) {
|
|
32
|
+
if (!sessionId) {
|
|
33
|
+
while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
37
|
+
while (perSession.length > this.maxSnapshots) {
|
|
38
|
+
const oldest = perSession.shift();
|
|
39
|
+
const idx = this.snapshots.indexOf(oldest);
|
|
40
|
+
if (idx !== -1) this.snapshots.splice(idx, 1);
|
|
41
|
+
}
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
async _isGitRepo() {
|
|
@@ -38,36 +48,38 @@ export class ShadowSnapshotEngine {
|
|
|
38
48
|
} catch { return false; }
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
async createSnapshot(label = '') {
|
|
51
|
+
async createSnapshot(label = '', { sessionId = '' } = {}) {
|
|
42
52
|
const id = crypto.randomUUID().slice(0, 8);
|
|
43
53
|
const createdAt = this._now();
|
|
44
54
|
const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
|
|
55
|
+
const sid = String(sessionId || '').trim();
|
|
45
56
|
let commit = null;
|
|
46
57
|
let ref = null;
|
|
47
58
|
const inGit = await this._isGitRepo();
|
|
48
59
|
if (inGit) {
|
|
49
60
|
try {
|
|
50
|
-
// stage working tree without committing to main branch
|
|
51
61
|
await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
|
|
52
62
|
const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
|
|
53
63
|
const treeHash = String(tree).trim();
|
|
54
64
|
const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
|
|
55
65
|
commit = String(commitHash).trim();
|
|
56
|
-
ref = `refs/dsh-time-machine/${id}`;
|
|
66
|
+
ref = sid ? `refs/dsh-time-machine/${sid}/${id}` : `refs/dsh-time-machine/${id}`;
|
|
57
67
|
await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
|
|
58
68
|
} catch {
|
|
59
69
|
commit = null;
|
|
60
70
|
ref = null;
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
|
-
const snap = { id, label: safeLabel, createdAt, commit, ref };
|
|
73
|
+
const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref, seq: ++this.seq };
|
|
64
74
|
this.snapshots.push(snap);
|
|
65
|
-
this.
|
|
75
|
+
this._trimFor(sid);
|
|
66
76
|
return snap;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
listSnapshots() {
|
|
70
|
-
|
|
79
|
+
listSnapshots(sessionId) {
|
|
80
|
+
const sid = sessionId != null ? String(sessionId).trim() : undefined;
|
|
81
|
+
const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
|
|
82
|
+
return [...list].sort((a, b) => b.createdAt - a.createdAt || b.seq - a.seq);
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
getSnapshot(id) {
|
|
@@ -91,7 +103,6 @@ export class ShadowSnapshotEngine {
|
|
|
91
103
|
try {
|
|
92
104
|
await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
|
|
93
105
|
} catch (e) {
|
|
94
|
-
// fallback to checkout of commit tree
|
|
95
106
|
await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
|
|
96
107
|
await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
|
|
97
108
|
}
|
|
@@ -99,6 +110,40 @@ export class ShadowSnapshotEngine {
|
|
|
99
110
|
return { rolledBack: true, snapshot: snap };
|
|
100
111
|
}
|
|
101
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
|
+
|
|
102
147
|
async diff(fromId, toId) {
|
|
103
148
|
const from = this.getSnapshot(fromId);
|
|
104
149
|
if (!from) {
|
|
@@ -107,7 +152,6 @@ export class ShadowSnapshotEngine {
|
|
|
107
152
|
throw err;
|
|
108
153
|
}
|
|
109
154
|
let to = toId ? this.getSnapshot(toId) : null;
|
|
110
|
-
// if to not given, diff against current HEAD/working tree
|
|
111
155
|
const inGit = await this._isGitRepo();
|
|
112
156
|
if (inGit && from.commit) {
|
|
113
157
|
try {
|
|
@@ -116,7 +160,6 @@ export class ShadowSnapshotEngine {
|
|
|
116
160
|
const diffStat = String(stdout ?? '').trim();
|
|
117
161
|
if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
|
|
118
162
|
} catch {}
|
|
119
|
-
// fallback to name-only
|
|
120
163
|
try {
|
|
121
164
|
const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
|
|
122
165
|
const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
|
|
@@ -129,6 +172,11 @@ export class ShadowSnapshotEngine {
|
|
|
129
172
|
|
|
130
173
|
setMax(n) {
|
|
131
174
|
this.maxSnapshots = Math.max(1, Number(n) || 20);
|
|
132
|
-
|
|
175
|
+
// trim all sessions
|
|
176
|
+
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
177
|
+
for (const sid of sessions) this._trimFor(sid);
|
|
178
|
+
if (sessions.length === 0) {
|
|
179
|
+
while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
|
|
180
|
+
}
|
|
133
181
|
}
|
|
134
182
|
}
|