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/versioning.js
CHANGED
|
@@ -19,10 +19,12 @@ import { join } from 'node:path'
|
|
|
19
19
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
20
20
|
import {
|
|
21
21
|
createArtifactStore,
|
|
22
|
+
gcArtifacts,
|
|
22
23
|
refFor,
|
|
23
24
|
retraceDomainSpec,
|
|
24
25
|
} from './artifact-store.js'
|
|
25
26
|
import { versionsProjectionDefinition } from './projection/versions.js'
|
|
27
|
+
import { createGitAdapter, createSubprocessRunner } from './git-adapter.js'
|
|
26
28
|
|
|
27
29
|
/** Max bytes snapshotted per touched file (over → file skipped, version kept). */
|
|
28
30
|
const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
|
|
@@ -30,7 +32,7 @@ const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
|
|
|
30
32
|
const BINARY_PROBE_BYTES = 1024
|
|
31
33
|
|
|
32
34
|
/** Default per-session config (client may override per request). */
|
|
33
|
-
export const DEFAULT_RETRACE_CONFIG = { versioning: true, git: true, retentionLimit: 50 }
|
|
35
|
+
export const DEFAULT_RETRACE_CONFIG = { versioning: true, git: true, retentionLimit: 50, prewrite: true }
|
|
34
36
|
|
|
35
37
|
function isBinary(bytes) {
|
|
36
38
|
const probe = bytes.subarray(0, BINARY_PROBE_BYTES)
|
|
@@ -50,6 +52,7 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
50
52
|
let seamCtx = null
|
|
51
53
|
let domain = null
|
|
52
54
|
let store = null
|
|
55
|
+
let git = null
|
|
53
56
|
const disposers = []
|
|
54
57
|
/** Per-session request-carried config; seeded from the domain global when ready. */
|
|
55
58
|
const configs = new Map()
|
|
@@ -105,6 +108,109 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
105
108
|
)
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Side effect: record the git facts (HEAD + dirty) of a new version boundary
|
|
113
|
+
* (PLAN §4.4 commit-free). Read-only commands only; best-effort — a failure
|
|
114
|
+
* just leaves the version without git facts (snapshot rollback still works).
|
|
115
|
+
*/
|
|
116
|
+
async function recordVersionGit(session, record) {
|
|
117
|
+
if (!domain || !git || !configFor(session.id).git) return
|
|
118
|
+
const cwd = session.header?.cwd
|
|
119
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return
|
|
120
|
+
try {
|
|
121
|
+
const status = await git.status(cwd)
|
|
122
|
+
await domain.table('versiongit').put(record.versionId, {
|
|
123
|
+
headHash: status?.headHash ?? null,
|
|
124
|
+
dirty: status?.dirty ?? false,
|
|
125
|
+
})
|
|
126
|
+
} catch (error) {
|
|
127
|
+
log(`retrace: git facts skipped for ${record.versionId}: ${String(error)}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The git adapter, created lazily once the seam context is available. */
|
|
132
|
+
function ensureGit() {
|
|
133
|
+
if (git === null && seamCtx !== null) {
|
|
134
|
+
// `ctx.subprocess` is a cordis getter that throws when the service is not
|
|
135
|
+
// injected; headless/minimal compositions degrade to snapshot-only
|
|
136
|
+
// rollback instead of crashing the plugin.
|
|
137
|
+
let subprocess
|
|
138
|
+
try {
|
|
139
|
+
subprocess = ctx.subprocess
|
|
140
|
+
} catch {
|
|
141
|
+
subprocess = undefined
|
|
142
|
+
}
|
|
143
|
+
if (subprocess) git = createGitAdapter(createSubprocessRunner(ctx, log))
|
|
144
|
+
}
|
|
145
|
+
return git
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
// P1.5 — bounded retention: background GC over the artifact store.
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
/** sessionId → retained versionIds (updated at every boundary we observe). */
|
|
152
|
+
const retainedVersions = new Map()
|
|
153
|
+
/** Throttle: one sweep per session at most every GC_INTERVAL_MS. */
|
|
154
|
+
const lastGc = new Map()
|
|
155
|
+
const GC_INTERVAL_MS = options.gcIntervalMs ?? 60_000
|
|
156
|
+
|
|
157
|
+
function noteRetained(sessionId, versions) {
|
|
158
|
+
retainedVersions.set(sessionId, new Set(versions.map((v) => v.versionId)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Filter a refcounts record to the references still retained by any known
|
|
163
|
+
* session, returning the surviving refs. Unknown versions are kept
|
|
164
|
+
* (conservative — a cold-restored session we have not seen yet may still
|
|
165
|
+
* reference them).
|
|
166
|
+
*/
|
|
167
|
+
function retainedRefs(refs) {
|
|
168
|
+
const retained = new Set()
|
|
169
|
+
for (const ids of retainedVersions.values()) for (const id of ids) retained.add(id)
|
|
170
|
+
return refs.filter((ref) => {
|
|
171
|
+
const colon = ref.indexOf(':')
|
|
172
|
+
const versionId = colon === -1 ? ref : ref.slice(0, colon)
|
|
173
|
+
return retained.has(versionId)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Throttled sweep: prune refs to truncated versions of known sessions, drop
|
|
179
|
+
* objects that end up with zero refs, and prune versiongit rows of versions
|
|
180
|
+
* no longer retained. Safe by construction — never touches an object a
|
|
181
|
+
* retained version references, and unknown sessions keep their refs.
|
|
182
|
+
*/
|
|
183
|
+
async function sweepGc(sessionId) {
|
|
184
|
+
if (!domain || !store) return
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
const last = lastGc.get(sessionId) ?? 0
|
|
187
|
+
if (now - last < GC_INTERVAL_MS) return
|
|
188
|
+
lastGc.set(sessionId, now)
|
|
189
|
+
try {
|
|
190
|
+
const refcounts = domain.table('refcounts')
|
|
191
|
+
const versiongit = domain.table('versiongit')
|
|
192
|
+
const keep = new Set()
|
|
193
|
+
const dead = []
|
|
194
|
+
for (const [sha, record] of refcounts.entries()) {
|
|
195
|
+
const refs = Array.isArray(record.refs) ? record.refs : []
|
|
196
|
+
const kept = retainedRefs(refs)
|
|
197
|
+
if (kept.length !== refs.length) await refcounts.put(sha, { ...record, refs: kept })
|
|
198
|
+
if (kept.length > 0) keep.add(sha)
|
|
199
|
+
else dead.push(sha)
|
|
200
|
+
}
|
|
201
|
+
// Prune versiongit rows of versions no session retains any more.
|
|
202
|
+
const retained = new Set()
|
|
203
|
+
for (const ids of retainedVersions.values()) for (const id of ids) retained.add(id)
|
|
204
|
+
for (const versionId of [...versiongit.keys()]) {
|
|
205
|
+
if (!retained.has(versionId)) await versiongit.delete(versionId)
|
|
206
|
+
}
|
|
207
|
+
const removed = await gcArtifacts(store, keep)
|
|
208
|
+
if (removed > 0 || dead.length > 0) log(`retrace: GC removed ${removed} objects (${dead.length} zero-ref)`)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
log(`retrace: GC sweep failed: ${String(error)}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
108
214
|
/**
|
|
109
215
|
* Register the projection unit, open the domain and wire the change feed.
|
|
110
216
|
* Idempotent; safe to call from apply.
|
|
@@ -118,9 +224,12 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
118
224
|
disposers.push(
|
|
119
225
|
seam.sessionProjections.onChanged((session, key, value, seq) => {
|
|
120
226
|
if (key !== 'retrace/versions') return
|
|
227
|
+
noteRetained(session.id, value.versions ?? [])
|
|
121
228
|
const latest = value.versions.at(-1)
|
|
122
229
|
if (!latest || latest.boundarySeq !== seq) return // only NEW boundaries
|
|
123
230
|
void snapshotVersionFiles(session, latest)
|
|
231
|
+
void recordVersionGit(session, latest)
|
|
232
|
+
void sweepGc(session.id)
|
|
124
233
|
}),
|
|
125
234
|
)
|
|
126
235
|
// Domain open is async; versioning degrades to L1 if it fails.
|
|
@@ -181,6 +290,77 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
181
290
|
return seamCtx.sessionQuery.readSurface(sessionId)
|
|
182
291
|
}
|
|
183
292
|
|
|
293
|
+
/** The live agent for one session (rollback busy-guard). */
|
|
294
|
+
function agentOf(sessionId) {
|
|
295
|
+
return ctx.agents?.get?.(sessionId) ?? undefined
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Resolve the content address of `<versionId>:<path>` (or null). */
|
|
299
|
+
async function resolveSnapshot(versionId, path) {
|
|
300
|
+
if (!domain || !store) return null
|
|
301
|
+
const ref = refFor(versionId, path)
|
|
302
|
+
const refcounts = domain.table('refcounts')
|
|
303
|
+
for (const [sha, record] of refcounts.entries()) {
|
|
304
|
+
if (record.refs && record.refs.includes(ref)) return sha
|
|
305
|
+
}
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Integrity-checked snapshot read (rollback restore path). */
|
|
310
|
+
async function readSnapshot(sha) {
|
|
311
|
+
if (!store) {
|
|
312
|
+
const error = new Error('versioning surface unavailable')
|
|
313
|
+
error.code = 'versioning-unavailable'
|
|
314
|
+
throw error
|
|
315
|
+
}
|
|
316
|
+
return store.read(sha)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Git facts for one version (headHash recorded at its boundary). */
|
|
320
|
+
async function gitHeadFor(versionId) {
|
|
321
|
+
if (!domain) return null
|
|
322
|
+
return domain.table('versiongit').get(versionId) ?? null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Repo status of the session workspace (null = not a repository). */
|
|
326
|
+
async function gitStatus(sessionId) {
|
|
327
|
+
const adapter = ensureGit()
|
|
328
|
+
if (!adapter) return null
|
|
329
|
+
const session = ctx.sessions.get(sessionId)
|
|
330
|
+
const cwd = session?.header?.cwd
|
|
331
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return null
|
|
332
|
+
return adapter.status(cwd)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `git checkout <headHash> -- <paths>` (write; caller confirms first). */
|
|
336
|
+
async function gitCheckout(cwd, headHash, paths) {
|
|
337
|
+
const adapter = ensureGit()
|
|
338
|
+
if (!adapter) {
|
|
339
|
+
const error = new Error('git adapter unavailable')
|
|
340
|
+
error.code = 'versioning-unavailable'
|
|
341
|
+
throw error
|
|
342
|
+
}
|
|
343
|
+
return adapter.checkout(cwd, headHash, paths)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One-click init for a non-repository workspace (write; caller confirms). */
|
|
347
|
+
async function gitInit(sessionId) {
|
|
348
|
+
const adapter = ensureGit()
|
|
349
|
+
if (!adapter) {
|
|
350
|
+
const error = new Error('git adapter unavailable')
|
|
351
|
+
error.code = 'versioning-unavailable'
|
|
352
|
+
throw error
|
|
353
|
+
}
|
|
354
|
+
const session = ctx.sessions.get(sessionId)
|
|
355
|
+
const cwd = session?.header?.cwd
|
|
356
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
357
|
+
const error = new Error('session has no workspace cwd')
|
|
358
|
+
error.code = 'no-cwd'
|
|
359
|
+
throw error
|
|
360
|
+
}
|
|
361
|
+
return adapter.init(cwd)
|
|
362
|
+
}
|
|
363
|
+
|
|
184
364
|
return {
|
|
185
365
|
register,
|
|
186
366
|
available,
|
|
@@ -189,5 +369,12 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
189
369
|
snapshot,
|
|
190
370
|
readEvent,
|
|
191
371
|
readSurface,
|
|
372
|
+
agentOf,
|
|
373
|
+
resolveSnapshot,
|
|
374
|
+
readSnapshot,
|
|
375
|
+
gitHeadFor,
|
|
376
|
+
gitStatus,
|
|
377
|
+
gitCheckout,
|
|
378
|
+
gitInit,
|
|
192
379
|
}
|
|
193
380
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-retrace",
|
|
3
3
|
"description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.2",
|
|
5
|
+
"packageManager": "pnpm@11.7.0",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "lib/index.js",
|
|
7
8
|
"types": "lib/types/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
|
|
11
|
+
"check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"prepublishOnly": "pnpm check && pnpm build && pnpm test"
|
|
14
|
+
},
|
|
8
15
|
"exports": {
|
|
9
16
|
".": {
|
|
10
17
|
"default": "./lib/index.js"
|
|
@@ -78,23 +85,19 @@
|
|
|
78
85
|
"react": "^18.2.0"
|
|
79
86
|
},
|
|
80
87
|
"dependencies": {
|
|
88
|
+
"dsh-log-contract": "^0.1.0",
|
|
81
89
|
"zod": "^4.4.3"
|
|
82
90
|
},
|
|
83
91
|
"repository": {
|
|
84
92
|
"type": "git",
|
|
85
|
-
"url": "git+https://github.com/
|
|
93
|
+
"url": "git+https://github.com/yamingmou/dsh-retrace.git"
|
|
86
94
|
},
|
|
87
95
|
"bugs": {
|
|
88
|
-
"url": "https://github.com/
|
|
96
|
+
"url": "https://github.com/yamingmou/dsh-retrace/issues"
|
|
89
97
|
},
|
|
90
|
-
"homepage": "https://github.com/
|
|
98
|
+
"homepage": "https://github.com/yamingmou/dsh-retrace#readme",
|
|
91
99
|
"devDependencies": {
|
|
92
100
|
"esbuild": "^0.28.2",
|
|
93
101
|
"vitest": "^4.1.11"
|
|
94
|
-
},
|
|
95
|
-
"scripts": {
|
|
96
|
-
"build": "node scripts/generate-dynamic.mjs && node scripts/build-client.mjs",
|
|
97
|
-
"check": "node scripts/check-syntax.mjs && node scripts/check-dynamic.mjs",
|
|
98
|
-
"test": "vitest run"
|
|
99
102
|
}
|
|
100
|
-
}
|
|
103
|
+
}
|