dsh-retrace 0.3.0

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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * dsh-retrace — `retrace/versions` projection unit definition.
3
+ *
4
+ * Registered on the official `ctx.sessionProjections` registry
5
+ * (`@deepseek-ai/dsh-session-projection`), per PLAN.md §4.1. The framework
6
+ * owns the single `session/event` subscription, the per-session watermark
7
+ * cache, checkpoint persistence (`dsh-session-projection-cache`) and cold
8
+ * reads; this unit contributes only the pure state machine:
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()`).
15
+ *
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
+ */
20
+ import { z } from 'zod'
21
+ import {
22
+ VERSION_LIMIT,
23
+ applyVersionIndex,
24
+ createVersionIndexState,
25
+ viewVersionIndex,
26
+ } from '../version-index.js'
27
+
28
+ const VERSION_KINDS = ['recall', 'edit', 'regenerate', 'restore', 'compaction', 'replace']
29
+ const FILE_MODES = ['created', 'modified', 'deleted']
30
+
31
+ const fileChangeSchema = z.object({
32
+ path: z.string(),
33
+ mode: z.enum(FILE_MODES),
34
+ })
35
+
36
+ const versionSummarySchema = z.object({
37
+ versionId: z.string(),
38
+ boundarySeq: z.number().int().nonnegative(),
39
+ createdAt: z.number().int().nonnegative(),
40
+ kind: z.enum(VERSION_KINDS),
41
+ markerText: z.string(),
42
+ messageCount: z.number().int().nonnegative(),
43
+ fileCounts: z.object({
44
+ created: z.number().int().nonnegative(),
45
+ modified: z.number().int().nonnegative(),
46
+ deleted: z.number().int().nonnegative(),
47
+ }),
48
+ touchedFiles: z.array(fileChangeSchema),
49
+ git: z.null(),
50
+ })
51
+
52
+ /** The wire view schema (what the registry validates and clients receive). */
53
+ export const versionsViewSchema = z.object({
54
+ versions: z.array(versionSummarySchema).max(VERSION_LIMIT),
55
+ })
56
+
57
+ /**
58
+ * The `retrace/versions` unit — register via
59
+ * `ctx.sessionProjections.register(versionsProjectionDefinition)`.
60
+ */
61
+ export const versionsProjectionDefinition = {
62
+ key: 'retrace/versions',
63
+ schema: versionsViewSchema,
64
+ init: () => createVersionIndexState(),
65
+ apply: (state, event) => applyVersionIndex(state, event),
66
+ view: (state) => viewVersionIndex(state),
67
+ /** Bump when the serialized fold state or fold semantics change. */
68
+ stateVersion: 1,
69
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Client entry type stub. The client half is plain ESM importing React and
3
+ * registering `slots`, `locale` and `conversationEvents` services.
4
+ */
5
+ export const name: 'dsh-retrace'
6
+ export const inject: string[]
7
+ export function apply(ctx: unknown): void
8
+
9
+ /**
10
+ * Swap the transport the client uses to reach the Host. The published client
11
+ * defaults to the same-origin HTTP route; the generated dynamic client
12
+ * (scripts/generate-dynamic.mjs) installs a `host.call` wire before apply.
13
+ */
14
+ export function __setMessageEditorWire(fn: ((op: string, payload: unknown) => Promise<unknown>) | null): void
@@ -0,0 +1,65 @@
1
+ /**
2
+ * dsh-retrace type surface.
3
+ * The runtime implementation is dependency-free plain ESM; these types describe
4
+ * the public entry points for TypeScript consumers.
5
+ */
6
+
7
+ export interface EditorOpFailure {
8
+ ok: false
9
+ error: { code: string; message: string }
10
+ }
11
+
12
+ export interface RecallResult {
13
+ op: 'recall'
14
+ messageId: string
15
+ seq: number
16
+ markerSeq: number
17
+ shadowed: number
18
+ /** Durable text of the recalled message (echoed into the composer). */
19
+ text: string
20
+ }
21
+
22
+ export interface EditResult {
23
+ op: 'edit'
24
+ messageId: string
25
+ seq: number
26
+ markerSeq: number
27
+ shadowed: number
28
+ resendMessageId: string
29
+ text: string
30
+ originalText: string
31
+ fromScratch: boolean
32
+ }
33
+
34
+ export interface RegenerateResult {
35
+ op: 'regenerate'
36
+ messageId: string
37
+ seq: number
38
+ markerSeq: number
39
+ shadowed: number
40
+ resendMessageId: string
41
+ }
42
+
43
+ export type EditorOpResult =
44
+ | { ok: true; value: RecallResult }
45
+ | { ok: true; value: EditResult }
46
+ | { ok: true; value: RegenerateResult }
47
+
48
+ export type EditorOpResponse = EditorOpResult | EditorOpFailure
49
+
50
+ export interface RecallArgs {
51
+ sessionId: string
52
+ messageId: string
53
+ }
54
+
55
+ export interface EditAndResendArgs extends RecallArgs {
56
+ text: string
57
+ /** Rewind the whole surface first (new-conversation semantics). */
58
+ fromScratch?: boolean
59
+ }
60
+
61
+ export interface EditorApi {
62
+ recall(args: RecallArgs): Promise<EditorOpResponse>
63
+ editAndResend(args: EditAndResendArgs): Promise<EditorOpResponse>
64
+ regenerate(args: RecallArgs): Promise<EditorOpResponse>
65
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * dsh-retrace — Version index fold (pure, zero imports).
3
+ *
4
+ * Rewritten per PLAN.md v0.3 (2026-08-20 revision): the old "hand-written
5
+ * VersionIndex service + custom jsonl index" design is scrapped. This module
6
+ * is the pure fold consumed by the official `ctx.sessionProjections`
7
+ * projection unit (lib/projection/versions.js) — the framework owns the
8
+ * session/event subscription, the per-session watermark cache, checkpoint
9
+ * persistence and cold-read restore; this file owns only deterministic state
10
+ * transitions over one session's committed events.
11
+ *
12
+ * Boundary semantics mirror the official surface fold (`foldSurface` in
13
+ * `@deepseek-ai/dsh-session/surface`): an append pushes the event seq onto
14
+ * the surface; a replace splices [start..end] out and inserts the
15
+ * replacement's own seq. Every replacement event closes a "version" — our
16
+ * markers (recall / edit / regenerate / restore), compaction checkpoints, or
17
+ * any other future surface replacer. Between two boundaries the fold also
18
+ * accumulates the session's touched files (parsing tool calls and tool
19
+ * results), so each version records what artifacts changed in its window.
20
+ *
21
+ * The state is plain JSON only (arrays, no Set/Map) so the projection cache
22
+ * can persist it verbatim (`dsh-session-projection-cache` validates rows
23
+ * with `z.json()`).
24
+ */
25
+
26
+ export const MARKER_ID_PREFIX = 'retrace'
27
+
28
+ /** Timeline keeps the most recent N versions; full history stays replayable from the log. */
29
+ export const VERSION_LIMIT = 200
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Official-equivalent predicates
33
+ // ---------------------------------------------------------------------------
34
+ // Semantics copied from the official contracts (not code) and locked by unit
35
+ // tests, so this module stays import-free and runnable in every realm:
36
+ // isReplacementSurfaceEvent — @deepseek-ai/dsh-session/surface
37
+ // isCompactCheckpointSource — @deepseek-ai/dsh-compaction/checkpoint
38
+
39
+ const SURFACE_EVENT_TYPES = new Set(['user/message', 'assistant/message', 'tool/result'])
40
+
41
+ /** Whether an event is surface-eligible AND carries a surfaceOp marker. */
42
+ export function isSurfaceEvent(event) {
43
+ return SURFACE_EVENT_TYPES.has(event?.type) && event.surfaceOp !== undefined
44
+ }
45
+
46
+ /** Whether an event shadowed an existing surface range (append is the only non-replacement). */
47
+ export function isReplacementSurfaceEvent(event) {
48
+ return isSurfaceEvent(event) && event.surfaceOp !== 'append'
49
+ }
50
+
51
+ /** Whether a message source identifies a compaction checkpoint (`plugin: compact`). */
52
+ export function isCompactCheckpointSource(source) {
53
+ return Boolean(source) && source.kind === 'plugin' && source.plugin === 'compact'
54
+ }
55
+
56
+ /** Map a marker event id prefix to a user-facing version kind. */
57
+ export function kindFromMarkerId(id) {
58
+ 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
63
+ return 'edit'
64
+ }
65
+
66
+ /**
67
+ * Classify a replacement boundary event into a version kind.
68
+ * 1. our markers carry `data.editor` (assistant/message, id `retrace-<op>-…`);
69
+ * 2. compaction checkpoints are user/message with the `plugin: compact` source;
70
+ * 3. anything else is a generic `replace`.
71
+ */
72
+ export function classifyBoundaryKind(event) {
73
+ if (event?.data?.editor) return kindFromMarkerId(event.data?.message?.id)
74
+ if (isCompactCheckpointSource(event?.data?.source)) return 'compaction'
75
+ return 'replace'
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Touched-file extraction (pure)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /** Tool name → write intent. Unknown tools are ignored unless meta.path says otherwise. */
83
+ const WRITE_TOOLS = /(?:^|\.)(fs\.(?:write|edit|create|append)|edit|patch|apply-patch)(?:$|\.)/i
84
+ const DELETE_TOOLS = /(?:^|\.)(fs\.(?:remove|delete|rename)|rm|del)(?:$|\.)/i
85
+
86
+ function classifyToolIntent(name) {
87
+ if (typeof name !== 'string') return null
88
+ if (DELETE_TOOLS.test(name)) return 'delete'
89
+ if (WRITE_TOOLS.test(name)) return 'write'
90
+ return null
91
+ }
92
+
93
+ /** Pull path-like strings out of a tool-call arguments JSON (best effort). */
94
+ export function pathsFromToolCallArguments(name, argumentsJson) {
95
+ if (typeof argumentsJson !== 'string' || argumentsJson.length === 0) return []
96
+ let args
97
+ try {
98
+ args = JSON.parse(argumentsJson)
99
+ } catch {
100
+ return []
101
+ }
102
+ if (typeof args !== 'object' || args === null) return []
103
+ const found = new Set()
104
+ // Values under these keys are paths by contract — accepted even without a
105
+ // slash (`README.md`); free-floating strings elsewhere need a slash to be
106
+ // considered path-like (avoids capturing prose like "hello world").
107
+ const PATH_KEYS = new Set(['path', 'paths', 'file_path', 'filepath', 'cwd', 'dirname'])
108
+ const visit = (value, underPathKey) => {
109
+ if (typeof value === 'string') {
110
+ const looksLikePath = underPathKey
111
+ ? value.length > 0 && value !== '.' && value !== '..'
112
+ : value.includes('/') && value.length > 1
113
+ if (looksLikePath) found.add(value)
114
+ return
115
+ }
116
+ if (Array.isArray(value)) {
117
+ for (const item of value) visit(item, underPathKey)
118
+ return
119
+ }
120
+ if (typeof value === 'object' && value !== null) {
121
+ for (const key of Object.keys(value)) {
122
+ if (PATH_KEYS.has(key)) visit(value[key], true)
123
+ else if (typeof value[key] === 'object' && value[key] !== null) visit(value[key], underPathKey)
124
+ }
125
+ }
126
+ }
127
+ visit(args, false)
128
+ return [...found]
129
+ }
130
+
131
+ /**
132
+ * Parse one event's touched-file contribution.
133
+ * @returns {Array<{path: string, intent: 'write'|'delete'|'unknown'}>}
134
+ */
135
+ export function touchedFilesFromEvent(event) {
136
+ if (!event) return []
137
+ const { type, data } = event
138
+ const out = []
139
+ if (type === 'tool/call' && data && typeof data.arguments === 'string') {
140
+ const intent = classifyToolIntent(data.name)
141
+ if (intent !== null) {
142
+ for (const path of pathsFromToolCallArguments(data.name, data.arguments)) {
143
+ out.push({ path, intent })
144
+ }
145
+ }
146
+ }
147
+ if (type === 'tool/result' && data) {
148
+ const failed = Boolean(data.error)
149
+ const metaPath = data.meta && typeof data.meta.path === 'string' ? data.meta.path : null
150
+ if (metaPath && !failed) out.push({ path: metaPath, intent: 'write' })
151
+ }
152
+ return out
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Version index fold
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * @typedef {object} VersionFile
161
+ * @property {string} path — canonical path relative to the session cwd.
162
+ * @property {'created'|'modified'|'deleted'} mode
163
+ */
164
+
165
+ /**
166
+ * @typedef {object} VersionRecord
167
+ * @property {string} versionId — `v<boundarySeq>`.
168
+ * @property {number} boundarySeq — the replacement event's seq.
169
+ * @property {number} createdAt — event time (ms), keeps the fold replayable.
170
+ * @property {'recall'|'edit'|'regenerate'|'restore'|'compaction'|'replace'} kind
171
+ * @property {string} markerText — replaced-content summary (editor.text) or ''.
172
+ * @property {VersionFile[]} touchedFiles
173
+ * @property {number} messageCount — surface node count at the boundary.
174
+ * @property {null} git — P1: { headHash, dirty, diffSha }.
175
+ */
176
+
177
+ /** Create the empty per-session fold state (plain JSON only). */
178
+ export function createVersionIndexState() {
179
+ return {
180
+ versions: [],
181
+ /** path → { intent, lastSeq } — files touched since the last boundary. */
182
+ windowFiles: {},
183
+ /** paths that appeared in any already-finalized version (for created/modified). */
184
+ knownFiles: [],
185
+ /** current surface node seqs (mirrors foldSurface). */
186
+ surface: [],
187
+ }
188
+ }
189
+
190
+ /** Map a window file entry to its version mode against the previous versions. */
191
+ function fileMode(state, path, window) {
192
+ if (window.intent === 'delete') return 'deleted'
193
+ return state.knownFiles.includes(path) ? 'modified' : 'created'
194
+ }
195
+
196
+ function extractMarkerText(event, kind) {
197
+ if (kind === 'compaction') return ''
198
+ const text = event?.data?.editor?.text
199
+ return typeof text === 'string' ? text : ''
200
+ }
201
+
202
+ /** Close a version at a replacement boundary (window files frozen, window cleared). */
203
+ function freezeVersion(state, event) {
204
+ const boundarySeq = event.seq
205
+ const kind = classifyBoundaryKind(event)
206
+ const files = Object.entries(state.windowFiles)
207
+ .map(([path, window]) => ({ path, mode: fileMode(state, path, window) }))
208
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
209
+ const record = {
210
+ versionId: `v${boundarySeq}`,
211
+ boundarySeq,
212
+ createdAt: typeof event.time === 'number' ? event.time : 0,
213
+ kind,
214
+ markerText: extractMarkerText(event, kind),
215
+ touchedFiles: files,
216
+ messageCount: state.surface.length,
217
+ git: null,
218
+ }
219
+ const versions = [...state.versions, record]
220
+ if (versions.length > VERSION_LIMIT) versions.splice(0, versions.length - VERSION_LIMIT)
221
+ const knownFiles = [...state.knownFiles]
222
+ for (const file of files) {
223
+ if (!knownFiles.includes(file.path)) knownFiles.push(file.path)
224
+ }
225
+ return { versions, windowFiles: {}, knownFiles, surface: state.surface }
226
+ }
227
+
228
+ /**
229
+ * Fold one committed event into the version index state.
230
+ *
231
+ * Performance contract (load-bearing for the projection registry): when the
232
+ * event changes nothing — not surface, not files, not a boundary — the SAME
233
+ * state reference is returned (`Object.is` gates the change feed, so a new
234
+ * object means a downstream snapshot/push).
235
+ *
236
+ * @param {ReturnType<typeof createVersionIndexState>} state
237
+ * @param {object} event
238
+ * @returns the next state (same reference when nothing changed).
239
+ */
240
+ export function applyVersionIndex(state, event) {
241
+ if (!event || typeof event.seq !== 'number') return state
242
+ let next = state
243
+
244
+ // 1. Surface fold (mirror foldSurface): append → push; replace → splice.
245
+ if (isSurfaceEvent(event)) {
246
+ const op = event.surfaceOp
247
+ if (op === 'append') {
248
+ next = { ...next, surface: [...next.surface, event.seq] }
249
+ } else if (op && op.op === 'replace') {
250
+ const startIdx = next.surface.indexOf(op.start)
251
+ const endIdx = next.surface.indexOf(op.end)
252
+ if (startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx) {
253
+ const surface = next.surface.slice()
254
+ surface.splice(startIdx, endIdx - startIdx + 1, event.seq)
255
+ next = { ...next, surface }
256
+ }
257
+ }
258
+ }
259
+
260
+ // 2. Touched-file window.
261
+ const touched = touchedFilesFromEvent(event)
262
+ if (touched.length > 0) {
263
+ const windowFiles = { ...next.windowFiles }
264
+ for (const { path, intent } of touched) {
265
+ const prev = windowFiles[path]
266
+ if (!prev || event.seq > prev.lastSeq) windowFiles[path] = { intent, lastSeq: event.seq }
267
+ }
268
+ next = { ...next, windowFiles }
269
+ }
270
+
271
+ // 3. Version boundary.
272
+ if (isReplacementSurfaceEvent(event)) {
273
+ next = freezeVersion(next, event)
274
+ }
275
+
276
+ return next
277
+ }
278
+
279
+ /** Count file modes of one version's touched files (timeline badge input). */
280
+ export function countFileModes(touchedFiles) {
281
+ const counts = { created: 0, modified: 0, deleted: 0 }
282
+ for (const file of touchedFiles) {
283
+ if (file.mode === 'created') counts.created += 1
284
+ else if (file.mode === 'modified') counts.modified += 1
285
+ else if (file.mode === 'deleted') counts.deleted += 1
286
+ }
287
+ return counts
288
+ }
289
+
290
+ /**
291
+ * The client-visible wire value (what `ctx.sessionProjections` serves in
292
+ * `session/projection` frames and `snapshot()`). Compact per-version summary
293
+ * plus the full touched-file list (the HTTP `/versions` route serves this
294
+ * same shape; rollback/detail reads files lazily in P1).
295
+ */
296
+ export function viewVersionIndex(state) {
297
+ return {
298
+ versions: state.versions.map((record) => ({
299
+ versionId: record.versionId,
300
+ boundarySeq: record.boundarySeq,
301
+ createdAt: record.createdAt,
302
+ kind: record.kind,
303
+ markerText: record.markerText,
304
+ messageCount: record.messageCount,
305
+ fileCounts: countFileModes(record.touchedFiles),
306
+ touchedFiles: record.touchedFiles,
307
+ git: record.git,
308
+ })),
309
+ }
310
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * dsh-retrace — the versioning seam: projection unit registration, artifact
3
+ * snapshot side effects, the `retrace` storageDomain, and the per-session
4
+ * config view.
5
+ *
6
+ * PLAN.md §4.1/§4.3/§4.6. Everything here lives behind `ctx.inject(...)` so
7
+ * headless compositions without `sessionProjections`/`sessionQuery`/
8
+ * `storageDomain` simply never activate it — the plugin degrades to plain L1
9
+ * (recall / edit / regenerate) with no versioning surface, exactly like
10
+ * 0.2.x.
11
+ *
12
+ * The projection unit is registered unconditionally once the seam is
13
+ * available (pure fold, framework-owned cache, negligible cost). The
14
+ * "versioning off" switch is honored at the side-effect and HTTP surface:
15
+ * no snapshots are written and `/versions` reports `enabled: false` — the
16
+ * user-visible behavior is identical to 0.2.x either way.
17
+ */
18
+ import { join } from 'node:path'
19
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
20
+ import {
21
+ createArtifactStore,
22
+ refFor,
23
+ retraceDomainSpec,
24
+ } from './artifact-store.js'
25
+ import { versionsProjectionDefinition } from './projection/versions.js'
26
+
27
+ /** Max bytes snapshotted per touched file (over → file skipped, version kept). */
28
+ const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
29
+ /** NUL-byte probe window for binary detection. */
30
+ const BINARY_PROBE_BYTES = 1024
31
+
32
+ /** Default per-session config (client may override per request). */
33
+ export const DEFAULT_RETRACE_CONFIG = { versioning: true, git: true, retentionLimit: 50 }
34
+
35
+ function isBinary(bytes) {
36
+ const probe = bytes.subarray(0, BINARY_PROBE_BYTES)
37
+ for (const byte of probe) if (byte === 0) return true
38
+ return false
39
+ }
40
+
41
+ /**
42
+ * Create the versioning seam for one plugin context.
43
+ * `register()` must run synchronously inside `apply` so the `ctx.inject`
44
+ * effects attach to the plugin fiber.
45
+ * @param {object} options - `storeRoot` overrides the artifact root
46
+ * (defaults to `$DSH_HOME/dsh-retrace`); tests use a temp directory.
47
+ */
48
+ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
49
+ let registered = false
50
+ let seamCtx = null
51
+ let domain = null
52
+ let store = null
53
+ const disposers = []
54
+ /** Per-session request-carried config; seeded from the domain global when ready. */
55
+ const configs = new Map()
56
+
57
+ function configFor(sessionId) {
58
+ return configs.get(sessionId) ?? { ...DEFAULT_RETRACE_CONFIG }
59
+ }
60
+
61
+ function setConfig(sessionId, config) {
62
+ configs.set(sessionId, { ...DEFAULT_RETRACE_CONFIG, ...config })
63
+ }
64
+
65
+ /** Versioning surface availability (false in headless/minimal compositions). */
66
+ function available() {
67
+ return seamCtx !== null
68
+ }
69
+
70
+ /** Read one touched file through the sandboxed fs and snapshot it. */
71
+ async function snapshotOne(session, record, file) {
72
+ const cwd = session.header?.cwd
73
+ if (typeof cwd !== 'string' || cwd.length === 0) return
74
+ try {
75
+ const target = await ctx.fs.resolve(file.path, { cwd })
76
+ const workspaceTarget = await ctx.fs.resolve('.', { cwd })
77
+ if (!ctx.fs.contains(workspaceTarget, target)) return // outside the workspace
78
+ const bytes = await ctx.fs.readBytes(target, undefined, MAX_SNAPSHOT_BYTES)
79
+ if (isBinary(bytes)) return
80
+ const { sha256, sizeBytes } = await store.save(bytes)
81
+ const refsTable = domain.table('refcounts')
82
+ const ref = refFor(record.versionId, file.path)
83
+ const prev = refsTable.get(sha256)
84
+ if (prev && !prev.refs.includes(ref)) {
85
+ await refsTable.put(sha256, { ...prev, refs: [...prev.refs, ref] })
86
+ } else if (!prev) {
87
+ await refsTable.put(sha256, {
88
+ refs: [ref],
89
+ sizeBytes,
90
+ createdAt: typeof record.createdAt === 'number' ? record.createdAt : Date.now(),
91
+ })
92
+ }
93
+ } catch (error) {
94
+ log(`retrace: snapshot skipped for ${file.path}: ${String(error)}`)
95
+ }
96
+ }
97
+
98
+ /** Side effect: snapshot every non-deleted touched file of a new version. */
99
+ async function snapshotVersionFiles(session, record) {
100
+ if (!domain || !store || !configFor(session.id).versioning) return
101
+ await Promise.allSettled(
102
+ record.touchedFiles
103
+ .filter((file) => file.mode !== 'deleted')
104
+ .map((file) => snapshotOne(session, record, file)),
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Register the projection unit, open the domain and wire the change feed.
110
+ * Idempotent; safe to call from apply.
111
+ */
112
+ function register() {
113
+ if (registered) return
114
+ registered = true
115
+ ctx.inject(['sessionProjections', 'sessionQuery', 'storageDomain'], (seam) => {
116
+ seamCtx = seam
117
+ disposers.push(seam.sessionProjections.register(versionsProjectionDefinition))
118
+ disposers.push(
119
+ seam.sessionProjections.onChanged((session, key, value, seq) => {
120
+ if (key !== 'retrace/versions') return
121
+ const latest = value.versions.at(-1)
122
+ if (!latest || latest.boundarySeq !== seq) return // only NEW boundaries
123
+ void snapshotVersionFiles(session, latest)
124
+ }),
125
+ )
126
+ // Domain open is async; versioning degrades to L1 if it fails.
127
+ void seam.storageDomain
128
+ .open(retraceDomainSpec)
129
+ .then((opened) => {
130
+ domain = opened
131
+ const root = options.storeRoot ?? join(resolveDshHome(), 'dsh-retrace')
132
+ store = createArtifactStore(root)
133
+ disposers.push(() => void domain.close())
134
+ const global = opened.global.get()
135
+ if (global) {
136
+ // Seed session configs with the durable defaults (per-request overrides win).
137
+ for (const [id, config] of configs) setConfig(id, { ...config, ...global })
138
+ }
139
+ })
140
+ .catch((error) => log(`retrace: versioning disabled: ${String(error)}`))
141
+ })
142
+ ctx.effect(() => () => {
143
+ for (const dispose of disposers) dispose()
144
+ }, 'dsh-retrace: versioning seam')
145
+ }
146
+
147
+ /** HTTP fallback channel: the live projection snapshot for one session. */
148
+ function snapshot(sessionId) {
149
+ if (!available()) {
150
+ return { enabled: false, versions: [] }
151
+ }
152
+ const session = ctx.sessions.get(sessionId)
153
+ if (!session) {
154
+ const error = new Error(`session "${sessionId}" not found`)
155
+ error.code = 'session-not-found'
156
+ throw error
157
+ }
158
+ const cut = seamCtx.sessionProjections.snapshot(session)
159
+ const value = cut.values['retrace/versions']
160
+ if (!value) return { enabled: false, versions: [] }
161
+ return { enabled: configFor(sessionId).versioning, ...value }
162
+ }
163
+
164
+ /** readEvent passthrough (timeline detail drawer). */
165
+ async function readEvent(request) {
166
+ if (!available()) {
167
+ const error = new Error('versioning surface unavailable')
168
+ error.code = 'versioning-unavailable'
169
+ throw error
170
+ }
171
+ return seamCtx.sessionQuery.readEvent(request)
172
+ }
173
+
174
+ /** readSurface passthrough. */
175
+ async function readSurface(sessionId) {
176
+ if (!available()) {
177
+ const error = new Error('versioning surface unavailable')
178
+ error.code = 'versioning-unavailable'
179
+ throw error
180
+ }
181
+ return seamCtx.sessionQuery.readSurface(sessionId)
182
+ }
183
+
184
+ return {
185
+ register,
186
+ available,
187
+ configFor,
188
+ setConfig,
189
+ snapshot,
190
+ readEvent,
191
+ readSurface,
192
+ }
193
+ }