dsh-retrace 0.3.0 → 0.4.2
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 +40 -27
- package/README.zh.md +37 -28
- package/lib/artifact-store.js +9 -0
- package/lib/client.bundle.js +475 -12
- package/lib/client.js +600 -13
- package/lib/dynamic-client.js +475 -12
- package/lib/dynamic-host.js +107 -80
- package/lib/git-adapter.js +177 -0
- package/lib/host-core.js +107 -80
- package/lib/http.js +104 -3
- package/lib/index.js +15 -2
- package/lib/prewrite-guard.js +71 -0
- package/lib/projection/versions.js +32 -10
- package/lib/rollback.js +283 -0
- package/lib/version-index.js +15 -4
- package/lib/versioning.js +188 -1
- package/package.json +13 -10
package/lib/http.js
CHANGED
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
* — one event + context window (sessionQuery.readEvent, lazy reads).
|
|
13
13
|
* GET /api/plugins/retrace/surface?sessionId=
|
|
14
14
|
* — current model surface (sessionQuery.readSurface).
|
|
15
|
+
* POST /api/plugins/retrace/rollback/preview
|
|
16
|
+
* — dry-run: messages removed + artifact actions (no side effects).
|
|
17
|
+
* POST /api/plugins/retrace/rollback
|
|
18
|
+
* — execute the rollback ({sessionId, versionId, scope}).
|
|
19
|
+
* GET /api/plugins/retrace/git/status?sessionId=
|
|
20
|
+
* — repo detection + HEAD + dirty (timeline git banner).
|
|
21
|
+
* POST /api/plugins/retrace/git/init
|
|
22
|
+
* — one-click git init for a non-repository workspace (user-confirmed).
|
|
23
|
+
* GET /api/plugins/retrace/snapshot?sessionId=&versionId=&path=
|
|
24
|
+
* — read one version's snapshot content (rollback preview / detail).
|
|
15
25
|
*
|
|
16
26
|
* Per PLAN.md §4.6 the client carries its localStorage config on every
|
|
17
27
|
* request as `x-retrace-config: {"versioning":bool,"git":bool,
|
|
@@ -24,7 +34,7 @@ export const ROUTE_PREFIX = '/api/plugins/retrace'
|
|
|
24
34
|
const MAX_BODY_BYTES = 64 * 1024
|
|
25
35
|
|
|
26
36
|
/** Default per-request config (client overrides via the header). */
|
|
27
|
-
export const DEFAULT_CONFIG = { versioning: true, git: true, retentionLimit: 50 }
|
|
37
|
+
export const DEFAULT_CONFIG = { versioning: true, git: true, retentionLimit: 50, prewrite: true }
|
|
28
38
|
|
|
29
39
|
/** Parse the `x-retrace-config` request header (tolerant of garbage). */
|
|
30
40
|
export function parseRetraceConfig(raw) {
|
|
@@ -37,6 +47,7 @@ export function parseRetraceConfig(raw) {
|
|
|
37
47
|
if (Number.isInteger(parsed.retentionLimit) && parsed.retentionLimit > 0) {
|
|
38
48
|
config.retentionLimit = parsed.retentionLimit
|
|
39
49
|
}
|
|
50
|
+
if (typeof parsed.prewrite === 'boolean') config.prewrite = parsed.prewrite
|
|
40
51
|
} catch {
|
|
41
52
|
// malformed header → defaults
|
|
42
53
|
}
|
|
@@ -64,7 +75,7 @@ function sendError(res, error) {
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
/** Route one request. `seam` is the versioning seam (lib/versioning.js). */
|
|
67
|
-
export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = () => {} }) {
|
|
78
|
+
export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback, log = () => {} }) {
|
|
68
79
|
const api = createEditorApi(ctx, sessions, agents, log)
|
|
69
80
|
|
|
70
81
|
function handleVersions(req, res, sessionId, config) {
|
|
@@ -98,6 +109,79 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = ()
|
|
|
98
109
|
}
|
|
99
110
|
}
|
|
100
111
|
|
|
112
|
+
async function handleGitStatus(req, res, sessionId) {
|
|
113
|
+
try {
|
|
114
|
+
const value = await seam.gitStatus(sessionId)
|
|
115
|
+
sendJson(res, 200, { ok: true, value })
|
|
116
|
+
} catch (error) {
|
|
117
|
+
sendError(res, error)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleSnapshot(req, res, searchParams) {
|
|
122
|
+
const sessionId = searchParams.get('sessionId') ?? ''
|
|
123
|
+
const versionId = searchParams.get('versionId') ?? ''
|
|
124
|
+
const path = searchParams.get('path') ?? ''
|
|
125
|
+
try {
|
|
126
|
+
const sha = await seam.resolveSnapshot(versionId, path)
|
|
127
|
+
if (!sha) {
|
|
128
|
+
sendJson(res, 200, { ok: true, value: { found: false } })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const bytes = await seam.readSnapshot(sha)
|
|
132
|
+
sendJson(res, 200, {
|
|
133
|
+
ok: true,
|
|
134
|
+
value: {
|
|
135
|
+
found: true,
|
|
136
|
+
sha256: sha,
|
|
137
|
+
sizeBytes: bytes.byteLength,
|
|
138
|
+
text: new TextDecoder().decode(bytes),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
} catch (error) {
|
|
142
|
+
sendError(res, error)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** POST body parse + dispatch shared by the rollback/git ops. */
|
|
147
|
+
function handleJsonPost(req, res, fn) {
|
|
148
|
+
let body = ''
|
|
149
|
+
req.setEncoding('utf8')
|
|
150
|
+
req.on('data', (chunk) => {
|
|
151
|
+
body += chunk
|
|
152
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
153
|
+
sendJson(res, 413, {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: { code: 'payload-too-large', message: 'payload exceeds 64 KiB' },
|
|
156
|
+
})
|
|
157
|
+
req.destroy()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
req.on('error', () => { /* socket errors are terminal; nothing to send */ })
|
|
161
|
+
req.on('end', async () => {
|
|
162
|
+
let args = {}
|
|
163
|
+
if (body.length > 0) {
|
|
164
|
+
try {
|
|
165
|
+
args = JSON.parse(body)
|
|
166
|
+
} catch {
|
|
167
|
+
sendJson(res, 400, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: { code: 'bad-json', message: 'request body is not valid JSON' },
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
175
|
+
if (sessionId) seam.setConfig(sessionId, parseRetraceConfig(req.headers['x-retrace-config']))
|
|
176
|
+
try {
|
|
177
|
+
const value = await fn(args)
|
|
178
|
+
sendJson(res, 200, { ok: true, value })
|
|
179
|
+
} catch (error) {
|
|
180
|
+
sendError(res, error)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
101
185
|
function handlePost(req, res, op) {
|
|
102
186
|
let body = ''
|
|
103
187
|
req.setEncoding('utf8')
|
|
@@ -146,7 +230,8 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = ()
|
|
|
146
230
|
|
|
147
231
|
return (req, res) => {
|
|
148
232
|
const url = new URL(req.url ?? '/', 'http://retrace.local')
|
|
149
|
-
const
|
|
233
|
+
const segments = url.pathname.split('/').filter(Boolean)
|
|
234
|
+
const op = segments.at(-1) ?? ''
|
|
150
235
|
const searchParams = url.searchParams
|
|
151
236
|
const sessionId = searchParams.get('sessionId') ?? ''
|
|
152
237
|
const config = parseRetraceConfig(req.headers['x-retrace-config'])
|
|
@@ -168,6 +253,10 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = ()
|
|
|
168
253
|
return void handleEvent(req, res, searchParams)
|
|
169
254
|
case 'surface':
|
|
170
255
|
return void handleSurface(req, res, sessionId)
|
|
256
|
+
case 'status':
|
|
257
|
+
return void handleGitStatus(req, res, sessionId)
|
|
258
|
+
case 'snapshot':
|
|
259
|
+
return void handleSnapshot(req, res, searchParams)
|
|
171
260
|
default:
|
|
172
261
|
return sendJson(res, 404, {
|
|
173
262
|
ok: false,
|
|
@@ -181,6 +270,18 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, log = ()
|
|
|
181
270
|
error: { code: 'method-not-allowed', message: 'POST or GET only' },
|
|
182
271
|
})
|
|
183
272
|
}
|
|
273
|
+
// P1 rollback/git POST ops (last segment discriminates).
|
|
274
|
+
if (segments.includes('rollback') && op === 'preview') {
|
|
275
|
+
if (!rollback) return sendJson(res, 503, { ok: false, error: { code: 'rollback-unavailable', message: 'rollback surface unavailable' } })
|
|
276
|
+
return handleJsonPost(req, res, (args) => rollback.preview(args))
|
|
277
|
+
}
|
|
278
|
+
if (segments.includes('rollback')) {
|
|
279
|
+
if (!rollback) return sendJson(res, 503, { ok: false, error: { code: 'rollback-unavailable', message: 'rollback surface unavailable' } })
|
|
280
|
+
return handleJsonPost(req, res, (args) => rollback.execute(args))
|
|
281
|
+
}
|
|
282
|
+
if (segments.includes('git') && op === 'init') {
|
|
283
|
+
return handleJsonPost(req, res, (args) => seam.gitInit(String(args?.sessionId ?? '')))
|
|
284
|
+
}
|
|
184
285
|
return handlePost(req, res, op)
|
|
185
286
|
}
|
|
186
287
|
}
|
package/lib/index.js
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
import { createEditorApi } from './host-core.js'
|
|
23
23
|
import { createRetraceHttpHandler, ROUTE_PREFIX } from './http.js'
|
|
24
24
|
import { createVersioningSeam } from './versioning.js'
|
|
25
|
+
import { createRollbackExecutor } from './rollback.js'
|
|
26
|
+
import { createMarkerGuard } from './prewrite-guard.js'
|
|
25
27
|
|
|
26
28
|
export const name = 'dsh-retrace'
|
|
27
|
-
export const inject = ['sessions', 'agents', 'webServer']
|
|
29
|
+
export const inject = ['sessions', 'agents', 'webServer', 'fs', 'subprocess', 'sandboxPolicy']
|
|
28
30
|
|
|
29
31
|
export function apply(ctx) {
|
|
30
32
|
const log = (line) => ctx.logger?.info(line)
|
|
@@ -33,11 +35,22 @@ export function apply(ctx) {
|
|
|
33
35
|
const seam = createVersioningSeam(ctx, log)
|
|
34
36
|
seam.register()
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
// 写前校验守卫(8-25 事故闭环):marker 落盘前过三层契约;依赖缺失自动降级。
|
|
39
|
+
const guard = createMarkerGuard({
|
|
40
|
+
log,
|
|
41
|
+
enabled: (sessionId) => seam.configFor(sessionId).prewrite !== false,
|
|
42
|
+
})
|
|
43
|
+
const hooks = { validateMarker: guard.validateMarkerAppend }
|
|
44
|
+
|
|
45
|
+
// P1 rollback executor: context/artifact restore over the seam (git + snapshots).
|
|
46
|
+
const rollback = createRollbackExecutor({ ctx, sessions: ctx.sessions, seam, validateMarker: hooks.validateMarker, log })
|
|
47
|
+
|
|
48
|
+
const api = createEditorApi(ctx, ctx.sessions, ctx.agents, log, hooks)
|
|
37
49
|
const handler = createRetraceHttpHandler(ctx, {
|
|
38
50
|
sessions: ctx.sessions,
|
|
39
51
|
agents: ctx.agents,
|
|
40
52
|
seam,
|
|
53
|
+
rollback,
|
|
41
54
|
log,
|
|
42
55
|
})
|
|
43
56
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — lib/prewrite-guard.js
|
|
3
|
+
*
|
|
4
|
+
* ★ 写前校验(pre-write validation)——8-25 会话修复事故的闭环。
|
|
5
|
+
*
|
|
6
|
+
* 事故(2026-08-25)第 1 轮失败就是"违约写入没被拦":surface-replace 的
|
|
7
|
+
* `sourceEventSeqs` 被清空后写盘 → 会话加载抛 `SessionPersistenceCorruptionError`;
|
|
8
|
+
* 第 2 轮把 marker 改成 append → 客户端引擎崩溃(rt.js:6816)。如果写入前先校验,
|
|
9
|
+
* 会话根本不会被改坏。
|
|
10
|
+
*
|
|
11
|
+
* 本模块把 marker 写入(撤回/编辑/重发/重新生成/恢复 追加的替换型空 assistant
|
|
12
|
+
* 消息)接到 `dsh-log-contract` 的写前校验器上:`createPreWriter(...).validateAppend(...)`
|
|
13
|
+
* 与离线体检共用同一套判定(S5 覆盖 / M1 引擎 / P1/P2 marker 语义 / S8 foldSurface
|
|
14
|
+
* 终验),保证"体检看到的问题 = 写入前拦下的问题"。
|
|
15
|
+
*
|
|
16
|
+
* 设计约束:
|
|
17
|
+
* - host-core 保持零 import(动态插件 realm 可运行),校验器以
|
|
18
|
+
* `hooks.validateMarker` 注入 `createEditorApi`(lib/host-core.js);
|
|
19
|
+
* - 依赖 `dsh-log-contract` 在运行时**懒加载**(`await import`)——包缺失/加载失败
|
|
20
|
+
* 时守卫静默降级(仅日志),插件照常工作,绝不因守护件损坏主功能;
|
|
21
|
+
* - `prewriterFactory` 可注入(测试用 fake),默认指向 `createPreWriter`;
|
|
22
|
+
* - `enabled(sessionId)` 门控(默认全开):写前校验可整体关闭(大会话的完整
|
|
23
|
+
* 重放校验有秒级成本,见 DEVLOG)。
|
|
24
|
+
*
|
|
25
|
+
* 失败语义:任何 error 级违规 → 抛 `marker-rejected`(op 包装层转为
|
|
26
|
+
* `{ ok: false, error: { code: 'marker-rejected', ... } }`),**不落盘**。
|
|
27
|
+
*/
|
|
28
|
+
import { editorError } from './host-core.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 建立 marker 写前校验器。
|
|
32
|
+
* @param {object} [options]
|
|
33
|
+
* @param {(line: string) => void} [options.log] 拒绝/降级时的诊断日志
|
|
34
|
+
* @param {(input: {events: Array}) => { validateAppend(candidate: object): {ok: boolean, violations?: Array} }} [options.prewriterFactory]
|
|
35
|
+
* 默认 `dsh-log-contract` 的 `createPreWriter`;测试注入 fake。
|
|
36
|
+
* @param {(sessionId: string) => boolean} [options.enabled] 门控(默认恒 true)。
|
|
37
|
+
* @returns {{ validateMarkerAppend(session, envelope): Promise<void> }}
|
|
38
|
+
*/
|
|
39
|
+
export function createMarkerGuard({ log = () => {}, prewriterFactory, enabled = () => true } = {}) {
|
|
40
|
+
let factory = prewriterFactory ?? null
|
|
41
|
+
return {
|
|
42
|
+
/** 校验"即将追加的完整事件信封";通过返回,违规则抛 `marker-rejected`。 */
|
|
43
|
+
async validateMarkerAppend(session, envelope) {
|
|
44
|
+
if (typeof enabled === 'function' && enabled(session?.id) === false) return
|
|
45
|
+
if (factory === null) {
|
|
46
|
+
try {
|
|
47
|
+
factory = (await import('dsh-log-contract')).createPreWriter
|
|
48
|
+
} catch (error) {
|
|
49
|
+
log(`retrace: prewrite guard unavailable (dsh-log-contract not loadable): ${String(error)}`)
|
|
50
|
+
factory = false // remember the failure; don't retry per write
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (factory === false) return
|
|
55
|
+
let verdict
|
|
56
|
+
try {
|
|
57
|
+
const prewriter = factory({ events: session.events })
|
|
58
|
+
verdict = prewriter.validateAppend(envelope)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
61
|
+
throw editorError('marker-rejected', `Marker pre-write validation failed: ${message}`)
|
|
62
|
+
}
|
|
63
|
+
if (verdict?.ok) return
|
|
64
|
+
const detail = Array.isArray(verdict?.violations)
|
|
65
|
+
? verdict.violations.map((v) => `[${v.id}/${v.severity}] ${v.message}`).join(' | ')
|
|
66
|
+
: 'unknown violation'
|
|
67
|
+
log(`retrace: marker write rejected by contract guard: ${detail}`)
|
|
68
|
+
throw editorError('marker-rejected', `Marker write rejected by contract guard: ${detail}`)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
* cache, checkpoint persistence (`dsh-session-projection-cache`) and cold
|
|
8
8
|
* reads; this unit contributes only the pure state machine:
|
|
9
9
|
*
|
|
10
|
-
* - `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - `
|
|
14
|
-
*
|
|
10
|
+
* - `stateSchema` — zod schema of the RAW fold state (plain JSON; the
|
|
11
|
+
* durable checkpoint validates rows against it),
|
|
12
|
+
* - `init` / `apply` — the deterministic fold (lib/version-index.js),
|
|
13
|
+
* - `wire` — the client-visible view ({viewSchema, view}); a unit WITHOUT
|
|
14
|
+
* `wire` registers as checkpoint-only and is never served in
|
|
15
|
+
* `session/projection` push frames or `snapshot()` — the framework
|
|
16
|
+
* contract that P0's original definition missed (found by the P1
|
|
17
|
+
* real-harness smoke: every session's projection block lacked the key).
|
|
15
18
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* can persist it (`z.json()` at the durable boundary).
|
|
19
|
+
* `view` / `schema` top-level aliases stay for the unit tests, which drive
|
|
20
|
+
* the definition directly instead of through the registry.
|
|
19
21
|
*/
|
|
20
22
|
import { z } from 'zod'
|
|
21
23
|
import {
|
|
@@ -54,16 +56,36 @@ export const versionsViewSchema = z.object({
|
|
|
54
56
|
versions: z.array(versionSummarySchema).max(VERSION_LIMIT),
|
|
55
57
|
})
|
|
56
58
|
|
|
59
|
+
/** Raw fold-state schema (durable checkpoint rows validate against it). */
|
|
60
|
+
const windowEntrySchema = z.object({
|
|
61
|
+
intent: z.enum(['write', 'delete', 'unknown']),
|
|
62
|
+
lastSeq: z.number().int().nonnegative(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const versionIndexStateSchema = z.object({
|
|
66
|
+
versions: z.array(z.unknown()).max(VERSION_LIMIT),
|
|
67
|
+
windowFiles: z.record(z.string(), windowEntrySchema),
|
|
68
|
+
knownFiles: z.array(z.string()),
|
|
69
|
+
surface: z.array(z.number().int().nonnegative()),
|
|
70
|
+
})
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* The `retrace/versions` unit — register via
|
|
59
74
|
* `ctx.sessionProjections.register(versionsProjectionDefinition)`.
|
|
60
75
|
*/
|
|
61
76
|
export const versionsProjectionDefinition = {
|
|
62
77
|
key: 'retrace/versions',
|
|
63
|
-
|
|
78
|
+
stateSchema: versionIndexStateSchema,
|
|
64
79
|
init: () => createVersionIndexState(),
|
|
65
80
|
apply: (state, event) => applyVersionIndex(state, event),
|
|
66
|
-
|
|
81
|
+
wire: {
|
|
82
|
+
viewSchema: versionsViewSchema,
|
|
83
|
+
view: (state) => viewVersionIndex(state),
|
|
84
|
+
},
|
|
67
85
|
/** Bump when the serialized fold state or fold semantics change. */
|
|
68
86
|
stateVersion: 1,
|
|
69
87
|
}
|
|
88
|
+
|
|
89
|
+
// Top-level aliases (unit tests drive the definition directly).
|
|
90
|
+
versionsProjectionDefinition.schema = versionsProjectionDefinition.wire.viewSchema
|
|
91
|
+
versionsProjectionDefinition.view = versionsProjectionDefinition.wire.view
|
package/lib/rollback.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — Rollback executor (PLAN.md §4.5).
|
|
3
|
+
*
|
|
4
|
+
* Rolls a session (and optionally its artifacts) back to a recorded version:
|
|
5
|
+
*
|
|
6
|
+
* scope 'context' — append an invisible replacement marker shadowing every
|
|
7
|
+
* surface node added AFTER the version boundary, so the
|
|
8
|
+
* model-visible history rewinds to that point (git-checkout
|
|
9
|
+
* semantics: the rewound content stays in the durable log
|
|
10
|
+
* as a later version — non-destructive, auditable).
|
|
11
|
+
* scope 'artifacts' — restore the files the version's window touched to their
|
|
12
|
+
* content at that version: git checkout when the workspace
|
|
13
|
+
* is a repository and the version recorded a HEAD, else
|
|
14
|
+
* content-addressed snapshot read-back through `ctx.fs`
|
|
15
|
+
* (CAS-guarded), with subprocess `rm` for files that were
|
|
16
|
+
* deleted in the version's window (realpath-in-workspace +
|
|
17
|
+
* in-manifest guards only).
|
|
18
|
+
* scope 'both' — context first, then artifacts.
|
|
19
|
+
*
|
|
20
|
+
* Every execution is dry-run-previewable first and records the restore as a new
|
|
21
|
+
* version (kind='restore') in the projection feed, so a rollback is itself a
|
|
22
|
+
* version and can be rolled back again (idempotent per-session lock included).
|
|
23
|
+
*/
|
|
24
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
25
|
+
import { foldSurface } from '@deepseek-ai/dsh-session'
|
|
26
|
+
import { appendEditorMarker, editorError } from './host-core.js'
|
|
27
|
+
|
|
28
|
+
const VALID_SCOPES = ['context', 'artifacts', 'both']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the rollback executor.
|
|
32
|
+
* @param {object} deps
|
|
33
|
+
* @param {object} deps.ctx — host context (ctx.fs / ctx.subprocess / ctx.sandboxPolicy).
|
|
34
|
+
* @param {object} deps.sessions — session registry (sessions.get / sessions.flush).
|
|
35
|
+
* @param {object} deps.seam — versioning seam (snapshot / resolveSnapshot /
|
|
36
|
+
* readSnapshot / gitStatus / gitHeadFor).
|
|
37
|
+
* @param {(line: string) => void} [deps.log]
|
|
38
|
+
*/
|
|
39
|
+
export function createRollbackExecutor({ ctx, sessions, seam, validateMarker, log = () => {} }) {
|
|
40
|
+
/** One in-flight rollback per session; later ops wait for the earlier one. */
|
|
41
|
+
const locks = new Map()
|
|
42
|
+
|
|
43
|
+
function locked(sessionId, fn) {
|
|
44
|
+
const previous = locks.get(sessionId) ?? Promise.resolve()
|
|
45
|
+
const next = previous.catch(() => {}).then(fn)
|
|
46
|
+
locks.set(sessionId, next)
|
|
47
|
+
void next.finally(() => {
|
|
48
|
+
if (locks.get(sessionId) === next) locks.delete(sessionId)
|
|
49
|
+
}).catch(() => {})
|
|
50
|
+
return next
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requireSession(sessionId) {
|
|
54
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
55
|
+
throw editorError('bad-request', 'sessionId must be a non-empty string')
|
|
56
|
+
}
|
|
57
|
+
const session = sessions.get(sessionId)
|
|
58
|
+
if (!session) throw editorError('session-not-found', `session "${sessionId}" not found`)
|
|
59
|
+
return session
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requireIdle(sessionId) {
|
|
63
|
+
const agent = seam.agentOf?.(sessionId)
|
|
64
|
+
if (agent && typeof agent.status === 'string' && agent.status === 'running') {
|
|
65
|
+
throw editorError('agent-busy', 'The agent is still responding; stop the current reply before rolling back.')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function versionOf(sessionId, versionId) {
|
|
70
|
+
const snap = seam.snapshot(sessionId)
|
|
71
|
+
const record = (snap.versions ?? []).find((v) => v.versionId === versionId)
|
|
72
|
+
if (!record) throw editorError('version-not-found', `version "${versionId}" not found in session "${sessionId}"`)
|
|
73
|
+
return record
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cwdOf(session) {
|
|
77
|
+
return typeof session.header?.cwd === 'string' ? session.header.cwd : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The surface diff of "now" vs the version boundary: every current surface
|
|
82
|
+
* node absent from the version's folded surface. An empty diff means the
|
|
83
|
+
* session is already at (or before) that version.
|
|
84
|
+
*/
|
|
85
|
+
function contextDiff(session, record) {
|
|
86
|
+
const events = session.events
|
|
87
|
+
const target = foldSurface(events.slice(0, record.boundarySeq + 1))
|
|
88
|
+
const targetNodes = new Set(target.nodes)
|
|
89
|
+
const current = session.surface.nodes
|
|
90
|
+
const diff = current.filter((seq) => !targetNodes.has(seq))
|
|
91
|
+
return {
|
|
92
|
+
messages: diff.length,
|
|
93
|
+
diff,
|
|
94
|
+
firstSeq: diff.length > 0 ? diff[0] : null,
|
|
95
|
+
lastSeq: diff.length > 0 ? diff[diff.length - 1] : null,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Realpath must stay inside the workspace and match a manifest path. */
|
|
100
|
+
async function workspaceRealpath(session, relPath) {
|
|
101
|
+
const cwd = cwdOf(session)
|
|
102
|
+
if (!cwd) return null
|
|
103
|
+
const target = await ctx.fs.resolve(relPath, { cwd })
|
|
104
|
+
const root = await ctx.fs.resolve('.', { cwd })
|
|
105
|
+
if (!ctx.fs.contains(root, target)) return null
|
|
106
|
+
return target
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Artifact plan for one version: per file the action and the method. */
|
|
110
|
+
async function artifactPlan(session, record, git) {
|
|
111
|
+
const rows = []
|
|
112
|
+
const cwd = cwdOf(session)
|
|
113
|
+
const headHash = git?.headHash
|
|
114
|
+
for (const file of record.touchedFiles) {
|
|
115
|
+
if (file.mode === 'deleted') {
|
|
116
|
+
const target = cwd ? await workspaceRealpath(session, file.path) : null
|
|
117
|
+
rows.push({
|
|
118
|
+
path: file.path,
|
|
119
|
+
action: 'delete',
|
|
120
|
+
method: 'subprocess',
|
|
121
|
+
safe: target !== null,
|
|
122
|
+
})
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (git && headHash) {
|
|
126
|
+
rows.push({ path: file.path, action: 'restore', method: 'git', safe: true })
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const sha = await seam.resolveSnapshot(record.versionId, file.path)
|
|
130
|
+
if (sha) {
|
|
131
|
+
rows.push({ path: file.path, action: 'restore', method: 'snapshot', safe: true })
|
|
132
|
+
} else {
|
|
133
|
+
rows.push({ path: file.path, action: 'skip', reason: 'no-snapshot' })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { rows, git: git ? { enabled: true, headHash: git.headHash } : { enabled: false } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Dry-run preview: what a rollback would remove / touch (no side effects). */
|
|
140
|
+
async function preview(args) {
|
|
141
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
142
|
+
const versionId = String(args?.versionId ?? '')
|
|
143
|
+
const scope = String(args?.scope ?? 'both')
|
|
144
|
+
if (!VALID_SCOPES.includes(scope)) throw editorError('bad-scope', `scope must be one of ${VALID_SCOPES.join(', ')}`)
|
|
145
|
+
const session = requireSession(sessionId)
|
|
146
|
+
const record = versionOf(sessionId, versionId)
|
|
147
|
+
const context = contextDiff(session, record)
|
|
148
|
+
const git = seam.configFor(sessionId).git ? await seam.gitStatus(sessionId) : null
|
|
149
|
+
const artifacts = await artifactPlan(session, record, git)
|
|
150
|
+
return {
|
|
151
|
+
versionId,
|
|
152
|
+
kind: record.kind,
|
|
153
|
+
boundarySeq: record.boundarySeq,
|
|
154
|
+
scope,
|
|
155
|
+
context,
|
|
156
|
+
artifacts,
|
|
157
|
+
applicable: scope === 'context' ? context.messages > 0 : scope === 'artifacts' ? artifacts.rows.length > 0 : context.messages > 0 || artifacts.rows.length > 0,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Delete one manifest file via `rm`, after realpath-in-workspace verification. */
|
|
162
|
+
async function removeOne(session, file) {
|
|
163
|
+
const target = await workspaceRealpath(session, file.path)
|
|
164
|
+
if (!target) return { path: file.path, status: 'skipped', reason: 'outside-workspace' }
|
|
165
|
+
const cwd = cwdOf(session)
|
|
166
|
+
if (!cwd) return { path: file.path, status: 'skipped', reason: 'no-cwd' }
|
|
167
|
+
const handle = ctx.subprocess.spawn({
|
|
168
|
+
argv: ['rm', '--', relative(resolve(cwd), resolve(target))],
|
|
169
|
+
cwd,
|
|
170
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
|
|
171
|
+
graceMs: 15_000,
|
|
172
|
+
signal: undefined,
|
|
173
|
+
env: {},
|
|
174
|
+
})
|
|
175
|
+
const outcome = await handle.done
|
|
176
|
+
return { path: file.path, status: outcome.exitCode === 0 ? 'deleted' : 'failed' }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Restore one snapshot file through the sandboxed fs (CAS-guarded write). */
|
|
180
|
+
async function restoreSnapshot(session, file, sha) {
|
|
181
|
+
const cwd = cwdOf(session)
|
|
182
|
+
if (!cwd) return { path: file.path, status: 'skipped', reason: 'no-cwd' }
|
|
183
|
+
const bytes = await seam.readSnapshot(sha)
|
|
184
|
+
const text = new TextDecoder().decode(bytes)
|
|
185
|
+
const target = await ctx.fs.resolve(file.path, { cwd })
|
|
186
|
+
let expected
|
|
187
|
+
try {
|
|
188
|
+
const stat = await ctx.fs.stat(target)
|
|
189
|
+
if (stat && typeof stat.version === 'number') expected = { kind: 'replaceIfVersion', version: stat.version }
|
|
190
|
+
} catch { /* missing file: plain create */ }
|
|
191
|
+
const policy = ctx.sandboxPolicy?.resolve?.({ session, mode: 'workspace-write' })
|
|
192
|
+
await ctx.fs.writeText(target, text, expected, undefined, policy)
|
|
193
|
+
return { path: file.path, status: 'restored' }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Execute artifact rollback for one version (git first, snapshot fallback). */
|
|
197
|
+
async function rollbackArtifacts(session, record, git) {
|
|
198
|
+
const results = []
|
|
199
|
+
const cwd = cwdOf(session)
|
|
200
|
+
const gitPaths = []
|
|
201
|
+
const snapshotJobs = []
|
|
202
|
+
const deletes = []
|
|
203
|
+
const headHash = git?.headHash
|
|
204
|
+
for (const file of record.touchedFiles) {
|
|
205
|
+
if (file.mode === 'deleted') {
|
|
206
|
+
deletes.push(file)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (git && headHash) {
|
|
210
|
+
gitPaths.push(file.path)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const sha = await seam.resolveSnapshot(record.versionId, file.path)
|
|
214
|
+
if (sha) snapshotJobs.push({ file, sha })
|
|
215
|
+
else results.push({ path: file.path, status: 'skipped', reason: 'no-snapshot' })
|
|
216
|
+
}
|
|
217
|
+
if (gitPaths.length > 0 && cwd && headHash) {
|
|
218
|
+
const outcome = await seam.gitCheckout(cwd, headHash, gitPaths)
|
|
219
|
+
for (const path of gitPaths) {
|
|
220
|
+
results.push({
|
|
221
|
+
path,
|
|
222
|
+
status: outcome.ok && outcome.checked.includes(path) ? 'restored' : outcome.ok ? 'unchanged' : 'failed',
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const { file, sha } of snapshotJobs) {
|
|
227
|
+
try {
|
|
228
|
+
results.push(await restoreSnapshot(session, file, sha))
|
|
229
|
+
} catch (error) {
|
|
230
|
+
results.push({ path: file.path, status: 'failed', reason: String(error) })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const file of deletes) {
|
|
234
|
+
try {
|
|
235
|
+
results.push(await removeOne(session, file))
|
|
236
|
+
} catch (error) {
|
|
237
|
+
results.push({ path: file.path, status: 'failed', reason: String(error) })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return results
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Execute a rollback (callers must preview first; the host enforces confirm). */
|
|
244
|
+
async function execute(args) {
|
|
245
|
+
const sessionId = String(args?.sessionId ?? '')
|
|
246
|
+
const versionId = String(args?.versionId ?? '')
|
|
247
|
+
const scope = String(args?.scope ?? 'both')
|
|
248
|
+
if (!VALID_SCOPES.includes(scope)) throw editorError('bad-scope', `scope must be one of ${VALID_SCOPES.join(', ')}`)
|
|
249
|
+
return locked(sessionId, async () => {
|
|
250
|
+
const session = requireSession(sessionId)
|
|
251
|
+
requireIdle(sessionId)
|
|
252
|
+
const record = versionOf(sessionId, versionId)
|
|
253
|
+
const git = seam.configFor(sessionId).git ? await seam.gitStatus(sessionId) : null
|
|
254
|
+
const outcome = { op: 'restore', versionId, scope, markerSeq: null, artifacts: [], context: { messages: 0 } }
|
|
255
|
+
|
|
256
|
+
if (scope === 'context' || scope === 'both') {
|
|
257
|
+
const { diff } = contextDiff(session, record)
|
|
258
|
+
if (diff.length > 0) {
|
|
259
|
+
const span = { start: diff[0], end: diff[diff.length - 1], shadowedSeqs: diff.slice() }
|
|
260
|
+
const markerEvent = await appendEditorMarker(session, span, 'restore', record.boundarySeq, '', validateMarker)
|
|
261
|
+
await flushSafely(session)
|
|
262
|
+
outcome.markerSeq = markerEvent.seq
|
|
263
|
+
outcome.context = { messages: diff.length }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (scope === 'artifacts' || scope === 'both') {
|
|
268
|
+
outcome.artifacts = await rollbackArtifacts(session, record, git)
|
|
269
|
+
}
|
|
270
|
+
return outcome
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function flushSafely(session) {
|
|
275
|
+
try {
|
|
276
|
+
if (typeof sessions.flush === 'function') await sessions.flush(session)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log(`retrace: flush failed: ${String(error)}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { preview, execute }
|
|
283
|
+
}
|
package/lib/version-index.js
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
|
|
26
26
|
export const MARKER_ID_PREFIX = 'retrace'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Every marker-id prefix this build can classify. `MARKER_ID_PREFIX` is the
|
|
30
|
+
* prefix NEW markers are written with; the rest are LEGACY prefixes from
|
|
31
|
+
* earlier plugin names (dsh-message-editor). RENAME RULE: when the plugin
|
|
32
|
+
* changes identity again, keep the retired prefix here so markers written
|
|
33
|
+
* under the previous name keep classifying correctly.
|
|
34
|
+
*/
|
|
35
|
+
export const MARKER_ID_PREFIXES = [MARKER_ID_PREFIX, 'message-editor']
|
|
36
|
+
|
|
28
37
|
/** Timeline keeps the most recent N versions; full history stays replayable from the log. */
|
|
29
38
|
export const VERSION_LIMIT = 200
|
|
30
39
|
|
|
@@ -56,10 +65,12 @@ export function isCompactCheckpointSource(source) {
|
|
|
56
65
|
/** Map a marker event id prefix to a user-facing version kind. */
|
|
57
66
|
export function kindFromMarkerId(id) {
|
|
58
67
|
if (typeof id !== 'string') return 'edit'
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
for (const p of MARKER_ID_PREFIXES) {
|
|
69
|
+
const prefix = `${p}-`
|
|
70
|
+
if (!id.startsWith(prefix)) continue
|
|
71
|
+
const op = id.slice(prefix.length).split('-')[0]
|
|
72
|
+
if (op === 'recall' || op === 'edit' || op === 'regenerate' || op === 'restore') return op
|
|
73
|
+
}
|
|
63
74
|
return 'edit'
|
|
64
75
|
}
|
|
65
76
|
|