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.
- package/HUMANS.txt +18 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +284 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifact-store.js +239 -0
- package/lib/client.bundle.js +637 -0
- package/lib/client.js +752 -0
- package/lib/dynamic-client.js +647 -0
- package/lib/dynamic-host.js +399 -0
- package/lib/host-core.js +379 -0
- package/lib/http.js +186 -0
- package/lib/index.js +75 -0
- package/lib/projection/versions.js +69 -0
- package/lib/types/client.d.ts +14 -0
- package/lib/types/index.d.ts +65 -0
- package/lib/version-index.js +310 -0
- package/lib/versioning.js +193 -0
- package/package.json +100 -0
|
@@ -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
|
+
}
|