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/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 op = url.pathname.split('/').filter(Boolean).at(-1) ?? ''
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
- const api = createEditorApi(ctx, ctx.sessions, ctx.agents, log)
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
- * - `init` — empty fold state (see lib/version-index.js),
11
- * - `apply` — deterministic fold over one committed event (same reference
12
- * when nothing changed — `Object.is` gates the change feed),
13
- * - `view` — the client-visible wire value (version list summary served
14
- * in `session/projection` push frames and `snapshot()`).
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
- * The unit schema validates the VIEW value (the registry parses `view(state)`
17
- * before serving); the raw fold state is plain JSON so the projection cache
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
- schema: versionsViewSchema,
78
+ stateSchema: versionIndexStateSchema,
64
79
  init: () => createVersionIndexState(),
65
80
  apply: (state, event) => applyVersionIndex(state, event),
66
- view: (state) => viewVersionIndex(state),
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
@@ -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
+ }
@@ -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 prefix = `${MARKER_ID_PREFIX}-`
60
- if (!id.startsWith(prefix)) return 'edit'
61
- const op = id.slice(prefix.length).split('-')[0]
62
- if (op === 'recall' || op === 'edit' || op === 'regenerate' || op === 'restore') return op
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