@goodandready/dsh-time-machine 0.1.6 → 0.1.8
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 +33 -16
- package/README.ru.md +35 -14
- package/README.zh.md +41 -3
- package/lib/client.js +39 -95
- package/lib/index.js +71 -27
- package/lib/snapshot.js +92 -26
- package/package.json +2 -4
package/README.md
CHANGED
|
@@ -60,38 +60,41 @@ graph LR
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
-
##
|
|
63
|
+
## 🌟 Key Capabilities
|
|
64
64
|
|
|
65
|
-
### 1. 🛡️
|
|
66
|
-
* Captures
|
|
67
|
-
* Zero
|
|
68
|
-
*
|
|
65
|
+
### 1. 🛡️ Lightweight Shadow Git Snapshots
|
|
66
|
+
* Captures the full working tree, staged changes, and untracked files using isolated Git shadow tree references;
|
|
67
|
+
* Zero interference with user commit history, current active branch, or repository staging area;
|
|
68
|
+
* Maintains a rolling history of the most recent checkpoints with clear timestamps, session scoping, and labels.
|
|
69
69
|
|
|
70
70
|
### 2. ⏪ Instant Safe Rollback (`time_machine_checkpoint_rollback`)
|
|
71
71
|
* Restores the entire workspace or specific files to any previous checkpoint in milliseconds;
|
|
72
|
+
* Uses non-destructive checkout-index and clean without moving `HEAD` or rewriting branch history;
|
|
72
73
|
* Can be triggered programmatically by the agent or interactively by the user in the UI.
|
|
73
74
|
|
|
74
75
|
### 3. 🔍 Visual Snapshot Diff Inspector (`time_machine_diff`)
|
|
75
76
|
* Computes file-by-file visual diffs comparing current workspace state against any checkpoint;
|
|
76
|
-
* Highlights added, deleted, and modified lines with clean line numbers.
|
|
77
|
+
* Highlights added, deleted, and modified lines with clean line numbers and statistics.
|
|
77
78
|
|
|
78
|
-
### 4. 🕒 Interactive Sidebar Timeline (`lib/client.js`)
|
|
79
|
-
* Seamlessly integrates into DSH Web UI sidebar;
|
|
80
|
-
* Displays a chronological timeline of
|
|
79
|
+
### 4. 🕒 Interactive Sidebar & Settings Timeline (`lib/client.js`)
|
|
80
|
+
* Seamlessly integrates into DSH Web UI sidebar and settings tab;
|
|
81
|
+
* Displays a chronological timeline of session checkpoints with 1-click "Rollback", "Diff", and "Delete" buttons.
|
|
81
82
|
|
|
82
83
|
### 5. 🩹 Auto-Heal on Command Failure
|
|
83
|
-
* Automatically
|
|
84
|
+
* Automatically records checkpoints when a tool or command fails, preserving recovery options.
|
|
84
85
|
|
|
85
86
|
---
|
|
86
87
|
|
|
87
|
-
## 🛠️ Agent Tools Reference (
|
|
88
|
+
## 🛠️ Agent Tools Reference (6 Tools)
|
|
88
89
|
|
|
89
90
|
| Tool Name | Parameters | Description |
|
|
90
91
|
|---|---|---|
|
|
91
|
-
| `time_machine_checkpoint_create` | `label?: string` | Creates a shadow git workspace checkpoint before risky edits or operations |
|
|
92
|
-
| `time_machine_checkpoint_list` |
|
|
93
|
-
| `time_machine_checkpoint_rollback` | `id: string` |
|
|
94
|
-
| `time_machine_diff` | `
|
|
92
|
+
| `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | Creates a shadow git workspace checkpoint before risky edits or operations |
|
|
93
|
+
| `time_machine_checkpoint_list` | `sessionId?: string` | Lists all recent workspace checkpoints newest first |
|
|
94
|
+
| `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | Safely reverts workspace files back to specified checkpoint without altering branch HEAD |
|
|
95
|
+
| `time_machine_diff` | `from: string, to?: string` | Returns unified file diff between current workspace and target checkpoint |
|
|
96
|
+
| `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | Permanently deletes a single checkpoint and purges its git reference |
|
|
97
|
+
| `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | Prunes session checkpoints, keeping only the newest N checkpoints |
|
|
95
98
|
|
|
96
99
|
---
|
|
97
100
|
|
|
@@ -114,6 +117,20 @@ dsh-time-machine:
|
|
|
114
117
|
|
|
115
118
|
---
|
|
116
119
|
|
|
120
|
+
## 📋 Release Notes
|
|
121
|
+
|
|
122
|
+
### v0.1.7 — Critical Safety, Staging Isolation & Native Event Bus
|
|
123
|
+
* **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
|
+
* **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`.
|
|
125
|
+
* **Changed in v0.1.7**: Native DSH session event integration. Subscribed to `session/event` bus for automated checkpoints on `turn/start`, `approval/asked`, `turn/end`, and command errors.
|
|
126
|
+
* **Changed in v0.1.7**: Automatic Git ref cleanup on snapshot eviction to eliminate disk ref leaks.
|
|
127
|
+
* **Changed in v0.1.7**: Direct working directory diff computation when comparing checkpoints with uncommitted changes.
|
|
128
|
+
* **Changed in v0.1.7**: Strict compliance with DSH Plugin Authoring guidelines: form fields are enabled only when settings status is `ready`.
|
|
129
|
+
* **Added in v0.1.7**: WebServer request body size limit (1MB max payload) for DoS protection.
|
|
130
|
+
* **Added in v0.1.7**: Complete Chinese localization (`zh`) in frontend interface.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
117
134
|
## 📄 License
|
|
118
135
|
|
|
119
|
-
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
136
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/README.ru.md
CHANGED
|
@@ -60,34 +60,41 @@ graph LR
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
-
##
|
|
63
|
+
## 🌟 Ключевые возможности
|
|
64
64
|
|
|
65
|
-
### 1. 🛡️
|
|
65
|
+
### 1. 🛡️ Легковесные теневые Git-снапшоты
|
|
66
66
|
* Фиксирует всё рабочее дерево, индексированные и новые файлы через изолированные shadow-ссылки Git;
|
|
67
|
-
* Не создаёт лишних коммитов в пользовательской
|
|
68
|
-
*
|
|
67
|
+
* Не создаёт лишних коммитов в пользовательской истории, не переключает активные ветки и не сдвигает указатель `HEAD`;
|
|
68
|
+
* Изолирует рабочий индекс `.git/index` от фонового сохранения чекпоинтов через переменную `GIT_INDEX_FILE`;
|
|
69
|
+
* Хранит скользящую историю последних $N$ чекпоинтов с понятными метками, сессионной привязкой и метками времени.
|
|
69
70
|
|
|
70
71
|
### 2. ⏪ Мгновенный безопасный откат (`time_machine_checkpoint_rollback`)
|
|
71
72
|
* Возвращает всё рабочее пространство к любому предыдущему состоянию за миллисекунды;
|
|
73
|
+
* Не затирает историю веток Git: откат выполняется через безопасный `read-tree` + `checkout-index` + `clean -fd`;
|
|
72
74
|
* Может вызываться как программно агентом, так и пользователем через кнопку в интерфейсе.
|
|
73
75
|
|
|
74
76
|
### 3. 🔍 Визуальный инспектор Diff (`time_machine_diff`)
|
|
75
|
-
* Сравнивает текущие файлы со снапшотом и формирует наглядный пофайловый Diff.
|
|
77
|
+
* Сравнивает текущие файлы рабочего каталога со снапшотом и формирует наглядный пофайловый Diff.
|
|
76
78
|
|
|
77
|
-
### 4. 🕒 Интерактивная хроника в сайдбаре (`lib/client.js`)
|
|
78
|
-
* Встраивается в боковую панель Web UI DSH
|
|
79
|
-
* Показывает список чекпоинтов
|
|
79
|
+
### 4. 🕒 Интерактивная хроника в сайдбаре и настройках (`lib/client.js`)
|
|
80
|
+
* Встраивается в боковую панель Web UI DSH и карточку настроек плагинов;
|
|
81
|
+
* Показывает список чекпоинтов с кнопками «Откатить», «Сравнить Diff» и «Удалить».
|
|
82
|
+
|
|
83
|
+
### 5. 🩹 Авто-восстановление при сбоях команд
|
|
84
|
+
* Автоматически фиксирует контрольную точку при сбоях выполнения инструментов и команд.
|
|
80
85
|
|
|
81
86
|
---
|
|
82
87
|
|
|
83
|
-
## 🛠️ Инструменты агента (
|
|
88
|
+
## 🛠️ Инструменты агента (6 инструментов)
|
|
84
89
|
|
|
85
90
|
| Имя инструмента | Параметры | Описание |
|
|
86
91
|
|---|---|---|
|
|
87
|
-
| `time_machine_checkpoint_create` | `label?: string` | Создаёт теневой чекпоинт перед рискованными правками |
|
|
88
|
-
| `time_machine_checkpoint_list` |
|
|
89
|
-
| `time_machine_checkpoint_rollback` | `id: string` |
|
|
90
|
-
| `time_machine_diff` | `
|
|
92
|
+
| `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | Создаёт теневой чекпоинт перед рискованными правками |
|
|
93
|
+
| `time_machine_checkpoint_list` | `sessionId?: string` | Возвращает список недавних снапшотов от новых к старым |
|
|
94
|
+
| `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | Безопасно откатывает файлы рабочего пространства без изменения HEAD ветки |
|
|
95
|
+
| `time_machine_diff` | `from: string, to?: string` | Возвращает пофайловый Diff между текущим состоянием и чекпоинтом |
|
|
96
|
+
| `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | Удаляет отдельный чекпоинт и очищает соответствующий Git ref |
|
|
97
|
+
| `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | Прореживает чекпоинты сессии, сохраняя указанное количество самых свежих |
|
|
91
98
|
|
|
92
99
|
---
|
|
93
100
|
|
|
@@ -110,6 +117,20 @@ dsh-time-machine:
|
|
|
110
117
|
|
|
111
118
|
---
|
|
112
119
|
|
|
120
|
+
## 📋 История версий (Release Notes)
|
|
121
|
+
|
|
122
|
+
### v0.1.7 — Безопасность истории веток, изоляция индекса и события DSH
|
|
123
|
+
* **Changed in v0.1.7**: Безопасный откат рабочего каталога через `read-tree` + `checkout-index` + `clean -fd`. Устранена критическая проблема переноса `HEAD` ветки на сиротский коммит.
|
|
124
|
+
* **Changed in v0.1.7**: Защита пользовательского индекса Git. Чекпоинты создаются с изолированным `GIT_INDEX_FILE`, предотвращая перезапись подготовленных файлов в `.git/index`.
|
|
125
|
+
* **Changed in v0.1.7**: Поддержка нативной шины событий DSH `session/event` для автоматических чекпоинтов на `turn/start`, `approval/asked`, `turn/end` и аварийных снапшотов при ошибках.
|
|
126
|
+
* **Changed in v0.1.7**: Автоматическое удаление ссылок `refs/dsh-time-machine/...` из Git при вытеснении старых чекпоинтов по лимиту `maxSnapshots`.
|
|
127
|
+
* **Changed in v0.1.7**: Корректный расчет Diff напрямую относительно рабочей директории с незакоммиченными изменениями.
|
|
128
|
+
* **Changed in v0.1.7**: Соответствие стандартам DSH Plugin Authoring: поля формы настроек разблокированы строго в состоянии `ready`.
|
|
129
|
+
* **Added in v0.1.7**: Защита WebServer API от DoS-атак через лимит тела запроса (макс. 1 МБ).
|
|
130
|
+
* **Added in v0.1.7**: Полная китайская локализация интерфейса (`zh`).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
113
134
|
## 📄 Лицензия
|
|
114
135
|
|
|
115
|
-
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
136
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
4
|
|
|
5
|
-
<h3
|
|
5
|
+
<h3>面向 DeepSeek Harness 的影子 Git 自动快照、工作区时间旅行与即时回滚引擎</h3>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://www.npmjs.com/package/@goodandready/dsh-time-machine"><img src="https://img.shields.io/npm/v/@goodandready/dsh-time-machine.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<!--
|
|
14
|
+
<!-- 作者所有开源项目目录按钮 -->
|
|
15
15
|
<p align="center">
|
|
16
16
|
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
|
|
17
17
|
</p>
|
|
@@ -60,6 +60,19 @@ graph LR
|
|
|
60
60
|
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
+
## 🛠️ 智能体工具列表 (6 个工具)
|
|
64
|
+
|
|
65
|
+
| 工具名称 | 参数 | 说明 |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `time_machine_checkpoint_create` | `label?: string, sessionId?: string` | 在高危修改前创建影子 Git 检查点快照 |
|
|
68
|
+
| `time_machine_checkpoint_list` | `sessionId?: string` | 按时间倒序列出最近的工作区检查点 |
|
|
69
|
+
| `time_machine_checkpoint_rollback` | `id: string, confirm: boolean` | 安全还原工作区文件至指定检查点(不移动分支 HEAD) |
|
|
70
|
+
| `time_machine_diff` | `from: string, to?: string` | 对比当前工作区文件与目标检查点的文件差异 |
|
|
71
|
+
| `time_machine_checkpoint_delete` | `id: string, confirm: boolean` | 删除指定检查点并同步清理 Git 引用 |
|
|
72
|
+
| `time_machine_checkpoint_prune` | `sessionId?: string, keep?: number` | 清理会话过期检查点,保留指定数量的最新快照 |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
63
76
|
## 📦 安装指南
|
|
64
77
|
|
|
65
78
|
```bash
|
|
@@ -68,6 +81,31 @@ dsh plugin --profile web add @goodandready/dsh-time-machine
|
|
|
68
81
|
|
|
69
82
|
---
|
|
70
83
|
|
|
84
|
+
## ⚙️ 配置说明 (`settings.yaml`)
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
dsh-time-machine:
|
|
88
|
+
autoSnapshotEnabled: true # 文件修改前自动创建快照
|
|
89
|
+
maxSnapshots: 20 # 内存保留的最大滚动检查点数量
|
|
90
|
+
autoHealPrompt: true # 终端命令执行失败时提示回滚
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 📋 版本更新记录 (Release Notes)
|
|
96
|
+
|
|
97
|
+
### v0.1.7 — 分支历史安全、暂存区隔离与 DSH 原生事件总线
|
|
98
|
+
* **Changed in v0.1.7**: 安全工作区回滚。改用 `read-tree` + `checkout-index` + `clean -fd`,彻底杜绝回滚时误将分支 `HEAD` 覆盖为孤立提交的严重缺陷。
|
|
99
|
+
* **Changed in v0.1.7**: 保护用户 Git 暂存区。快照操作通过独立的 `GIT_INDEX_FILE` 执行,不会覆盖 `.git/index` 中已暂存的文件。
|
|
100
|
+
* **Changed in v0.1.7**: 原生对接 DSH `session/event` 事件总线,全面激活 `turn/start`、`approval/asked`、`turn/end` 和错误自愈事件监听。
|
|
101
|
+
* **Changed in v0.1.7**: 修复快照淘汰时的 Git 引用泄露问题,自动执行 `git update-ref -d`。
|
|
102
|
+
* **Changed in v0.1.7**: 修复 Diff 计算,直接对比当前工作区中的未提交修改。
|
|
103
|
+
* **Changed in v0.1.7**: 严格遵循 DSH 插件规范:仅在 `ready` 状态下允许编辑配置。
|
|
104
|
+
* **Added in v0.1.7**: WebServer API 增加 1MB 请求体大小上限,抵御 DoS 攻击。
|
|
105
|
+
* **Added in v0.1.7**: 前端界面完整支持中文本地化 (`zh`)。
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
71
109
|
## 📄 开源协议
|
|
72
110
|
|
|
73
|
-
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
111
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/lib/client.js
CHANGED
|
@@ -87,6 +87,28 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
unavailable: 'Настройки недоступны',
|
|
88
88
|
tabTitle: 'Машина времени',
|
|
89
89
|
};
|
|
90
|
+
const zh = {
|
|
91
|
+
title: '时光机与检查点',
|
|
92
|
+
sub: '影子快照、差异对比与即时回滚,保障智能体安全。',
|
|
93
|
+
loading: '加载中…',
|
|
94
|
+
create: '创建检查点',
|
|
95
|
+
labelPh: '检查点标签',
|
|
96
|
+
list: '时间线',
|
|
97
|
+
empty: '暂无检查点。',
|
|
98
|
+
diff: '差异',
|
|
99
|
+
rollback: '回滚',
|
|
100
|
+
delete: '删除',
|
|
101
|
+
confirmDelete: '确定删除此检查点?',
|
|
102
|
+
confirmRollback: '确定回滚到此检查点?工作区将被重置。',
|
|
103
|
+
close: '关闭',
|
|
104
|
+
save: '保存',
|
|
105
|
+
saved: '已保存',
|
|
106
|
+
autoSnapshot: '文件变更前自动创建快照',
|
|
107
|
+
maxSnapshots: '最大快照数',
|
|
108
|
+
autoHeal: '命令失败时提示恢复',
|
|
109
|
+
unavailable: '设置不可用',
|
|
110
|
+
tabTitle: '时光机',
|
|
111
|
+
};
|
|
90
112
|
|
|
91
113
|
let ChevronIcon = null;
|
|
92
114
|
try {
|
|
@@ -102,8 +124,8 @@ window.__ModuleLoader__.load({
|
|
|
102
124
|
function useLocale(ctx) {
|
|
103
125
|
if (!ctx || !ctx.locale) return 'en';
|
|
104
126
|
const lang = React.useSyncExternalStore(
|
|
105
|
-
React.useMemo(() => (cb) => ctx.locale.subscribe(cb) ?? (() => {}), [ctx]),
|
|
106
|
-
React.useCallback(() => ctx.locale.getSnapshot()
|
|
127
|
+
React.useMemo(() => (cb) => ctx.locale.subscribe ? (ctx.locale.subscribe(cb) ?? (() => {})) : (() => {}), [ctx]),
|
|
128
|
+
React.useCallback(() => (ctx.locale.getSnapshot && ctx.locale.getSnapshot()?.active) || 'en', [ctx])
|
|
107
129
|
);
|
|
108
130
|
return lang;
|
|
109
131
|
}
|
|
@@ -211,19 +233,13 @@ window.__ModuleLoader__.load({
|
|
|
211
233
|
|
|
212
234
|
function PluginCard(props) {
|
|
213
235
|
const ctx = props.ctx;
|
|
214
|
-
const sid = sessionIdOf(ctx);
|
|
215
236
|
const [expanded, setExpanded] = React.useState(false);
|
|
216
237
|
const lang = useLocale(ctx);
|
|
217
|
-
const t = lang
|
|
218
|
-
const [snapshots, setSnapshots] = React.useState([]);
|
|
219
|
-
const [loading, setLoading] = React.useState(false);
|
|
220
|
-
const [label, setLabel] = React.useState('');
|
|
221
|
-
const [diffText, setDiffText] = React.useState(null);
|
|
222
|
-
const [busy, setBusy] = React.useState(false);
|
|
223
|
-
const [err, setErr] = React.useState('');
|
|
238
|
+
const t = lang.startsWith('zh') ? zh : (lang.startsWith('ru') ? ru : en);
|
|
224
239
|
const [settingsStatus, setSettingsStatus] = React.useState('loading');
|
|
225
240
|
const [draft, setDraft] = React.useState({ autoSnapshotEnabled: true, maxSnapshots: 20, autoHealPrompt: true });
|
|
226
241
|
const [saving, setSaving] = React.useState(false);
|
|
242
|
+
const [saveErr, setSaveErr] = React.useState('');
|
|
227
243
|
|
|
228
244
|
const scope = React.useMemo(() => {
|
|
229
245
|
try { return ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : null; } catch { return null; }
|
|
@@ -256,65 +272,22 @@ window.__ModuleLoader__.load({
|
|
|
256
272
|
return () => { alive = false; try { unsub(); } catch {} };
|
|
257
273
|
}, [scope]);
|
|
258
274
|
|
|
259
|
-
const fetchList = React.useCallback(() => {
|
|
260
|
-
setLoading(true); setErr('');
|
|
261
|
-
const q = sid ? '?sessionId=' + encodeURIComponent(sid) : '';
|
|
262
|
-
fetch('/dsh-time-machine/snapshots' + q).then(r => r.json()).then(j => {
|
|
263
|
-
if (j.success) setSnapshots(j.snapshots || []);
|
|
264
|
-
else setErr(j.error || 'load failed');
|
|
265
|
-
}).catch(e => setErr(String(e.message || e))).finally(() => setLoading(false));
|
|
266
|
-
}, [sid]);
|
|
267
|
-
|
|
268
|
-
React.useEffect(() => { if (expanded) fetchList(); }, [expanded, fetchList]);
|
|
269
|
-
|
|
270
|
-
const onCreate = () => {
|
|
271
|
-
setBusy(true); setErr('');
|
|
272
|
-
fetch('/dsh-time-machine/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label, sessionId: sid }) })
|
|
273
|
-
.then(r => r.json()).then(j => {
|
|
274
|
-
if (!j.success) throw new Error(j.error || 'create failed');
|
|
275
|
-
setLabel(''); fetchList();
|
|
276
|
-
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
277
|
-
};
|
|
278
|
-
const onDiff = (id) => {
|
|
279
|
-
setBusy(true); setErr('');
|
|
280
|
-
fetch('/dsh-time-machine/diff?from=' + encodeURIComponent(id)).then(r => r.json()).then(j => {
|
|
281
|
-
if (!j.success) throw new Error(j.error || 'diff failed');
|
|
282
|
-
setDiffText(j.diff || '');
|
|
283
|
-
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
284
|
-
};
|
|
285
|
-
const onRollback = (id) => {
|
|
286
|
-
if (!window.confirm(t.confirmRollback)) return;
|
|
287
|
-
setBusy(true); setErr('');
|
|
288
|
-
fetch('/dsh-time-machine/rollback', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm: true }) })
|
|
289
|
-
.then(r => r.json()).then(j => {
|
|
290
|
-
if (!j.success) throw new Error(j.error || 'rollback failed');
|
|
291
|
-
fetchList();
|
|
292
|
-
}).catch(e => setErr(String(e.message || e))).finally(() => setBusy(false));
|
|
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
|
-
};
|
|
303
275
|
const onSave = async () => {
|
|
304
276
|
if (!scope) return;
|
|
305
|
-
setSaving(true);
|
|
277
|
+
setSaving(true); setSaveErr('');
|
|
306
278
|
try {
|
|
307
279
|
const keys = ['autoSnapshotEnabled', 'maxSnapshots', 'autoHealPrompt'];
|
|
308
280
|
const failures = [];
|
|
309
281
|
for (const k of keys) {
|
|
310
282
|
try { await scope.set(k, draft[k]); } catch (e) { failures.push(k + ': ' + String(e.message || e)); }
|
|
311
283
|
}
|
|
312
|
-
if (failures.length)
|
|
313
|
-
} catch (e) {
|
|
284
|
+
if (failures.length) setSaveErr(failures.join('; '));
|
|
285
|
+
} catch (e) { setSaveErr(String(e.message || e)); }
|
|
314
286
|
finally { setSaving(false); }
|
|
315
287
|
};
|
|
316
288
|
|
|
317
|
-
|
|
289
|
+
// Strict adherence to DSH Plugin Authoring: writable ONLY when ready
|
|
290
|
+
const writable = settingsStatus === 'ready';
|
|
318
291
|
|
|
319
292
|
return React.createElement('li', { className: 'tm-card' },
|
|
320
293
|
React.createElement('button', { type: 'button', className: 'tm-head', 'aria-expanded': expanded, onClick: () => setExpanded(!expanded) },
|
|
@@ -339,42 +312,15 @@ window.__ModuleLoader__.load({
|
|
|
339
312
|
React.createElement('input', { type: 'checkbox', checked: !!draft.autoHealPrompt, disabled: !writable, onChange: (e) => setDraft(d => ({ ...d, autoHealPrompt: e.target.checked })) }), ' ' + t.autoHeal
|
|
340
313
|
)
|
|
341
314
|
),
|
|
315
|
+
saveErr ? React.createElement('div', { className: 'tm-label', style: { color: 'var(--dsw-alias-state-error-primary)' } }, saveErr) : null,
|
|
342
316
|
React.createElement('div', { className: 'tm-foot' },
|
|
343
317
|
React.createElement('button', { className: 'tm-save', disabled: !writable || saving, onClick: onSave }, saving ? t.loading : t.save)
|
|
344
318
|
)
|
|
345
319
|
),
|
|
346
|
-
React.createElement('div', { className: 'tm-field' },
|
|
347
|
-
React.createElement('div', { style: { display: 'flex', gap: 8 } },
|
|
348
|
-
React.createElement('input', { className: 'tm-input', style: { flex: 1 }, placeholder: t.labelPh, value: label, onChange: (e) => setLabel(e.target.value) }),
|
|
349
|
-
React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: onCreate }, t.create)
|
|
350
|
-
)
|
|
351
|
-
),
|
|
352
320
|
React.createElement('div', { className: 'tm-field' },
|
|
353
321
|
React.createElement('div', { className: 'tm-label' }, t.list),
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
snapshots.length === 0 && !loading ? React.createElement('div', { className: 'tm-label' }, t.empty) : null,
|
|
357
|
-
React.createElement('div', { className: 'tm-list' },
|
|
358
|
-
snapshots.map(s => React.createElement('div', { key: s.id, className: 'tm-row' },
|
|
359
|
-
React.createElement('span', { className: 'tm-row-main' },
|
|
360
|
-
React.createElement('span', { className: 'tm-row-title' }, s.label + ' · ' + s.id),
|
|
361
|
-
React.createElement('span', { className: 'tm-row-meta' }, new Date(s.createdAt).toLocaleString() + (s.commit ? ' · ' + s.commit.slice(0,7) : ''))
|
|
362
|
-
),
|
|
363
|
-
React.createElement('button', { className: 'tm-btn', disabled: busy, onClick: () => onDiff(s.id) }, t.diff),
|
|
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)
|
|
366
|
-
))
|
|
367
|
-
)
|
|
368
|
-
),
|
|
369
|
-
diffText !== null ? React.createElement('div', { className: 'tm-modal', onClick: () => setDiffText(null) },
|
|
370
|
-
React.createElement('div', { className: 'tm-modal-box', onClick: (e) => e.stopPropagation() },
|
|
371
|
-
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
|
|
372
|
-
React.createElement('strong', null, 'diff'),
|
|
373
|
-
React.createElement('button', { className: 'tm-btn', onClick: () => setDiffText(null) }, t.close)
|
|
374
|
-
),
|
|
375
|
-
React.createElement('pre', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, diffText)
|
|
376
|
-
)
|
|
377
|
-
) : null
|
|
322
|
+
React.createElement(Timeline, { t, ctx })
|
|
323
|
+
)
|
|
378
324
|
) : null
|
|
379
325
|
);
|
|
380
326
|
}
|
|
@@ -382,7 +328,7 @@ window.__ModuleLoader__.load({
|
|
|
382
328
|
function TimeMachineTab(props) {
|
|
383
329
|
const ctx = props.ctx;
|
|
384
330
|
const lang = useLocale(ctx);
|
|
385
|
-
const t = lang
|
|
331
|
+
const t = lang.startsWith('zh') ? zh : (lang.startsWith('ru') ? ru : en);
|
|
386
332
|
return React.createElement('div', { className: 'tm-tab' },
|
|
387
333
|
React.createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, t.tabTitle),
|
|
388
334
|
React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 4 } }, t.sub),
|
|
@@ -393,7 +339,7 @@ window.__ModuleLoader__.load({
|
|
|
393
339
|
module.exports.inject = ['slots', 'locale'];
|
|
394
340
|
module.exports.apply = function apply(ctx) {
|
|
395
341
|
ensureStyles();
|
|
396
|
-
try { ctx.locale && ctx.locale.register && ctx.locale.register(NS, { en, ru }); } catch {}
|
|
342
|
+
try { ctx.locale && ctx.locale.register && ctx.locale.register(NS, { en, ru, zh }); } catch {}
|
|
397
343
|
// declaration-safe settings registration (alpha2 SlotCore requires inject)
|
|
398
344
|
let settingsRegistered = false;
|
|
399
345
|
const doRegisterSettings = () => {
|
|
@@ -423,7 +369,6 @@ window.__ModuleLoader__.load({
|
|
|
423
369
|
ctx.slots.inject('settings.plugin.item', () => {
|
|
424
370
|
doRegisterSettings();
|
|
425
371
|
});
|
|
426
|
-
// also handle delayed BetterSidebar via inject
|
|
427
372
|
} catch (e) {
|
|
428
373
|
console.error('[dsh-time-machine] slots.inject failed', e);
|
|
429
374
|
doRegisterSettings();
|
|
@@ -441,8 +386,8 @@ window.__ModuleLoader__.load({
|
|
|
441
386
|
svc.registerTab({
|
|
442
387
|
id: 'time-machine',
|
|
443
388
|
title: () => {
|
|
444
|
-
const a = ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active || 'en';
|
|
445
|
-
return a.startsWith('ru') ? 'Машина времени' : 'Time Machine';
|
|
389
|
+
const a = (ctx.locale && ctx.locale.getSnapshot && ctx.locale.getSnapshot().active) || 'en';
|
|
390
|
+
return a.startsWith('zh') ? '时光机' : (a.startsWith('ru') ? 'Машина времени' : 'Time Machine');
|
|
446
391
|
},
|
|
447
392
|
order: 30,
|
|
448
393
|
single: true,
|
|
@@ -464,7 +409,6 @@ window.__ModuleLoader__.load({
|
|
|
464
409
|
return () => {};
|
|
465
410
|
});
|
|
466
411
|
} catch (e) {
|
|
467
|
-
// betterSidebar is optional, do not hide loader failure
|
|
468
412
|
if (String(e && e.message || '').includes('not declared')) {
|
|
469
413
|
// service not declared in this host build - silently skip tab
|
|
470
414
|
} else {
|
|
@@ -475,4 +419,4 @@ window.__ModuleLoader__.load({
|
|
|
475
419
|
};
|
|
476
420
|
return module.exports;
|
|
477
421
|
},
|
|
478
|
-
});
|
|
422
|
+
});
|
package/lib/index.js
CHANGED
|
@@ -15,6 +15,12 @@ const NS = '@goodandready/dsh-time-machine';
|
|
|
15
15
|
function sessionIdOf(execution, ctx) {
|
|
16
16
|
try {
|
|
17
17
|
if (execution && execution.sessionId) return String(execution.sessionId);
|
|
18
|
+
if (execution && execution.session) {
|
|
19
|
+
return String(execution.session.id || execution.session.header?.id || '');
|
|
20
|
+
}
|
|
21
|
+
if (execution && execution.data && execution.data.sessionId) {
|
|
22
|
+
return String(execution.data.sessionId);
|
|
23
|
+
}
|
|
18
24
|
if (execution && execution.agent && execution.agent.session) {
|
|
19
25
|
return String(execution.agent.session.id || execution.agent.session.header?.id || '');
|
|
20
26
|
}
|
|
@@ -49,7 +55,35 @@ export function apply(ctx, config) {
|
|
|
49
55
|
} catch {}
|
|
50
56
|
};
|
|
51
57
|
|
|
52
|
-
//
|
|
58
|
+
// 1. Native DSH session/event bus (Cordis standard in DSH)
|
|
59
|
+
ctx.effect(() => {
|
|
60
|
+
const off = ctx.on && ctx.on('session/event', (session, event) => {
|
|
61
|
+
const sid = (session && session.id) || sessionIdOf(event, ctx);
|
|
62
|
+
const type = event && event.type;
|
|
63
|
+
if (type === 'turn/start') {
|
|
64
|
+
const turnId = event.turnId || event.data?.turnId || Date.now();
|
|
65
|
+
autoSnap(`auto:turn:${turnId}`, sid);
|
|
66
|
+
} else if (type === 'approval/asked') {
|
|
67
|
+
const tool = event.tool || event.data?.toolName || event.data?.tool || 'approval';
|
|
68
|
+
autoSnap(`auto:approval:${tool}`, sid);
|
|
69
|
+
} else if (type === 'turn/end') {
|
|
70
|
+
if (!getConfig().autoSnapshotEnabled) return;
|
|
71
|
+
const outcome = String((event && (event.outcome || event.data?.outcome || event.result?.outcome || event.result?.status)) || '');
|
|
72
|
+
if (!sid) return;
|
|
73
|
+
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
74
|
+
engine.pruneSnapshots(sid, 3).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
} else if (type === 'tool/error' || type === 'command/error') {
|
|
77
|
+
if (getConfig().autoHealPrompt) {
|
|
78
|
+
const tool = event.tool || event.data?.toolName || 'command';
|
|
79
|
+
autoSnap(`auto:error:${tool}`, sid);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return () => { try { typeof off === 'function' && off(); } catch {} };
|
|
84
|
+
}, 'dsh-time-machine: native session events');
|
|
85
|
+
|
|
86
|
+
// 2. Legacy/mock events bus fallback (for unit tests and non-standard environments)
|
|
53
87
|
try {
|
|
54
88
|
if (ctx.events && typeof ctx.events.on === 'function') {
|
|
55
89
|
ctx.effect(() => {
|
|
@@ -76,7 +110,6 @@ export function apply(ctx, config) {
|
|
|
76
110
|
if (outcome === 'success' || outcome === 'ok' || outcome === 'done') {
|
|
77
111
|
engine.pruneSnapshots(sid, 3).catch(() => {});
|
|
78
112
|
}
|
|
79
|
-
// failure/blocked: keep all checkpoints for review
|
|
80
113
|
}));
|
|
81
114
|
} catch {}
|
|
82
115
|
return () => { for (const off of offs) try { typeof off === 'function' && off(); } catch {} };
|
|
@@ -114,10 +147,6 @@ export function apply(ctx, config) {
|
|
|
114
147
|
},
|
|
115
148
|
execute: async (args = {}, execution) => {
|
|
116
149
|
const sid = args.sessionId != null ? String(args.sessionId).trim() : (sessionIdOf(execution, tctx) ? String(sessionIdOf(execution, tctx)).trim() : undefined);
|
|
117
|
-
const list = sid !== undefined ? engine.listSnapshots(sid) : engine.listSnapshots();
|
|
118
|
-
// if sid undefined and we want all, return all; but if sessionId was auto-detected, filter
|
|
119
|
-
// For backward compat, if no sid provided and engine has sessionIds, return all
|
|
120
|
-
// If sid was auto-detected (execution has session), use it
|
|
121
150
|
const finalList = (args.sessionId == null && sid !== undefined && sid !== '') ? engine.listSnapshots(sid) : (args.sessionId != null ? engine.listSnapshots(String(args.sessionId)) : engine.listSnapshots());
|
|
122
151
|
return { success: true, snapshots: finalList, sessionId: sid || '' };
|
|
123
152
|
},
|
|
@@ -197,6 +226,33 @@ export function apply(ctx, config) {
|
|
|
197
226
|
else ctx.inject(['tools'], (tctx) => registerTools(tctx));
|
|
198
227
|
} catch {}
|
|
199
228
|
|
|
229
|
+
// Safe read body with max 1MB limit to protect against DoS
|
|
230
|
+
const readJsonBody = (req, res, cb) => {
|
|
231
|
+
let body = '';
|
|
232
|
+
let exceeded = false;
|
|
233
|
+
req.on('data', (c) => {
|
|
234
|
+
body += c;
|
|
235
|
+
if (body.length > 1024 * 1024) {
|
|
236
|
+
exceeded = true;
|
|
237
|
+
res.statusCode = 413;
|
|
238
|
+
res.setHeader('content-type', 'application/json');
|
|
239
|
+
res.end(JSON.stringify({ success: false, error: 'payload too large' }));
|
|
240
|
+
req.destroy();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
req.on('end', () => {
|
|
244
|
+
if (exceeded) return;
|
|
245
|
+
try {
|
|
246
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
247
|
+
cb(parsed);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
res.statusCode = 400;
|
|
250
|
+
res.setHeader('content-type', 'application/json');
|
|
251
|
+
res.end(JSON.stringify({ success: false, error: 'invalid json' }));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
200
256
|
// web routes for UI (session-aware via ?sessionId=)
|
|
201
257
|
ctx.effect(() => {
|
|
202
258
|
const disposals = [];
|
|
@@ -233,12 +289,9 @@ export function apply(ctx, config) {
|
|
|
233
289
|
disposals.push(ctx.webServer.register({
|
|
234
290
|
kind: 'exact',
|
|
235
291
|
path: '/dsh-time-machine/create',
|
|
236
|
-
handler:
|
|
237
|
-
|
|
238
|
-
req.on('data', (c) => body += c);
|
|
239
|
-
req.on('end', async () => {
|
|
292
|
+
handler: (req, res) => {
|
|
293
|
+
readJsonBody(req, res, async (parsed) => {
|
|
240
294
|
try {
|
|
241
|
-
const parsed = body ? JSON.parse(body) : {};
|
|
242
295
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '').trim();
|
|
243
296
|
const snap = await engine.createSnapshot(parsed.label, { sessionId: sid });
|
|
244
297
|
res.setHeader('content-type', 'application/json');
|
|
@@ -254,12 +307,9 @@ export function apply(ctx, config) {
|
|
|
254
307
|
disposals.push(ctx.webServer.register({
|
|
255
308
|
kind: 'exact',
|
|
256
309
|
path: '/dsh-time-machine/delete',
|
|
257
|
-
handler:
|
|
258
|
-
|
|
259
|
-
req.on('data', (c) => body += c);
|
|
260
|
-
req.on('end', async () => {
|
|
310
|
+
handler: (req, res) => {
|
|
311
|
+
readJsonBody(req, res, async (parsed) => {
|
|
261
312
|
try {
|
|
262
|
-
const parsed = body ? JSON.parse(body) : {};
|
|
263
313
|
const out = await engine.deleteSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
|
|
264
314
|
res.setHeader('content-type', 'application/json');
|
|
265
315
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
@@ -274,12 +324,9 @@ export function apply(ctx, config) {
|
|
|
274
324
|
disposals.push(ctx.webServer.register({
|
|
275
325
|
kind: 'exact',
|
|
276
326
|
path: '/dsh-time-machine/prune',
|
|
277
|
-
handler:
|
|
278
|
-
|
|
279
|
-
req.on('data', (c) => body += c);
|
|
280
|
-
req.on('end', async () => {
|
|
327
|
+
handler: (req, res) => {
|
|
328
|
+
readJsonBody(req, res, async (parsed) => {
|
|
281
329
|
try {
|
|
282
|
-
const parsed = body ? JSON.parse(body) : {};
|
|
283
330
|
const sid = String(parsed.sessionId || new URL(req.url, 'http://localhost').searchParams.get('sessionId') || '');
|
|
284
331
|
const out = await engine.pruneSnapshots(sid, parsed.keep);
|
|
285
332
|
res.setHeader('content-type', 'application/json');
|
|
@@ -295,12 +342,9 @@ export function apply(ctx, config) {
|
|
|
295
342
|
disposals.push(ctx.webServer.register({
|
|
296
343
|
kind: 'exact',
|
|
297
344
|
path: '/dsh-time-machine/rollback',
|
|
298
|
-
handler:
|
|
299
|
-
|
|
300
|
-
req.on('data', (c) => body += c);
|
|
301
|
-
req.on('end', async () => {
|
|
345
|
+
handler: (req, res) => {
|
|
346
|
+
readJsonBody(req, res, async (parsed) => {
|
|
302
347
|
try {
|
|
303
|
-
const parsed = body ? JSON.parse(body) : {};
|
|
304
348
|
const out = await engine.rollbackSnapshot(String(parsed.id || ''), { confirm: parsed.confirm });
|
|
305
349
|
res.setHeader('content-type', 'application/json');
|
|
306
350
|
res.end(JSON.stringify({ success: true, ...out }));
|
|
@@ -317,4 +361,4 @@ export function apply(ctx, config) {
|
|
|
317
361
|
}, 'dsh-time-machine: web routes');
|
|
318
362
|
|
|
319
363
|
ctx.provide?.('timeMachineEngine', engine);
|
|
320
|
-
}
|
|
364
|
+
}
|
package/lib/snapshot.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
2
4
|
import { execFile as execFileCb } from 'node:child_process';
|
|
3
5
|
import { promisify } from 'node:util';
|
|
4
6
|
|
|
@@ -6,7 +8,7 @@ const execFile = promisify(execFileCb);
|
|
|
6
8
|
|
|
7
9
|
async function defaultExec(cmd, args, opts = {}) {
|
|
8
10
|
try {
|
|
9
|
-
const { stdout } = await execFile(cmd, args, { encoding: 'utf8', ...opts });
|
|
11
|
+
const { stdout } = await execFile(cmd, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, ...opts });
|
|
10
12
|
return { stdout: String(stdout ?? ''), stderr: '' };
|
|
11
13
|
} catch (e) {
|
|
12
14
|
const err = e;
|
|
@@ -28,16 +30,26 @@ export class ShadowSnapshotEngine {
|
|
|
28
30
|
|
|
29
31
|
_now() { return Date.now(); }
|
|
30
32
|
|
|
31
|
-
_trimFor(sessionId) {
|
|
33
|
+
async _trimFor(sessionId) {
|
|
32
34
|
if (!sessionId) {
|
|
33
|
-
while (this.snapshots.length > this.maxSnapshots)
|
|
35
|
+
while (this.snapshots.length > this.maxSnapshots) {
|
|
36
|
+
const oldest = this.snapshots.shift();
|
|
37
|
+
if (oldest?.ref) {
|
|
38
|
+
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
34
41
|
return;
|
|
35
42
|
}
|
|
36
43
|
const perSession = this.snapshots.filter(s => s.sessionId === sessionId);
|
|
37
44
|
while (perSession.length > this.maxSnapshots) {
|
|
38
45
|
const oldest = perSession.shift();
|
|
39
46
|
const idx = this.snapshots.indexOf(oldest);
|
|
40
|
-
if (idx !== -1)
|
|
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
|
+
}
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
|
|
@@ -48,6 +60,17 @@ export class ShadowSnapshotEngine {
|
|
|
48
60
|
} catch { return false; }
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
async _gitDir() {
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await this.exec('git', ['rev-parse', '--git-dir'], this.cwd ? { cwd: this.cwd } : {});
|
|
66
|
+
const gdir = String(stdout ?? '').trim();
|
|
67
|
+
if (!gdir) return null;
|
|
68
|
+
return path.isAbsolute(gdir) ? gdir : path.resolve(this.cwd || process.cwd(), gdir);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
// Restore checkpoint metadata from git shadow refs after a restart.
|
|
52
75
|
// git is the durable store; memory is rebuilt from refs/dsh-time-machine/*.
|
|
53
76
|
async loadFromRefs() {
|
|
@@ -89,8 +112,15 @@ export class ShadowSnapshotEngine {
|
|
|
89
112
|
for (const snap of restored) snap.seq = ++this.seq;
|
|
90
113
|
this.snapshots.push(...restored);
|
|
91
114
|
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
92
|
-
for (const sid of sessions) this._trimFor(sid);
|
|
93
|
-
if (sessions.length === 0)
|
|
115
|
+
for (const sid of sessions) await this._trimFor(sid);
|
|
116
|
+
if (sessions.length === 0) {
|
|
117
|
+
while (this.snapshots.length > this.maxSnapshots) {
|
|
118
|
+
const oldest = this.snapshots.shift();
|
|
119
|
+
if (oldest?.ref) {
|
|
120
|
+
try { await this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}); } catch {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
94
124
|
return restored.length;
|
|
95
125
|
}
|
|
96
126
|
|
|
@@ -98,27 +128,49 @@ export class ShadowSnapshotEngine {
|
|
|
98
128
|
const id = crypto.randomUUID().slice(0, 8);
|
|
99
129
|
const createdAt = this._now();
|
|
100
130
|
const safeLabel = String(label ?? '').trim() || `checkpoint-${id}`;
|
|
101
|
-
const
|
|
131
|
+
const rawSid = String(sessionId || '').trim();
|
|
132
|
+
const safeSid = rawSid ? rawSid.replace(/[^a-zA-Z0-9_-]/g, '_') : '';
|
|
102
133
|
let commit = null;
|
|
103
134
|
let ref = null;
|
|
104
135
|
const inGit = await this._isGitRepo();
|
|
105
136
|
if (inGit) {
|
|
137
|
+
let shadowIndex = null;
|
|
106
138
|
try {
|
|
107
|
-
|
|
108
|
-
const
|
|
139
|
+
const gitDir = await this._gitDir();
|
|
140
|
+
const baseOpts = this.cwd ? { cwd: this.cwd } : {};
|
|
141
|
+
const execEnv = {
|
|
142
|
+
...process.env,
|
|
143
|
+
GIT_AUTHOR_NAME: 'DSH Time Machine',
|
|
144
|
+
GIT_AUTHOR_EMAIL: 'time-machine@dsh.local',
|
|
145
|
+
GIT_COMMITTER_NAME: 'DSH Time Machine',
|
|
146
|
+
GIT_COMMITTER_EMAIL: 'time-machine@dsh.local',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (gitDir) {
|
|
150
|
+
shadowIndex = path.join(gitDir, `tm_index_${id}`);
|
|
151
|
+
execEnv.GIT_INDEX_FILE = shadowIndex;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const shadowOpts = { ...baseOpts, env: execEnv };
|
|
155
|
+
await this.exec('git', ['add', '-A'], shadowOpts);
|
|
156
|
+
const { stdout: tree } = await this.exec('git', ['write-tree'], shadowOpts);
|
|
109
157
|
const treeHash = String(tree).trim();
|
|
110
|
-
const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel],
|
|
158
|
+
const { stdout: commitHash } = await this.exec('git', ['commit-tree', treeHash, '-m', safeLabel], shadowOpts);
|
|
111
159
|
commit = String(commitHash).trim();
|
|
112
|
-
ref =
|
|
113
|
-
await this.exec('git', ['update-ref', ref, commit],
|
|
160
|
+
ref = safeSid ? `refs/dsh-time-machine/${safeSid}/${id}` : `refs/dsh-time-machine/${id}`;
|
|
161
|
+
await this.exec('git', ['update-ref', ref, commit], baseOpts);
|
|
114
162
|
} catch {
|
|
115
163
|
commit = null;
|
|
116
164
|
ref = null;
|
|
165
|
+
} finally {
|
|
166
|
+
if (shadowIndex) {
|
|
167
|
+
try { await fs.unlink(shadowIndex); } catch {}
|
|
168
|
+
}
|
|
117
169
|
}
|
|
118
170
|
}
|
|
119
|
-
const snap = { id, label: safeLabel, sessionId:
|
|
171
|
+
const snap = { id, label: safeLabel, sessionId: rawSid, createdAt, commit, ref, seq: ++this.seq };
|
|
120
172
|
this.snapshots.push(snap);
|
|
121
|
-
this._trimFor(
|
|
173
|
+
await this._trimFor(rawSid);
|
|
122
174
|
return snap;
|
|
123
175
|
}
|
|
124
176
|
|
|
@@ -146,11 +198,16 @@ export class ShadowSnapshotEngine {
|
|
|
146
198
|
}
|
|
147
199
|
const inGit = await this._isGitRepo();
|
|
148
200
|
if (inGit && snap.commit) {
|
|
201
|
+
const opts = this.cwd ? { cwd: this.cwd } : {};
|
|
149
202
|
try {
|
|
150
|
-
|
|
203
|
+
// Safe rollback: restore index and working tree to snapshot without moving branch HEAD
|
|
204
|
+
await this.exec('git', ['read-tree', snap.commit], opts);
|
|
205
|
+
await this.exec('git', ['checkout-index', '-a', '-f'], opts);
|
|
206
|
+
await this.exec('git', ['clean', '-fd'], opts);
|
|
151
207
|
} catch (e) {
|
|
152
|
-
|
|
153
|
-
await this.exec('git', ['checkout
|
|
208
|
+
// Fallback: checkout files directly from commit tree
|
|
209
|
+
await this.exec('git', ['checkout', snap.commit, '--', '.'], opts).catch(() => {});
|
|
210
|
+
await this.exec('git', ['clean', '-fd'], opts).catch(() => {});
|
|
154
211
|
}
|
|
155
212
|
}
|
|
156
213
|
return { rolledBack: true, snapshot: snap };
|
|
@@ -181,7 +238,7 @@ export class ShadowSnapshotEngine {
|
|
|
181
238
|
async pruneSnapshots(sessionId, keep = 3) {
|
|
182
239
|
const sid = sessionId != null ? String(sessionId).trim() : undefined;
|
|
183
240
|
const list = this.listSnapshots(sid); // newest first
|
|
184
|
-
const minKeep = Math.max(0, Number(keep)
|
|
241
|
+
const minKeep = Math.max(0, Number.isFinite(Number(keep)) ? Number(keep) : 3);
|
|
185
242
|
const toRemove = list.slice(minKeep);
|
|
186
243
|
const removed = [];
|
|
187
244
|
for (const snap of toRemove) {
|
|
@@ -200,15 +257,20 @@ export class ShadowSnapshotEngine {
|
|
|
200
257
|
let to = toId ? this.getSnapshot(toId) : null;
|
|
201
258
|
const inGit = await this._isGitRepo();
|
|
202
259
|
if (inGit && from.commit) {
|
|
260
|
+
const opts = this.cwd ? { cwd: this.cwd } : {};
|
|
203
261
|
try {
|
|
204
|
-
const
|
|
205
|
-
|
|
262
|
+
const args = to?.commit
|
|
263
|
+
? ['diff', '--stat', `${from.commit}..${to.commit}`]
|
|
264
|
+
: ['diff', '--stat', from.commit];
|
|
265
|
+
const { stdout } = await this.exec('git', args, opts);
|
|
206
266
|
const diffStat = String(stdout ?? '').trim();
|
|
207
267
|
if (diffStat) return { from: fromId, to: toId || 'HEAD', diff: diffStat };
|
|
208
268
|
} catch {}
|
|
209
269
|
try {
|
|
210
|
-
const
|
|
211
|
-
|
|
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);
|
|
212
274
|
return { from: fromId, to: toId || 'HEAD', diff: String(stdout ?? '').trim() || '(no changes)' };
|
|
213
275
|
} catch {}
|
|
214
276
|
}
|
|
@@ -218,11 +280,15 @@ export class ShadowSnapshotEngine {
|
|
|
218
280
|
|
|
219
281
|
setMax(n) {
|
|
220
282
|
this.maxSnapshots = Math.max(1, Number(n) || 20);
|
|
221
|
-
// trim all sessions
|
|
222
283
|
const sessions = [...new Set(this.snapshots.map(s => s.sessionId))];
|
|
223
|
-
for (const sid of sessions) this._trimFor(sid);
|
|
284
|
+
for (const sid of sessions) this._trimFor(sid).catch(() => {});
|
|
224
285
|
if (sessions.length === 0) {
|
|
225
|
-
while (this.snapshots.length > this.maxSnapshots)
|
|
286
|
+
while (this.snapshots.length > this.maxSnapshots) {
|
|
287
|
+
const oldest = this.snapshots.shift();
|
|
288
|
+
if (oldest?.ref) {
|
|
289
|
+
this.exec('git', ['update-ref', '-d', oldest.ref], this.cwd ? { cwd: this.cwd } : {}).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
226
292
|
}
|
|
227
293
|
}
|
|
228
|
-
}
|
|
294
|
+
}
|
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.8",
|
|
4
4
|
"description": "DSH plugin for smart checkpoints, workspace safety guards, and instant rollback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,9 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"client": {
|
|
44
44
|
"platform": "web",
|
|
45
|
-
"inject": [
|
|
46
|
-
"@deepseek-ai/dsh-client-ui-slots"
|
|
47
|
-
]
|
|
45
|
+
"inject": []
|
|
48
46
|
}
|
|
49
47
|
},
|
|
50
48
|
"peerDependencies": {
|