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/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.3.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/azmavethy/dsh-retrace.git"
93
+ "url": "git+https://github.com/yamingmou/dsh-retrace.git"
86
94
  },
87
95
  "bugs": {
88
- "url": "https://github.com/azmavethy/dsh-retrace/issues"
96
+ "url": "https://github.com/yamingmou/dsh-retrace/issues"
89
97
  },
90
- "homepage": "https://github.com/azmavethy/dsh-retrace#readme",
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
+ }