@goodandready/dsh-time-machine 0.1.3 → 0.1.4
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 +28 -29
- package/lib/index.js +74 -13
- package/lib/snapshot.js +28 -15
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -104,7 +104,24 @@ window.__ModuleLoader__.load({
|
|
|
104
104
|
return lang;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function
|
|
107
|
+
function sessionIdOf(ctx) {
|
|
108
|
+
try {
|
|
109
|
+
if (!ctx) return '';
|
|
110
|
+
if (ctx.scope) {
|
|
111
|
+
if (ctx.scope.session) return String(ctx.scope.session.id || ctx.scope.session.header?.id || '');
|
|
112
|
+
if (typeof ctx.scope.id === 'string') return ctx.scope.id;
|
|
113
|
+
}
|
|
114
|
+
if (ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
|
|
115
|
+
if (ctx.get && typeof ctx.get === 'function') {
|
|
116
|
+
const s = ctx.get('session');
|
|
117
|
+
if (s) return String(s.id || s.header?.id || '');
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Timeline({ t, ctx }) {
|
|
124
|
+
const sid = sessionIdOf(ctx);
|
|
108
125
|
const [snapshots, setSnapshots] = React.useState([]);
|
|
109
126
|
const [loading, setLoading] = React.useState(false);
|
|
110
127
|
const [label, setLabel] = React.useState('');
|
|
@@ -114,17 +131,18 @@ window.__ModuleLoader__.load({
|
|
|
114
131
|
|
|
115
132
|
const fetchList = React.useCallback(() => {
|
|
116
133
|
setLoading(true); setErr('');
|
|
117
|
-
|
|
134
|
+
const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
|
|
135
|
+
fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
|
|
118
136
|
if (j.success) setSnapshots(j.snapshots || []);
|
|
119
137
|
else setErr(j.error || 'load failed');
|
|
120
138
|
}).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
|
|
121
|
-
}, []);
|
|
139
|
+
}, [sid]);
|
|
122
140
|
|
|
123
141
|
React.useEffect(() => { fetchList(); }, [fetchList]);
|
|
124
142
|
|
|
125
143
|
const onCreate = () => {
|
|
126
144
|
setBusy(true); setErr('');
|
|
127
|
-
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
|
|
145
|
+
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
|
|
128
146
|
.then(r => r.json()).then(j => {
|
|
129
147
|
if (!j.success) throw new Error(j.error || 'create failed');
|
|
130
148
|
setLabel(''); fetchList();
|
|
@@ -179,6 +197,7 @@ window.__ModuleLoader__.load({
|
|
|
179
197
|
|
|
180
198
|
function PluginCard(props) {
|
|
181
199
|
const ctx = props.ctx;
|
|
200
|
+
const sid = sessionIdOf(ctx);
|
|
182
201
|
const [expanded, setExpanded] = React.useState(false);
|
|
183
202
|
const lang = useLocale(ctx);
|
|
184
203
|
const t = lang === 'ru' ? ru : en;
|
|
@@ -225,17 +244,18 @@ window.__ModuleLoader__.load({
|
|
|
225
244
|
|
|
226
245
|
const fetchList = React.useCallback(() => {
|
|
227
246
|
setLoading(true); setErr('');
|
|
228
|
-
|
|
247
|
+
const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
|
|
248
|
+
fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
|
|
229
249
|
if (j.success) setSnapshots(j.snapshots || []);
|
|
230
250
|
else setErr(j.error || 'load failed');
|
|
231
251
|
}).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
|
|
232
|
-
}, []);
|
|
252
|
+
}, [sid]);
|
|
233
253
|
|
|
234
254
|
React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
|
|
235
255
|
|
|
236
256
|
const onCreate = () => {
|
|
237
257
|
setBusy(true); setErr('');
|
|
238
|
-
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) })
|
|
258
|
+
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
|
|
239
259
|
.then(r => r.json()).then(j => {
|
|
240
260
|
if (!j.success) throw new Error(j.error || 'create failed');
|
|
241
261
|
setLabel(''); fetchList();
|
|
@@ -342,31 +362,10 @@ window.__ModuleLoader__.load({
|
|
|
342
362
|
return React.createElement('div', { className: 'tm-tab' },
|
|
343
363
|
React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
|
|
344
364
|
React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
|
|
345
|
-
React.createElement(Timeline, { t })
|
|
365
|
+
React.createElement(Timeline, { t, ctx })
|
|
346
366
|
);
|
|
347
367
|
}
|
|
348
368
|
|
|
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
369
|
module.exports.inject = ['slots', 'locale'];
|
|
371
370
|
module.exports.apply = function apply(ctx) {
|
|
372
371
|
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,74 @@ 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
|
+
return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
|
|
70
|
+
}, 'dsh-time-machine: auto snapshot events');
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
// tools (session-aware)
|
|
29
75
|
const registerTools = (tctx) => {
|
|
30
76
|
tctx.tools.register({
|
|
31
77
|
name: 'time_machine_checkpoint_create',
|
|
32
|
-
description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label.',
|
|
78
|
+
description: 'Create a workspace checkpoint (shadow git snapshot) before risky changes. Returns id and label. Auto-creates per session if autoSnapshotEnabled.',
|
|
33
79
|
parameters: {
|
|
34
80
|
type: 'object',
|
|
35
81
|
properties: {
|
|
36
82
|
label: { type: 'string', description: 'Human label for checkpoint' },
|
|
83
|
+
sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
|
|
37
84
|
},
|
|
38
85
|
},
|
|
39
|
-
execute: async (
|
|
40
|
-
const
|
|
86
|
+
execute: async (args = {}, execution) => {
|
|
87
|
+
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || sessionIdOf(args, tctx) || '').trim();
|
|
88
|
+
const snap = await engine.createSnapshot(args.label, { sessionId: sid });
|
|
41
89
|
return { success: true, snapshot: snap };
|
|
42
90
|
},
|
|
43
91
|
});
|
|
44
92
|
|
|
45
93
|
tctx.tools.register({
|
|
46
94
|
name: 'time_machine_checkpoint_list',
|
|
47
|
-
description: 'List recent workspace checkpoints newest first.',
|
|
48
|
-
parameters: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
95
|
+
description: 'List recent workspace checkpoints newest first. Filter by sessionId if given.',
|
|
96
|
+
parameters: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
sessionId: { type: 'string', description: 'Filter by session id' },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
execute: async (args = {}, execution) => {
|
|
103
|
+
const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
|
|
104
|
+
const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
|
|
105
|
+
// if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
|
|
106
|
+
// For backward compat, if no sid provided and engine has sessionIds, return all
|
|
107
|
+
// If sid was auto-detected (execution has session), use it
|
|
108
|
+
const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
|
|
109
|
+
return { success: true, snapshots: finalList, sessionId: sid || '' };
|
|
52
110
|
},
|
|
53
111
|
});
|
|
54
112
|
|
|
@@ -92,7 +150,7 @@ export function apply(ctx, config) {
|
|
|
92
150
|
else ctx.inject(['tools'], (tctx) => registerTools(tctx));
|
|
93
151
|
} catch {}
|
|
94
152
|
|
|
95
|
-
// web routes for UI
|
|
153
|
+
// web routes for UI (session-aware via ?sessionId=)
|
|
96
154
|
ctx.effect(() => {
|
|
97
155
|
const disposals = [];
|
|
98
156
|
try {
|
|
@@ -100,8 +158,11 @@ export function apply(ctx, config) {
|
|
|
100
158
|
kind: 'exact',
|
|
101
159
|
path: '/dsh-time-machine/snapshots',
|
|
102
160
|
handler: (req, res) => {
|
|
161
|
+
const url = new URL(req.url, 'http://localhost');
|
|
162
|
+
const sid = url.searchParams.get('sessionId');
|
|
163
|
+
const list = sid != null ? engine.listSnapshots(String(sid)) : engine.listSnapshots();
|
|
103
164
|
res.setHeader('content-type', 'application/json');
|
|
104
|
-
res.end(JSON.stringify({ success: true, snapshots:
|
|
165
|
+
res.end(JSON.stringify({ success: true, snapshots: list }));
|
|
105
166
|
},
|
|
106
167
|
}));
|
|
107
168
|
disposals.push(ctx.webServer.register({
|
|
@@ -131,7 +192,8 @@ export function apply(ctx, config) {
|
|
|
131
192
|
req.on('end', async () => {
|
|
132
193
|
try {
|
|
133
194
|
const parsed = body ? JSON.parse(body) : {};
|
|
134
|
-
const
|
|
195
|
+
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
|
|
196
|
+
const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
|
|
135
197
|
res.setHeader('content-type', 'application/json');
|
|
136
198
|
res.end(JSON.stringify({ success: true, snapshot: snap }));
|
|
137
199
|
} catch (e) {
|
|
@@ -166,6 +228,5 @@ export function apply(ctx, config) {
|
|
|
166
228
|
return () => { for (const d of disposals) try { typeof d === 'function' && d(); } catch {} };
|
|
167
229
|
}, 'dsh-time-machine: web routes');
|
|
168
230
|
|
|
169
|
-
// expose engine for tests
|
|
170
231
|
ctx.provide?.('timeMachineEngine', engine);
|
|
171
232
|
}
|
package/lib/snapshot.js
CHANGED
|
@@ -16,19 +16,28 @@ 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
26
|
}
|
|
27
27
|
|
|
28
28
|
_now() { return Date.now(); }
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
_trimFor(sessionId) {
|
|
31
|
+
if (!sessionId) {
|
|
32
|
+
while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
36
|
+
while (perSession.length > this.maxSnapshots) {
|
|
37
|
+
const oldest = perSession.shift();
|
|
38
|
+
const idx = this.snapshots.indexOf(oldest);
|
|
39
|
+
if (idx !== -1) this.snapshots.splice(idx, 1);
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
async _isGitRepo() {
|
|
@@ -38,36 +47,38 @@ export class ShadowSnapshotEngine {
|
|
|
38
47
|
} catch { return false; }
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
async createSnapshot(label = '') {
|
|
50
|
+
async createSnapshot(label = '', { sessionId = '' } = {}) {
|
|
42
51
|
const id = crypto.randomUUID().slice(0, 8);
|
|
43
52
|
const createdAt = this._now();
|
|
44
53
|
const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
|
|
54
|
+
const sid = String(sessionId || '').trim();
|
|
45
55
|
let commit = null;
|
|
46
56
|
let ref = null;
|
|
47
57
|
const inGit = await this._isGitRepo();
|
|
48
58
|
if (inGit) {
|
|
49
59
|
try {
|
|
50
|
-
// stage working tree without committing to main branch
|
|
51
60
|
await this.exec('git', ['add', '-A'], this.cwd ? { cwd: this.cwd } : {});
|
|
52
61
|
const { stdout: tree } = await this.exec('git', ['write-tree'], this.cwd ? { cwd: this.cwd } : {});
|
|
53
62
|
const treeHash = String(tree).trim();
|
|
54
63
|
const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], this.cwd ? { cwd: this.cwd } : {});
|
|
55
64
|
commit = String(commitHash).trim();
|
|
56
|
-
ref = `refs/dsh-time-machine/${id}`;
|
|
65
|
+
ref = sid ? `refs/dsh-time-machine/${sid}/${id}` : `refs/dsh-time-machine/${id}`;
|
|
57
66
|
await this.exec('git', ['update-ref', ref, commit], this.cwd ? { cwd: this.cwd } : {});
|
|
58
67
|
} catch {
|
|
59
68
|
commit = null;
|
|
60
69
|
ref = null;
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
|
-
const snap = { id, label: safeLabel, createdAt, commit, ref };
|
|
72
|
+
const snap = { id, label: safeLabel, sessionId: sid, createdAt, commit, ref };
|
|
64
73
|
this.snapshots.push(snap);
|
|
65
|
-
this.
|
|
74
|
+
this._trimFor(sid);
|
|
66
75
|
return snap;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
listSnapshots() {
|
|
70
|
-
|
|
78
|
+
listSnapshots(sessionId) {
|
|
79
|
+
const sid = sessionId != null ? String(sessionId).trim() : undefined;
|
|
80
|
+
const list = sid !== undefined ? this.snapshots.filter(s => String(s.sessionId) === sid) : [...this.snapshots];
|
|
81
|
+
return [...list].sort((a, b) => b.createdAt - a.createdAt);
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
getSnapshot(id) {
|
|
@@ -91,7 +102,6 @@ export class ShadowSnapshotEngine {
|
|
|
91
102
|
try {
|
|
92
103
|
await this.exec('git', ['reset', '--hard', snap.commit], this.cwd ? { cwd: this.cwd } : {});
|
|
93
104
|
} catch (e) {
|
|
94
|
-
// fallback to checkout of commit tree
|
|
95
105
|
await this.exec('git', ['read-tree', snap.commit], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
|
|
96
106
|
await this.exec('git', ['checkout-index', '-a', '-f'], this.cwd ? { cwd: this.cwd } : {}).catch(()=>{});
|
|
97
107
|
}
|
|
@@ -107,7 +117,6 @@ export class ShadowSnapshotEngine {
|
|
|
107
117
|
throw err;
|
|
108
118
|
}
|
|
109
119
|
let to = toId ? this.getSnapshot(toId) : null;
|
|
110
|
-
// if to not given, diff against current HEAD/working tree
|
|
111
120
|
const inGit = await this._isGitRepo();
|
|
112
121
|
if (inGit && from.commit) {
|
|
113
122
|
try {
|
|
@@ -116,7 +125,6 @@ export class ShadowSnapshotEngine {
|
|
|
116
125
|
const diffStat = String(stdout ?? '').trim();
|
|
117
126
|
if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
|
|
118
127
|
} catch {}
|
|
119
|
-
// fallback to name-only
|
|
120
128
|
try {
|
|
121
129
|
const range = to?.commit ? `${from.commit}..${to.commit}` : from.commit;
|
|
122
130
|
const { stdout } = await this.exec('git', ['diff', '--name-only', range], this.cwd ? { cwd: this.cwd } : {});
|
|
@@ -129,6 +137,11 @@ export class ShadowSnapshotEngine {
|
|
|
129
137
|
|
|
130
138
|
setMax(n) {
|
|
131
139
|
this.maxSnapshots = Math.max(1, Number(n) || 20);
|
|
132
|
-
|
|
140
|
+
// trim all sessions
|
|
141
|
+
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
142
|
+
for (const sid of sessions) this._trimFor(sid);
|
|
143
|
+
if (sessions.length === 0) {
|
|
144
|
+
while (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
|
|
145
|
+
}
|
|
133
146
|
}
|
|
134
147
|
}
|