@goodandready/dsh-time-machine 0.1.8 → 0.1.10
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/README.md +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/client.js +6 -4
- package/lib/index.js +128 -94
- package/lib/snapshot.js +275 -204
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -119,6 +119,21 @@ dsh-time-machine:
|
|
|
119
119
|
|
|
120
120
|
## 📋 Release Notes
|
|
121
121
|
|
|
122
|
+
### v0.1.10 — Stability, Dynamic CWD, Unified Diff & Architecture Polish
|
|
123
|
+
* **Added in v0.1.10**: Dynamic / session-aware `cwd` resolution: TimeMachine tools and REST endpoints automatically locate the active session workspace or accept explicit target directory.
|
|
124
|
+
* **Added in v0.1.10**: Unified diff support (`format: "patch" | "stat"`) with size limits (up to 256KB) and clean truncation warnings.
|
|
125
|
+
* **Added in v0.1.10**: Async execution queue on Git mutation engine to completely eliminate lockfile collisions under concurrent agent operations.
|
|
126
|
+
* **Added in v0.1.10**: Single-batch Git ref querying using `git for-each-ref` replacing $O(N)$ sequential `git log` processes for instant snapshot listings.
|
|
127
|
+
* **Added in v0.1.10**: Redundant commit deduplication: auto-snapshots are safely skipped if the working tree has not changed.
|
|
128
|
+
* **Added in v0.1.10**: Startup cleanup of temporary `tm_index_*` staging files.
|
|
129
|
+
* **Changed in v0.1.10**: Settings UI card streamlined, directing snapshot inspection to the dedicated Time Machine sidebar tab.
|
|
130
|
+
* **Changed in v0.1.10**: Comprehensive error logging replacing silent exceptions.
|
|
131
|
+
|
|
132
|
+
### v0.1.9 — Event Bus Harmonization & Dependency Cleanup
|
|
133
|
+
* **Changed in v0.1.9**: Harmonized event bus subscriptions to prevent duplicate checkpoints. Native DSH `session/event` bus takes precedence, with legacy `ctx.events` used strictly as a fallback when `ctx.on` is unavailable.
|
|
134
|
+
* **Changed in v0.1.9**: Removed unused `@deepseek-ai/dsh-credentials` from `peerDependencies`.
|
|
135
|
+
* **Added in v0.1.9**: Standardized project design contract in `docs/design/DESIGN.md`.
|
|
136
|
+
|
|
122
137
|
### v0.1.7 — Critical Safety, Staging Isolation & Native Event Bus
|
|
123
138
|
* **Changed in v0.1.7**: Safe workspace rollback via `read-tree` + `checkout-index` + `clean -fd`. Rolling back to a checkpoint never modifies branch `HEAD` or severs git commit history.
|
|
124
139
|
* **Changed in v0.1.7**: User staging area protection. Checkpoints now isolate git index creation through `GIT_INDEX_FILE`, preventing disruption of pre-staged files in `.git/index`.
|
package/README.ru.md
CHANGED
|
@@ -119,6 +119,21 @@ dsh-time-machine:
|
|
|
119
119
|
|
|
120
120
|
## 📋 История версий (Release Notes)
|
|
121
121
|
|
|
122
|
+
### v0.1.10 — Стабильность, динамический CWD, Unified Diff и оптимизация архитектуры
|
|
123
|
+
* **Added in v0.1.10**: Динамическое определение рабочей директории (`cwd`): инструменты и REST API автоматически определяют каталог активной сессии либо принимают явный путь.
|
|
124
|
+
* **Added in v0.1.10**: Поддержка Unified Diff (`format: "patch" | "stat"`) с лимитом размера (до 256 КБ) и защитой от переполнения контекста.
|
|
125
|
+
* **Added in v0.1.10**: Асинхронная очередь мутаций Git, исключающая конфликты блокировок `index.lock` при одновременных запросах.
|
|
126
|
+
* **Added in v0.1.10**: Пакетная вычитка ссылок через `git for-each-ref` вместо $O(N)$ отдельных вызовов `git log`, ускоряющая загрузку списка чекпоинтов.
|
|
127
|
+
* **Added in v0.1.10**: Дедупликация снапшотов: авто-сохранение пропускается, если дерево файлов не изменилось с момента прошлого чекпоинта.
|
|
128
|
+
* **Added in v0.1.10**: Автоматическая очистка сиротских временных индексов `tm_index_*` при запуске плагина.
|
|
129
|
+
* **Changed in v0.1.10**: Оптимизирована карточка настроек: удалено дублирование ленты, добавлено указание на вкладку в боковой панели.
|
|
130
|
+
* **Changed in v0.1.10**: Полное логирование ошибок через `console.warn` вместо тихого подавления исключений.
|
|
131
|
+
|
|
132
|
+
### v0.1.9 — Гармонизация шины событий и очистка зависимостей
|
|
133
|
+
* **Changed in v0.1.9**: Устранено потенциальное дублирование авто-снапшотов. Нативная шина событий DSH `session/event` теперь имеет приоритет, а legacy `ctx.events` подключается строго как fallback при отсутствии `ctx.on`.
|
|
134
|
+
* **Changed in v0.1.9**: Удалена неиспользуемая зависимость `@deepseek-ai/dsh-credentials` из `peerDependencies`.
|
|
135
|
+
* **Added in v0.1.9**: Добавлен дизайн-контракт проекта `docs/design/DESIGN.md`.
|
|
136
|
+
|
|
122
137
|
### v0.1.7 — Безопасность истории веток, изоляция индекса и события DSH
|
|
123
138
|
* **Changed in v0.1.7**: Безопасный откат рабочего каталога через `read-tree` + `checkout-index` + `clean -fd`. Устранена критическая проблема переноса `HEAD` ветки на сиротский коммит.
|
|
124
139
|
* **Changed in v0.1.7**: Защита пользовательского индекса Git. Чекпоинты создаются с изолированным `GIT_INDEX_FILE`, предотвращая перезапись подготовленных файлов в `.git/index`.
|
package/README.zh.md
CHANGED
|
@@ -94,6 +94,21 @@ dsh-time-machine:
|
|
|
94
94
|
|
|
95
95
|
## 📋 版本更新记录 (Release Notes)
|
|
96
96
|
|
|
97
|
+
### v0.1.10 — 稳定性、动态工作目录、统一 Diff 与架构优化
|
|
98
|
+
* **Added in v0.1.10**: 动态工作区感知(`cwd`):时光机工具与 REST 接口自动关联当前会话目录或支持显式工作区路径。
|
|
99
|
+
* **Added in v0.1.10**: 统一补丁支持(`format: "patch" | "stat"`),带输出截断保护(最大 256KB)。
|
|
100
|
+
* **Added in v0.1.10**: Git 变动操作异步排队队列,彻底解决高并发下的 `index.lock` 竞争冲突。
|
|
101
|
+
* **Added in v0.1.10**: 采用 `git for-each-ref` 批量查询引用,消除 $O(N)$ 次 `git log` 进程生成,极大加速快照加载。
|
|
102
|
+
* **Added in v0.1.10**: 空提交去重:若工作树无变化,自动跳过重复快照创建。
|
|
103
|
+
* **Added in v0.1.10**: 启动时自动清理遗留的 `tm_index_*` 临时暂存索引文件。
|
|
104
|
+
* **Changed in v0.1.10**: 精简设置卡片界面,引导在侧边栏时光机标签页查看时间线。
|
|
105
|
+
* **Changed in v0.1.10**: 完善错误告警日志(`console.warn`),替换静默吞并异常。
|
|
106
|
+
|
|
107
|
+
### v0.1.9 — 事件总线统一与依赖清理
|
|
108
|
+
* **Changed in v0.1.9**: 统一事件总线监听逻辑,杜绝自动快照重复触发。优先使用原生 DSH `session/event` 总线,仅在缺失 `ctx.on` 时启用 legacy `ctx.events` 回退。
|
|
109
|
+
* **Changed in v0.1.9**: 从 `peerDependencies` 中移除未使用的 `@deepseek-ai/dsh-credentials`。
|
|
110
|
+
* **Added in v0.1.9**: 新增项目设计规范文档 `docs/design/DESIGN.md`。
|
|
111
|
+
|
|
97
112
|
### v0.1.7 — 分支历史安全、暂存区隔离与 DSH 原生事件总线
|
|
98
113
|
* **Changed in v0.1.7**: 安全工作区回滚。改用 `read-tree` + `checkout-index` + `clean -fd`,彻底杜绝回滚时误将分支 `HEAD` 覆盖为孤立提交的严重缺陷。
|
|
99
114
|
* **Changed in v0.1.7**: 保护用户 Git 暂存区。快照操作通过独立的 `GIT_INDEX_FILE` 执行,不会覆盖 `.git/index` 中已暂存的文件。
|
package/lib/client.js
CHANGED
|
@@ -64,6 +64,7 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
autoHeal: 'Prompt recovery on failure',
|
|
65
65
|
unavailable: 'Settings unavailable',
|
|
66
66
|
tabTitle: 'Time Machine',
|
|
67
|
+
sidebarNotice: 'Checkpoints and timeline are available in the Time Machine sidebar tab.',
|
|
67
68
|
};
|
|
68
69
|
const ru = {
|
|
69
70
|
title: 'Машина времени и чекпоинты',
|
|
@@ -86,6 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
86
87
|
autoHeal: 'Предлагать восстановление при ошибке',
|
|
87
88
|
unavailable: 'Настройки недоступны',
|
|
88
89
|
tabTitle: 'Машина времени',
|
|
90
|
+
sidebarNotice: 'Лента чекпоинтов и управление снимками доступны во вкладке «Машина времени» боковой панели.',
|
|
89
91
|
};
|
|
90
92
|
const zh = {
|
|
91
93
|
title: '时光机与检查点',
|
|
@@ -108,6 +110,7 @@ window.__ModuleLoader__.load({
|
|
|
108
110
|
autoHeal: '命令失败时提示恢复',
|
|
109
111
|
unavailable: '设置不可用',
|
|
110
112
|
tabTitle: '时光机',
|
|
113
|
+
sidebarNotice: '检查点时间线与管理可在侧边栏“时光机”标签页中查看。',
|
|
111
114
|
};
|
|
112
115
|
|
|
113
116
|
let ChevronIcon = null;
|
|
@@ -176,7 +179,7 @@ window.__ModuleLoader__.load({
|
|
|
176
179
|
};
|
|
177
180
|
const onDiff = (id) => {
|
|
178
181
|
setBusy(true); setErr('');
|
|
179
|
-
fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
|
|
182
|
+
fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id) + '&format=patch').then(r => r.json()).then(j => {
|
|
180
183
|
if (!j.success) throw new Error(j.error || 'diff failed');
|
|
181
184
|
setDiffText(j.diff || '');
|
|
182
185
|
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
@@ -317,9 +320,8 @@ window.__ModuleLoader__.load({
|
|
|
317
320
|
React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
|
|
318
321
|
)
|
|
319
322
|
),
|
|
320
|
-
React.createElement('div', { className: 'tm-field' },
|
|
321
|
-
React.createElement('div', { className: 'tm-label' }, t.
|
|
322
|
-
React.createElement(Timeline, { t, ctx })
|
|
323
|
+
React.createElement('div', { className: 'tm-field', style: { borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(255,255,255,0.08))', paddingTop: 10, marginTop: 6 } },
|
|
324
|
+
React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-label-secondary)', fontStyle: 'italic' } }, t.sidebarNotice)
|
|
323
325
|
)
|
|
324
326
|
) : null
|
|
325
327
|
);
|
package/lib/index.js
CHANGED
|
@@ -12,112 +12,124 @@ export const Config = Schema.object({
|
|
|
12
12
|
|
|
13
13
|
const NS = '@goodandready/dsh-time-machine';
|
|
14
14
|
|
|
15
|
-
function
|
|
15
|
+
function cwdOf(execution, ctx) {
|
|
16
16
|
try {
|
|
17
|
-
if (execution
|
|
18
|
-
if (execution
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (execution && execution.agent && execution.agent.session) {
|
|
25
|
-
return String(execution.agent.session.id || execution.agent.session.header?.id || '');
|
|
26
|
-
}
|
|
27
|
-
if (ctx && ctx.session) return String(ctx.session.id || ctx.session.header?.id || '');
|
|
28
|
-
if (ctx && ctx.get && typeof ctx.get === 'function') {
|
|
29
|
-
try { const s = ctx.get('session'); if (s) return String(s.id || s.header?.id || ''); } catch {}
|
|
17
|
+
if (execution?.cwd) return String(execution.cwd);
|
|
18
|
+
if (execution?.session?.workspace?.cwd) return String(execution.session.workspace.cwd);
|
|
19
|
+
if (execution?.session?.cwd) return String(execution.session.cwd);
|
|
20
|
+
if (ctx?.workspace?.cwd) return String(ctx.workspace.cwd);
|
|
21
|
+
if (typeof ctx?.get === 'function') {
|
|
22
|
+
const ws = ctx.get('workspace');
|
|
23
|
+
if (ws?.cwd) return String(ws.cwd);
|
|
30
24
|
}
|
|
31
25
|
} catch {}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sessionIdOf(execution, ctx) {
|
|
30
|
+
try {
|
|
31
|
+
if (execution?.sessionId) return String(execution.sessionId);
|
|
32
|
+
if (execution?.session?.id) return String(execution.session.id);
|
|
33
|
+
if (execution?.data?.sessionId) return String(execution.data.sessionId);
|
|
34
|
+
if (ctx?.session?.id) return String(ctx.session.id);
|
|
35
|
+
} catch {}
|
|
32
36
|
return '';
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export function apply(ctx, config) {
|
|
36
40
|
let getConfig = () => config;
|
|
37
|
-
|
|
38
|
-
engine
|
|
41
|
+
const initialCwd = cwdOf(null, ctx);
|
|
42
|
+
let engine = new ShadowSnapshotEngine({ maxSnapshots: config?.maxSnapshots ?? 20, cwd: initialCwd });
|
|
43
|
+
engine.loadFromRefs({ cwd: initialCwd }).catch(() => {});
|
|
39
44
|
|
|
40
45
|
ctx.inject(['settings'], (sctx) => {
|
|
41
46
|
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
42
47
|
getConfig = () => scope.get() ?? config;
|
|
43
|
-
const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20);
|
|
48
|
+
const syncMax = () => engine.setMax(getConfig().maxSnapshots ?? 20, cwdOf(null, ctx));
|
|
44
49
|
syncMax();
|
|
45
50
|
const stop = scope.watch ? scope.watch(syncMax) : () => {};
|
|
46
51
|
sctx.effect(() => () => { try { stop(); } catch {} }, 'dsh-time-machine: settings watch');
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
// auto-snapshot on turn/start and approval/asked (session-scoped)
|
|
50
|
-
const autoSnap = async (label, sessId) => {
|
|
55
|
+
const autoSnap = async (label, sessId, customCwd) => {
|
|
51
56
|
if (!getConfig().autoSnapshotEnabled) return;
|
|
52
57
|
try {
|
|
53
58
|
const sid = String(sessId || '').trim();
|
|
54
|
-
|
|
59
|
+
const targetCwd = customCwd || cwdOf(null, ctx);
|
|
60
|
+
await engine.createSnapshot(label, { sessionId: sid, cwd: targetCwd, skipIfNoChanges: true });
|
|
55
61
|
} catch {}
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
// 1. Native DSH session/event bus (Cordis standard in DSH)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
65
|
+
// When native session/event bus is present, use it exclusively to prevent double-fire.
|
|
66
|
+
const hasNativeEvents = typeof ctx.on === 'function';
|
|
67
|
+
|
|
68
|
+
if (hasNativeEvents) {
|
|
69
|
+
ctx.effect(() => {
|
|
70
|
+
const off = ctx.on('session/event', (session, event) => {
|
|
71
|
+
const sid = (session && session.id) || sessionIdOf(event, ctx);
|
|
72
|
+
const sessionCwd = session?.workspace?.cwd || session?.cwd || cwdOf(event, ctx);
|
|
73
|
+
const type = event && event.type;
|
|
74
|
+
if (type === 'turn/start') {
|
|
75
|
+
const turnId = event.turnId || event.data?.turnId || Date.now();
|
|
76
|
+
autoSnap(`auto:turn:${turnId}`, sid, sessionCwd);
|
|
77
|
+
} else if (type === 'approval/asked') {
|
|
78
|
+
const tool = event.tool || event.data?.toolName || event.data?.tool || 'approval';
|
|
79
|
+
autoSnap(`auto:approval:${tool}`, sid, sessionCwd);
|
|
80
|
+
} else if (type === 'turn/end') {
|
|
81
|
+
if (!getConfig().autoSnapshotEnabled) return;
|
|
82
|
+
const outcome = String((event && (event.outcome || event.data?.outcome || event.result?.outcome || event.result?.status)) || '');
|
|
83
|
+
if (!sid) return;
|
|
84
|
+
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
85
|
+
engine.pruneSnapshots(sid, 3, { cwd: sessionCwd }).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
} else if (type === 'tool/error' || type === 'command/error') {
|
|
88
|
+
if (getConfig().autoHealPrompt) {
|
|
89
|
+
const tool = event.tool || event.data?.toolName || 'command';
|
|
90
|
+
autoSnap(`auto:error:${tool}`, sid, sessionCwd);
|
|
91
|
+
}
|
|
80
92
|
}
|
|
93
|
+
});
|
|
94
|
+
return () => { try { typeof off === 'function' && off(); } catch {} };
|
|
95
|
+
}, 'dsh-time-machine: native session events');
|
|
96
|
+
} else {
|
|
97
|
+
// 2. Legacy/mock events bus fallback (for unit tests and non-standard environments without ctx.on)
|
|
98
|
+
try {
|
|
99
|
+
if (ctx.events && typeof ctx.events.on === 'function') {
|
|
100
|
+
ctx.effect(() => {
|
|
101
|
+
const offs = [];
|
|
102
|
+
try {
|
|
103
|
+
offs.push(ctx.events.on('turn/start', (ev) => {
|
|
104
|
+
const sid = sessionIdOf(ev, ctx);
|
|
105
|
+
autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
|
|
106
|
+
}));
|
|
107
|
+
} catch {}
|
|
108
|
+
try {
|
|
109
|
+
offs.push(ctx.events.on('approval/asked', (ev) => {
|
|
110
|
+
const sid = sessionIdOf(ev, ctx);
|
|
111
|
+
const tool = ev?.tool || ev?.name || 'approval';
|
|
112
|
+
autoSnap(`auto:approval:${tool}`, sid);
|
|
113
|
+
}));
|
|
114
|
+
} catch {}
|
|
115
|
+
try {
|
|
116
|
+
offs.push(ctx.events.on('turn/end', (ev) => {
|
|
117
|
+
if (!getConfig().autoSnapshotEnabled) return;
|
|
118
|
+
const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
|
|
119
|
+
const sid = sessionIdOf(ev, ctx);
|
|
120
|
+
if (!sid) return;
|
|
121
|
+
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
122
|
+
engine.pruneSnapshots(sid, 3).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
} catch {}
|
|
126
|
+
return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
|
|
127
|
+
}, 'dsh-time-machine: auto snapshot events fallback');
|
|
81
128
|
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
}, 'dsh-time-machine: native session events');
|
|
85
|
-
|
|
86
|
-
// 2. Legacy/mock events bus fallback (for unit tests and non-standard environments)
|
|
87
|
-
try {
|
|
88
|
-
if (ctx.events && typeof ctx.events.on === 'function') {
|
|
89
|
-
ctx.effect(() => {
|
|
90
|
-
const offs = [];
|
|
91
|
-
try {
|
|
92
|
-
offs.push(ctx.events.on('turn/start', (ev) => {
|
|
93
|
-
const sid = sessionIdOf(ev, ctx);
|
|
94
|
-
autoSnap(`auto:turn:${ev?.turnId || Date.now()}`, sid);
|
|
95
|
-
}));
|
|
96
|
-
} catch {}
|
|
97
|
-
try {
|
|
98
|
-
offs.push(ctx.events.on('approval/asked', (ev) => {
|
|
99
|
-
const sid = sessionIdOf(ev, ctx);
|
|
100
|
-
const tool = ev?.tool || ev?.name || 'approval';
|
|
101
|
-
autoSnap(`auto:approval:${tool}`, sid);
|
|
102
|
-
}));
|
|
103
|
-
} catch {}
|
|
104
|
-
try {
|
|
105
|
-
offs.push(ctx.events.on('turn/end', (ev) => {
|
|
106
|
-
if (!getConfig().autoSnapshotEnabled) return;
|
|
107
|
-
const outcome = String((ev && (ev.outcome || ev.result?.outcome || ev.result?.status)) || '');
|
|
108
|
-
const sid = sessionIdOf(ev, ctx);
|
|
109
|
-
if (!sid) return;
|
|
110
|
-
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
111
|
-
engine.pruneSnapshots(sid, 3).catch(() => {});
|
|
112
|
-
}
|
|
113
|
-
}));
|
|
114
|
-
} catch {}
|
|
115
|
-
return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
|
|
116
|
-
}, 'dsh-time-machine: auto snapshot events');
|
|
117
|
-
}
|
|
118
|
-
} catch {}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
119
131
|
|
|
120
|
-
// tools (session-aware)
|
|
132
|
+
// tools (session-aware and cwd-aware)
|
|
121
133
|
const registerTools = (tctx) => {
|
|
122
134
|
tctx.tools.register({
|
|
123
135
|
name: 'time_machine_checkpoint_create',
|
|
@@ -127,11 +139,13 @@ export function apply(ctx, config) {
|
|
|
127
139
|
properties: {
|
|
128
140
|
label: { type: 'string', description: 'Human label for checkpoint' },
|
|
129
141
|
sessionId: { type: 'string', description: 'Session id to scope checkpoint (auto-detected if omitted)' },
|
|
142
|
+
cwd: { type: 'string', description: 'Target workspace directory (auto-detected if omitted)' },
|
|
130
143
|
},
|
|
131
144
|
},
|
|
132
145
|
execute: async (args = {}, execution) => {
|
|
133
|
-
const sid = String(args.sessionId || sessionIdOf(execution, tctx) ||
|
|
134
|
-
const
|
|
146
|
+
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
|
|
147
|
+
const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
|
|
148
|
+
const snap = await engine.createSnapshot(args.label, { sessionId: sid, cwd: targetCwd });
|
|
135
149
|
return { success: true, snapshot: snap };
|
|
136
150
|
},
|
|
137
151
|
});
|
|
@@ -160,28 +174,36 @@ export function apply(ctx, config) {
|
|
|
160
174
|
properties: {
|
|
161
175
|
id: { type: 'string', description: 'Snapshot id to rollback to' },
|
|
162
176
|
confirm: { type: 'boolean', description: 'Must be true to confirm destructive rollback' },
|
|
177
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
163
178
|
},
|
|
164
179
|
required: ['id', 'confirm'],
|
|
165
180
|
},
|
|
166
|
-
execute: async ({ id, confirm } = {}) => {
|
|
167
|
-
const
|
|
181
|
+
execute: async ({ id, confirm, cwd } = {}, execution) => {
|
|
182
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
183
|
+
const res = await engine.rollbackSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
|
|
168
184
|
return { success: true, ...res };
|
|
169
185
|
},
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
tctx.tools.register({
|
|
173
189
|
name: 'time_machine_diff',
|
|
174
|
-
description: 'Show diff between checkpoint and current or another checkpoint.',
|
|
190
|
+
description: 'Show unified diff or stat between checkpoint and current or another checkpoint.',
|
|
175
191
|
parameters: {
|
|
176
192
|
type: 'object',
|
|
177
193
|
properties: {
|
|
178
194
|
from: { type: 'string', description: 'Source snapshot id' },
|
|
179
195
|
to: { type: 'string', description: 'Target snapshot id (default HEAD)' },
|
|
196
|
+
format: { type: 'string', enum: ['patch', 'stat'], description: 'Format of diff: patch (unified diff) or stat (summary)' },
|
|
197
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
180
198
|
},
|
|
181
199
|
required: ['from'],
|
|
182
200
|
},
|
|
183
|
-
execute: async ({ from, to } = {}) => {
|
|
184
|
-
const
|
|
201
|
+
execute: async ({ from, to, format, cwd } = {}, execution) => {
|
|
202
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
203
|
+
const res = await engine.diff(String(from ?? ''), to ? String(to) : undefined, {
|
|
204
|
+
cwd: targetCwd,
|
|
205
|
+
format: format === 'stat' ? 'stat' : 'patch',
|
|
206
|
+
});
|
|
185
207
|
return { success: true, ...res };
|
|
186
208
|
},
|
|
187
209
|
});
|
|
@@ -194,11 +216,13 @@ export function apply(ctx, config) {
|
|
|
194
216
|
properties: {
|
|
195
217
|
id: { type: 'string', description: 'Snapshot id to delete' },
|
|
196
218
|
confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
|
|
219
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
197
220
|
},
|
|
198
221
|
required: ['id', 'confirm'],
|
|
199
222
|
},
|
|
200
|
-
execute: async ({ id, confirm } = {}) => {
|
|
201
|
-
const
|
|
223
|
+
execute: async ({ id, confirm, cwd } = {}, execution) => {
|
|
224
|
+
const targetCwd = cwd ? String(cwd) : cwdOf(execution, tctx);
|
|
225
|
+
const res = await engine.deleteSnapshot(String(id ?? ''), { confirm, cwd: targetCwd });
|
|
202
226
|
return { success: true, ...res };
|
|
203
227
|
},
|
|
204
228
|
});
|
|
@@ -211,11 +235,13 @@ export function apply(ctx, config) {
|
|
|
211
235
|
properties: {
|
|
212
236
|
sessionId: { type: 'string', description: 'Session id to prune (default current session)' },
|
|
213
237
|
keep: { type: 'number', description: 'How many newest checkpoints to keep (default 3)' },
|
|
238
|
+
cwd: { type: 'string', description: 'Target workspace directory' },
|
|
214
239
|
},
|
|
215
240
|
},
|
|
216
241
|
execute: async (args = {}, execution) => {
|
|
217
242
|
const sid = String(args.sessionId || sessionIdOf(execution, tctx) || '').trim();
|
|
218
|
-
const
|
|
243
|
+
const targetCwd = args.cwd ? String(args.cwd) : cwdOf(execution, tctx);
|
|
244
|
+
const out = await engine.pruneSnapshots(sid, args.keep, { cwd: targetCwd });
|
|
219
245
|
return { success: true, sessionId: sid, ...out };
|
|
220
246
|
},
|
|
221
247
|
});
|
|
@@ -253,7 +279,7 @@ export function apply(ctx, config) {
|
|
|
253
279
|
});
|
|
254
280
|
};
|
|
255
281
|
|
|
256
|
-
// web routes for UI (session-aware via ?sessionId=)
|
|
282
|
+
// web routes for UI (session-aware via ?sessionId= and ?format=)
|
|
257
283
|
ctx.effect(() => {
|
|
258
284
|
const disposals = [];
|
|
259
285
|
try {
|
|
@@ -275,8 +301,12 @@ export function apply(ctx, config) {
|
|
|
275
301
|
const url = new URL(req.url, 'http://localhost');
|
|
276
302
|
const from = url.searchParams.get('from') || '';
|
|
277
303
|
const to = url.searchParams.get('to') || undefined;
|
|
304
|
+
const format = url.searchParams.get('format') || 'patch';
|
|
278
305
|
try {
|
|
279
|
-
const out = await engine.diff(String(from), to ? String(to) : undefined
|
|
306
|
+
const out = await engine.diff(String(from), to ? String(to) : undefined, {
|
|
307
|
+
cwd: cwdOf(null, ctx),
|
|
308
|
+
format: format === 'stat' ? 'stat' : 'patch',
|
|
309
|
+
});
|
|
280
310
|
res.setHeader('content-type', 'application/json');
|
|
281
311
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
282
312
|
} catch (e) {
|
|
@@ -293,7 +323,8 @@ export function apply(ctx, config) {
|
|
|
293
323
|
readJsonBody(req, res, async (parsed) => {
|
|
294
324
|
try {
|
|
295
325
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
|
|
296
|
-
const
|
|
326
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
327
|
+
const snap = await engine.createSnapshot(parsed.label, { sessionId: sid, cwd: targetCwd });
|
|
297
328
|
res.setHeader('content-type', 'application/json');
|
|
298
329
|
res.end(JSON.stringify({ success: true, snapshot: snap }));
|
|
299
330
|
} catch (e) {
|
|
@@ -310,7 +341,8 @@ export function apply(ctx, config) {
|
|
|
310
341
|
handler: (req, res) => {
|
|
311
342
|
readJsonBody(req, res, async (parsed) => {
|
|
312
343
|
try {
|
|
313
|
-
const
|
|
344
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
345
|
+
const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
|
|
314
346
|
res.setHeader('content-type', 'application/json');
|
|
315
347
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
316
348
|
} catch (e) {
|
|
@@ -328,7 +360,8 @@ export function apply(ctx, config) {
|
|
|
328
360
|
readJsonBody(req, res, async (parsed) => {
|
|
329
361
|
try {
|
|
330
362
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
|
|
331
|
-
const
|
|
363
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
364
|
+
const out = await engine.pruneSnapshots(sid, parsed.keep, { cwd: targetCwd });
|
|
332
365
|
res.setHeader('content-type', 'application/json');
|
|
333
366
|
res.end(JSON.stringify({ success: true, sessionId: sid, ...out }));
|
|
334
367
|
} catch (e) {
|
|
@@ -345,7 +378,8 @@ export function apply(ctx, config) {
|
|
|
345
378
|
handler: (req, res) => {
|
|
346
379
|
readJsonBody(req, res, async (parsed) => {
|
|
347
380
|
try {
|
|
348
|
-
const
|
|
381
|
+
const targetCwd = parsed.cwd ? String(parsed.cwd) : cwdOf(null, ctx);
|
|
382
|
+
const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm, cwd: targetCwd });
|
|
349
383
|
res.setHeader('content-type', 'application/json');
|
|
350
384
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
351
385
|
} catch (e) {
|
package/lib/snapshot.js
CHANGED
|
@@ -18,159 +18,164 @@ async function defaultExec(cmd, args, opts = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const MAX_DIFF_LENGTH = 256 * 1024; // 256 KB safety limit
|
|
22
|
+
|
|
21
23
|
// ponytail: in-memory engine, git shadow refs if repo present; O(n) scan, per-session trim
|
|
22
24
|
export class ShadowSnapshotEngine {
|
|
23
25
|
constructor({ exec = defaultExec, maxSnapshots = 20, cwd } = {}) {
|
|
24
26
|
this.exec = exec;
|
|
25
27
|
this.maxSnapshots = maxSnapshots;
|
|
26
28
|
this.cwd = cwd;
|
|
27
|
-
this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref}
|
|
29
|
+
this.snapshots = []; // {id, label, sessionId, createdAt, commit, ref, treeHash}
|
|
28
30
|
this.seq = 0;
|
|
31
|
+
this._queue = Promise.resolve();
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
_now() { return Date.now(); }
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (oldest?.ref) {
|
|
38
|
-
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
44
|
-
while (perSession.length > this.maxSnapshots) {
|
|
45
|
-
const oldest = perSession.shift();
|
|
46
|
-
const idx = this.snapshots.indexOf(oldest);
|
|
47
|
-
if (idx !== -1) {
|
|
48
|
-
this.snapshots.splice(idx, 1);
|
|
49
|
-
if (oldest?.ref) {
|
|
50
|
-
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
36
|
+
_enqueue(operation) {
|
|
37
|
+
const next = this._queue.then(() => operation(), () => operation());
|
|
38
|
+
this._queue = next.catch(() => {});
|
|
39
|
+
return next;
|
|
54
40
|
}
|
|
55
41
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await this.exec('git', ['rev-parse', '--is-inside-work-tree'], this.cwd ? { cwd: this.cwd } : {});
|
|
59
|
-
return true;
|
|
60
|
-
} catch { return false; }
|
|
42
|
+
_resolveCwd(customCwd) {
|
|
43
|
+
return customCwd || this.cwd || process.cwd();
|
|
61
44
|
}
|
|
62
45
|
|
|
63
|
-
async
|
|
46
|
+
async _hasGit(targetCwd) {
|
|
64
47
|
try {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
return path.isAbsolute(gdir) ? gdir : path.resolve(this.cwd || process.cwd(), gdir);
|
|
48
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
49
|
+
const { stdout } = await this.exec('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
|
|
50
|
+
return stdout.trim() === 'true';
|
|
69
51
|
} catch {
|
|
70
|
-
return
|
|
52
|
+
return false;
|
|
71
53
|
}
|
|
72
54
|
}
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
async loadFromRefs() {
|
|
77
|
-
if (!(await this._isGitRepo())) return 0;
|
|
78
|
-
let refs = [];
|
|
56
|
+
async cleanupOrphanedIndices(targetCwd) {
|
|
57
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
79
58
|
try {
|
|
80
|
-
const { stdout } = await this.exec(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (this.snapshots.some(s => s.ref === ref)) continue;
|
|
92
|
-
let createdAt = 0;
|
|
93
|
-
let label = ref.split('/').pop();
|
|
94
|
-
try {
|
|
95
|
-
const { stdout } = await this.exec(
|
|
96
|
-
'git',
|
|
97
|
-
['log', '-1', '--format=%ct%00%s', ref],
|
|
98
|
-
this.cwd ? { cwd: this.cwd } : {},
|
|
99
|
-
);
|
|
100
|
-
const [ct, msg] = String(stdout ?? '').split('\0');
|
|
101
|
-
createdAt = Number(ct) * 1000 || 0;
|
|
102
|
-
if (msg && String(msg).trim()) label = String(msg).trim();
|
|
103
|
-
} catch {}
|
|
104
|
-
const short = ref.replace(/^refs\/dsh-time-machine\//, '');
|
|
105
|
-
const slash = short.indexOf('/');
|
|
106
|
-
const sessionId = slash === -1 ? '' : short.slice(0, slash);
|
|
107
|
-
const id = slash === -1 ? short : short.slice(slash + 1);
|
|
108
|
-
restored.push({ id: id || short, label, sessionId, createdAt, commit, ref, seq: 0 });
|
|
59
|
+
const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
|
|
60
|
+
const gitDir = stdout.trim();
|
|
61
|
+
const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
|
|
62
|
+
const files = await fs.readdir(resolvedGitDir).catch(() => []);
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
if (file.startsWith('tm_index_')) {
|
|
65
|
+
await fs.unlink(path.join(resolvedGitDir, file)).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[dsh-time-machine] cleanupOrphanedIndices warning:', e.message || e);
|
|
109
70
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
for (const sid of sessions) await this._trimFor(sid);
|
|
116
|
-
if (sessions.length === 0) {
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async _trimFor(sessionId, targetCwd) {
|
|
74
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
75
|
+
if (!sessionId) {
|
|
117
76
|
while (this.snapshots.length > this.maxSnapshots) {
|
|
118
77
|
const oldest = this.snapshots.shift();
|
|
119
78
|
if (oldest?.ref) {
|
|
120
|
-
try {
|
|
79
|
+
try {
|
|
80
|
+
await this.exec('git', ['update-ref', '-d', oldest.ref], { cwd });
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn('[dsh-time-machine] failed to delete evicted ref:', oldest.ref, e.message || e);
|
|
83
|
+
}
|
|
121
84
|
}
|
|
122
85
|
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const sessionSnaps = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
89
|
+
const toRemoveCount = sessionSnaps.length - this.maxSnapshots;
|
|
90
|
+
if (toRemoveCount > 0) {
|
|
91
|
+
const toRemove = sessionSnaps.slice(0, toRemoveCount);
|
|
92
|
+
const removeIds = new Set(toRemove.map(s => s.id));
|
|
93
|
+
for (const snap of toRemove) {
|
|
94
|
+
if (snap.ref) {
|
|
95
|
+
try {
|
|
96
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.warn('[dsh-time-machine] failed to delete session ref:', snap.ref, e.message || e);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
this.snapshots = this.snapshots.filter(s => !removeIds.has(s.id));
|
|
123
103
|
}
|
|
124
|
-
return restored.length;
|
|
125
104
|
}
|
|
126
105
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
106
|
+
createSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
|
|
107
|
+
return this._enqueue(() => this._doCreateSnapshot(label, { sessionId, cwd: customCwd }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async _doCreateSnapshot(label, { sessionId = '', cwd: customCwd } = {}) {
|
|
111
|
+
const cwd = this._resolveCwd(customCwd);
|
|
112
|
+
const id = `snap-${this._now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
113
|
+
const cleanLabel = String(label || 'checkpoint').slice(0, 100);
|
|
114
|
+
const cleanSession = String(sessionId || '').trim();
|
|
133
115
|
let commit = null;
|
|
134
116
|
let ref = null;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
117
|
+
let treeHash = null;
|
|
118
|
+
|
|
119
|
+
if (await this._hasGit(cwd)) {
|
|
120
|
+
const gitDirRes = await this.exec('git', ['rev-parse', '--git-dir'], { cwd });
|
|
121
|
+
const gitDir = gitDirRes.stdout.trim();
|
|
122
|
+
const resolvedGitDir = path.isAbsolute(gitDir) ? gitDir : path.resolve(cwd, gitDir);
|
|
123
|
+
const tempIndexFile = path.join(resolvedGitDir, `tm_index_${crypto.randomBytes(4).toString('hex')}`);
|
|
124
|
+
|
|
125
|
+
const gitEnv = {
|
|
126
|
+
...process.env,
|
|
127
|
+
GIT_INDEX_FILE: tempIndexFile,
|
|
128
|
+
GIT_AUTHOR_NAME: 'DSH Time Machine',
|
|
129
|
+
GIT_AUTHOR_EMAIL: 'time-machine@deepseek.local',
|
|
130
|
+
GIT_COMMITTER_NAME: 'DSH Time Machine',
|
|
131
|
+
GIT_COMMITTER_EMAIL: 'time-machine@deepseek.local',
|
|
132
|
+
};
|
|
133
|
+
|
|
138
134
|
try {
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if (gitDir) {
|
|
150
|
-
shadowIndex = path.join(gitDir, `tm_index_${id}`);
|
|
151
|
-
execEnv.GIT_INDEX_FILE = shadowIndex;
|
|
135
|
+
await this.exec('git', ['add', '-A'], { cwd, env: gitEnv });
|
|
136
|
+
const treeRes = await this.exec('git', ['write-tree'], { cwd, env: gitEnv });
|
|
137
|
+
treeHash = treeRes.stdout.trim();
|
|
138
|
+
|
|
139
|
+
// Check if identical to last snapshot for this session to avoid redundant commits
|
|
140
|
+
const lastSnap = this.snapshots.filter(s => s.sessionId === cleanSession).slice(-1)[0];
|
|
141
|
+
if (lastSnap && lastSnap.treeHash && lastSnap.treeHash === treeHash) {
|
|
142
|
+
return lastSnap;
|
|
152
143
|
}
|
|
153
144
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
ref = safeSid ? `refs/dsh-time-machine/${safeSid}/${id}` : `refs/dsh-time-machine/${id}`;
|
|
161
|
-
await this.exec('git', ['update-ref', ref, commit], baseOpts);
|
|
162
|
-
} catch {
|
|
163
|
-
commit = null;
|
|
164
|
-
ref = null;
|
|
165
|
-
} finally {
|
|
166
|
-
if (shadowIndex) {
|
|
167
|
-
try { await fs.unlink(shadowIndex); } catch {}
|
|
145
|
+
const headCommitRes = await this.exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd }).catch(() => ({ stdout: '' }));
|
|
146
|
+
const headCommit = headCommitRes.stdout.trim();
|
|
147
|
+
|
|
148
|
+
const commitArgs = ['commit-tree', treeHash, '-m', `[dsh-time-machine] ${cleanLabel}`];
|
|
149
|
+
if (headCommit) {
|
|
150
|
+
commitArgs.push('-p', headCommit);
|
|
168
151
|
}
|
|
152
|
+
const commitRes = await this.exec('git', commitArgs, { cwd, env: gitEnv });
|
|
153
|
+
commit = commitRes.stdout.trim();
|
|
154
|
+
|
|
155
|
+
const refPath = cleanSession
|
|
156
|
+
? `refs/dsh-time-machine/${cleanSession}/${id}`
|
|
157
|
+
: `refs/dsh-time-machine/${id}`;
|
|
158
|
+
await this.exec('git', ['update-ref', refPath, commit], { cwd });
|
|
159
|
+
ref = refPath;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.warn('[dsh-time-machine] git snapshot creation failed, falling back to memory record:', e.message || e);
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.unlink(tempIndexFile).catch(() => {});
|
|
169
164
|
}
|
|
170
165
|
}
|
|
171
|
-
|
|
166
|
+
|
|
167
|
+
const snap = {
|
|
168
|
+
id,
|
|
169
|
+
label: cleanLabel,
|
|
170
|
+
sessionId: cleanSession,
|
|
171
|
+
createdAt: this._now(),
|
|
172
|
+
commit,
|
|
173
|
+
ref,
|
|
174
|
+
treeHash,
|
|
175
|
+
seq: ++this.seq,
|
|
176
|
+
};
|
|
172
177
|
this.snapshots.push(snap);
|
|
173
|
-
await this._trimFor(
|
|
178
|
+
await this._trimFor(cleanSession, cwd);
|
|
174
179
|
return snap;
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -184,111 +189,177 @@ export class ShadowSnapshotEngine {
|
|
|
184
189
|
return this.snapshots.find(s => s.id === id) || null;
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
throw
|
|
192
|
-
|
|
192
|
+
deleteSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
193
|
+
return this._enqueue(async () => {
|
|
194
|
+
if (!confirm) throw new Error('confirm required to delete snapshot');
|
|
195
|
+
const idx = this.snapshots.findIndex(s => s.id === id);
|
|
196
|
+
if (idx === -1) throw new Error(`Snapshot not found: ${id}`);
|
|
197
|
+
const snap = this.snapshots[idx];
|
|
198
|
+
const cwd = this._resolveCwd(customCwd);
|
|
199
|
+
if (snap.ref && await this._hasGit(cwd)) {
|
|
200
|
+
try {
|
|
201
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
console.warn('[dsh-time-machine] failed to delete ref on deleteSnapshot:', snap.ref, e.message || e);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.snapshots.splice(idx, 1);
|
|
207
|
+
return { deleted: true, id };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
pruneSnapshots(sessionId = '', keep = 3, { cwd: customCwd } = {}) {
|
|
212
|
+
return this._enqueue(async () => {
|
|
213
|
+
const sid = sessionId != null ? String(sessionId).trim() : '';
|
|
214
|
+
const list = this.listSnapshots(sid); // newest first
|
|
215
|
+
const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
|
|
216
|
+
const toRemove = list.slice(minKeep);
|
|
217
|
+
const removed = [];
|
|
218
|
+
const cwd = this._resolveCwd(customCwd);
|
|
219
|
+
for (const snap of toRemove) {
|
|
220
|
+
const idx = this.snapshots.findIndex(s => s.id === snap.id);
|
|
221
|
+
if (idx !== -1) {
|
|
222
|
+
if (snap.ref && await this._hasGit(cwd)) {
|
|
223
|
+
try {
|
|
224
|
+
await this.exec('git', ['update-ref', '-d', snap.ref], { cwd });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.warn('[dsh-time-machine] failed to delete ref on pruneSnapshots:', snap.ref, e.message || e);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.snapshots.splice(idx, 1);
|
|
230
|
+
removed.push(snap.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { removed, kept: list.length - removed.length };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
rollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
238
|
+
return this._enqueue(() => this._doRollbackSnapshot(id, { confirm, cwd: customCwd }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _doRollbackSnapshot(id, { confirm = false, cwd: customCwd } = {}) {
|
|
242
|
+
if (!confirm) throw new Error('confirm required for rollback');
|
|
193
243
|
const snap = this.getSnapshot(id);
|
|
194
|
-
if (!snap) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
throw err;
|
|
198
|
-
}
|
|
199
|
-
const inGit = await this._isGitRepo();
|
|
200
|
-
if (inGit && snap.commit) {
|
|
201
|
-
const opts = this.cwd ? { cwd: this.cwd } : {};
|
|
244
|
+
if (!snap) throw new Error(`Snapshot not found: ${id}`);
|
|
245
|
+
const cwd = this._resolveCwd(customCwd);
|
|
246
|
+
if (snap.commit && await this._hasGit(cwd)) {
|
|
202
247
|
try {
|
|
203
|
-
|
|
204
|
-
await this.exec('git', ['
|
|
205
|
-
await this.exec('git', ['
|
|
206
|
-
await this.exec('git', ['clean', '-fd'], opts);
|
|
248
|
+
await this.exec('git', ['read-tree', snap.commit], { cwd });
|
|
249
|
+
await this.exec('git', ['checkout-index', '-a', '-f'], { cwd });
|
|
250
|
+
await this.exec('git', ['clean', '-fd'], { cwd });
|
|
207
251
|
} catch (e) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await this.exec('git', ['clean', '-fd'], opts).catch(() => {});
|
|
252
|
+
console.warn('[dsh-time-machine] rollback error:', e.message || e);
|
|
253
|
+
throw new Error(`Rollback execution failed: ${e.message || e}`);
|
|
211
254
|
}
|
|
212
255
|
}
|
|
213
|
-
return { rolledBack: true,
|
|
256
|
+
return { rolledBack: true, id, commit: snap.commit };
|
|
214
257
|
}
|
|
215
258
|
|
|
216
|
-
async
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (!
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
throw err;
|
|
227
|
-
}
|
|
228
|
-
if (snap.ref) {
|
|
229
|
-
try {
|
|
230
|
-
await this.exec('git', ['update-ref', '-d', snap.ref], this.cwd ? { cwd: this.cwd } : {});
|
|
231
|
-
} catch {}
|
|
259
|
+
async diff(fromId, toId = undefined, { cwd: customCwd, format = 'patch' } = {}) {
|
|
260
|
+
const snapFrom = this.getSnapshot(fromId);
|
|
261
|
+
if (!snapFrom) throw new Error(`Snapshot not found: ${fromId}`);
|
|
262
|
+
const snapTo = toId ? this.getSnapshot(toId) : null;
|
|
263
|
+
if (toId && !snapTo) throw new Error(`Target snapshot not found: ${toId}`);
|
|
264
|
+
|
|
265
|
+
const cwd = this._resolveCwd(customCwd);
|
|
266
|
+
if (!(await this._hasGit(cwd)) || !snapFrom.commit) {
|
|
267
|
+
const toLabel = snapTo ? snapTo.label : "current";
|
|
268
|
+
return { from: fromId, to: toId || "HEAD", diff: `diff ${snapFrom.label} -> ${toLabel} (no git repo)`, format };
|
|
232
269
|
}
|
|
233
|
-
const idx = this.snapshots.indexOf(snap);
|
|
234
|
-
if (idx !== -1) this.snapshots.splice(idx, 1);
|
|
235
|
-
return { deleted: true, snapshot: snap };
|
|
236
|
-
}
|
|
237
270
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
271
|
+
try {
|
|
272
|
+
const fromRef = snapFrom.commit || snapFrom.ref;
|
|
273
|
+
let diffArgs = ['diff'];
|
|
274
|
+
if (format === 'stat') {
|
|
275
|
+
diffArgs.push('--stat');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (snapTo) {
|
|
279
|
+
const toRef = snapTo.commit || snapTo.ref;
|
|
280
|
+
diffArgs.push(fromRef, toRef);
|
|
281
|
+
} else {
|
|
282
|
+
diffArgs.push(fromRef);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { stdout } = await this.exec('git', diffArgs, { cwd });
|
|
286
|
+
let diffOutput = stdout;
|
|
287
|
+
let truncated = false;
|
|
288
|
+
if (diffOutput.length > MAX_DIFF_LENGTH) {
|
|
289
|
+
diffOutput = diffOutput.slice(0, MAX_DIFF_LENGTH) + '\n\n... [diff truncated: output exceeded limit]';
|
|
290
|
+
truncated = true;
|
|
291
|
+
}
|
|
292
|
+
return { diff: diffOutput, from: fromId, to: toId || 'HEAD', format, truncated };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
console.warn('[dsh-time-machine] diff computation error:', e.message || e);
|
|
295
|
+
return { diff: `[diff error: ${e.message || e}]`, from: fromId, to: toId || 'HEAD', format };
|
|
246
296
|
}
|
|
247
|
-
return { removed, kept: list.length - removed.length };
|
|
248
297
|
}
|
|
249
298
|
|
|
250
|
-
async
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
: ['diff', '--stat', from.commit];
|
|
265
|
-
const { stdout } = await this.exec('git', args, opts);
|
|
266
|
-
const diffStat = String(stdout ?? '').trim();
|
|
267
|
-
if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
|
|
268
|
-
} catch {}
|
|
269
|
-
try {
|
|
270
|
-
const args = to?.commit
|
|
271
|
-
? ['diff', '--name-only', `${from.commit}..${to.commit}`]
|
|
272
|
-
: ['diff', '--name-only', from.commit];
|
|
273
|
-
const { stdout } = await this.exec('git', args, opts);
|
|
274
|
-
return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
|
|
275
|
-
} catch {}
|
|
299
|
+
async loadFromRefs(targetCwd) {
|
|
300
|
+
const cwd = this._resolveCwd(targetCwd);
|
|
301
|
+
if (!(await this._hasGit(cwd))) return 0;
|
|
302
|
+
let refs = [];
|
|
303
|
+
try {
|
|
304
|
+
// Support both single-batch with committerdate/subject and basic for-each-ref
|
|
305
|
+
const { stdout } = await this.exec('git', [
|
|
306
|
+
'for-each-ref',
|
|
307
|
+
'--format=%(refname)%00%(objectname)%00%(committerdate:raw)%00%(contents:subject)',
|
|
308
|
+
'refs/dsh-time-machine'
|
|
309
|
+
], { cwd });
|
|
310
|
+
refs = String(stdout ?? '').trim().split('\n').filter(Boolean);
|
|
311
|
+
} catch {
|
|
312
|
+
return 0;
|
|
276
313
|
}
|
|
277
|
-
const toLabel = to ? to.label : 'current';
|
|
278
|
-
return { from: fromId, to: toId || 'HEAD', diff: `diff ${from.label} -> ${toLabel} (no git repo)` };
|
|
279
|
-
}
|
|
280
314
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
315
|
+
let restoredCount = 0;
|
|
316
|
+
for (const line of refs) {
|
|
317
|
+
const parts = line.split('\0');
|
|
318
|
+
const ref = parts[0]?.trim();
|
|
319
|
+
const commit = parts[1]?.trim();
|
|
320
|
+
if (!ref || !commit) continue;
|
|
321
|
+
if (this.snapshots.some(s => s.ref === ref)) continue;
|
|
322
|
+
|
|
323
|
+
let createdAt = 0;
|
|
324
|
+
let label = ref.split('/').pop();
|
|
325
|
+
|
|
326
|
+
// If parts has date and subject from single-batch
|
|
327
|
+
if (parts.length >= 4 && parts[2] && parts[3] !== undefined) {
|
|
328
|
+
const timestampSeconds = parseInt(parts[2].split(' ')[0], 10);
|
|
329
|
+
if (!isNaN(timestampSeconds)) createdAt = timestampSeconds * 1000;
|
|
330
|
+
const sub = parts[3].trim().replace(/^\[dsh-time-machine\]\s*/, '');
|
|
331
|
+
if (sub) label = sub;
|
|
332
|
+
} else {
|
|
333
|
+
// Fallback for tests or git versions returning minimal for-each-ref
|
|
334
|
+
try {
|
|
335
|
+
const { stdout } = await this.exec('git', ['log', '-1', '--format=%ct%00%s', ref], { cwd });
|
|
336
|
+
const [ct, msg] = String(stdout ?? '').split('\0');
|
|
337
|
+
createdAt = Number(ct) * 1000 || 0;
|
|
338
|
+
if (msg && String(msg).trim()) label = String(msg).trim();
|
|
339
|
+
} catch {}
|
|
291
340
|
}
|
|
341
|
+
|
|
342
|
+
const short = ref.replace(/^refs\/dsh-time-machine\//, '');
|
|
343
|
+
const slash = short.indexOf('/');
|
|
344
|
+
const sessionId = slash === -1 ? '' : short.slice(0, slash);
|
|
345
|
+
const id = slash === -1 ? short : short.slice(slash + 1);
|
|
346
|
+
|
|
347
|
+
this.snapshots.push({
|
|
348
|
+
id: id || short,
|
|
349
|
+
label,
|
|
350
|
+
sessionId,
|
|
351
|
+
createdAt,
|
|
352
|
+
commit,
|
|
353
|
+
ref,
|
|
354
|
+
seq: ++this.seq,
|
|
355
|
+
});
|
|
356
|
+
restoredCount++;
|
|
292
357
|
}
|
|
358
|
+
|
|
359
|
+
this.snapshots.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0) || a.seq - b.seq);
|
|
360
|
+
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
361
|
+
for (const sid of sessions) await this._trimFor(sid, cwd);
|
|
362
|
+
if (sessions.length === 0) await this._trimFor('', cwd);
|
|
363
|
+
return restoredCount;
|
|
293
364
|
}
|
|
294
365
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-time-machine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
50
|
-
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
51
50
|
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
52
51
|
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
53
52
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|