dsh-retrace 0.4.2 → 0.4.5
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/client.bundle.js +435 -104
- package/lib/client.js +634 -132
- package/lib/dynamic-client.js +435 -104
- package/lib/forkmap.js +106 -0
- package/lib/http.js +14 -0
- package/lib/projection/forkmap.js +69 -0
- package/lib/versioning.js +21 -6
- package/package.json +1 -1
package/lib/forkmap.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — `retrace/forkmap` pure fold (PLAN §5.2, P2.1).
|
|
3
|
+
*
|
|
4
|
+
* The fork map is the BRANCH-TOPOLOGY presentation layer ("how we got here"):
|
|
5
|
+
* every surface replacement — recall / edit / regenerate / restore markers,
|
|
6
|
+
* compaction checkpoints, and any other replace — is a FORK POINT that shadows
|
|
7
|
+
* a range of the old path and continues a new path. This module mirrors the
|
|
8
|
+
* official `foldSurface` state transition (the same one the versions unit
|
|
9
|
+
* uses) and additionally records each boundary with the seqs it shadowed, so
|
|
10
|
+
* the client can render:
|
|
11
|
+
*
|
|
12
|
+
* spine — the current-surface node flow (markers sit ON the spine),
|
|
13
|
+
* branches — at each boundary, the shadowed (old-path) node cluster.
|
|
14
|
+
*
|
|
15
|
+
* Data notes (verified against the official source, 2026-08-26):
|
|
16
|
+
* - replace `start`/`end` are EVENT SEQS (not surface indices); the shadowed
|
|
17
|
+
* seqs are the surface nodes the splice removed — exactly
|
|
18
|
+
* `foldSurface(...).replacements[].shadowedSeqs`.
|
|
19
|
+
* - official `traceEvent` does NOT link a marker to its new path
|
|
20
|
+
* (new-path events never cite the marker, so `derivedEventSeqs` is empty)
|
|
21
|
+
* — the new path IS the spine: the surface nodes after the boundary
|
|
22
|
+
* (PLAN.md:295 "标记→新路径无需自解析" corrected by research).
|
|
23
|
+
* - boundaries ARE the versions (every replacement closes a version); the
|
|
24
|
+
* wire stays lean (no markerText — the client joins it from
|
|
25
|
+
* `retrace/versions` by boundarySeq).
|
|
26
|
+
*
|
|
27
|
+
* The module is import-free except for the shared boundary predicates, stays
|
|
28
|
+
* transport-neutral, and is registered alongside `retrace/versions` in the
|
|
29
|
+
* versioning seam. No side effects: pure fold only.
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
classifyBoundaryKind,
|
|
33
|
+
isReplacementSurfaceEvent,
|
|
34
|
+
isSurfaceEvent,
|
|
35
|
+
} from './version-index.js'
|
|
36
|
+
|
|
37
|
+
/** Fork-map fold state (plain JSON — durable checkpoint rows validate it). */
|
|
38
|
+
export function createForkmapState() {
|
|
39
|
+
return {
|
|
40
|
+
/** Current surface nodes: `{ seq, type }` in fold order (mirror foldSurface). */
|
|
41
|
+
nodes: [],
|
|
42
|
+
/** Replacement boundaries in append order. */
|
|
43
|
+
boundaries: [],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fold one committed event into the fork-map state.
|
|
49
|
+
*
|
|
50
|
+
* Performance contract (load-bearing for the projection registry): when the
|
|
51
|
+
* event changes nothing — no surface move, no boundary — the SAME state
|
|
52
|
+
* reference is returned (`Object.is` gates the change feed).
|
|
53
|
+
*
|
|
54
|
+
* @param {ReturnType<typeof createForkmapState>} state
|
|
55
|
+
* @param {object} event
|
|
56
|
+
* @returns the next state (same reference when nothing changed).
|
|
57
|
+
*/
|
|
58
|
+
export function applyForkmap(state, event) {
|
|
59
|
+
if (!event || typeof event.seq !== 'number') return state
|
|
60
|
+
let next = state
|
|
61
|
+
let shadowed = null
|
|
62
|
+
|
|
63
|
+
// 1. Surface fold (mirror foldSurface / the versions unit).
|
|
64
|
+
if (isSurfaceEvent(event)) {
|
|
65
|
+
const op = event.surfaceOp
|
|
66
|
+
if (op === 'append') {
|
|
67
|
+
next = { ...next, nodes: [...next.nodes, { seq: event.seq, type: event.type }] }
|
|
68
|
+
} else if (op && op.op === 'replace') {
|
|
69
|
+
const startIdx = next.nodes.findIndex((node) => node.seq === op.start)
|
|
70
|
+
const endIdx = next.nodes.findIndex((node) => node.seq === op.end)
|
|
71
|
+
if (startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx) {
|
|
72
|
+
shadowed = next.nodes.slice(startIdx, endIdx + 1).map((node) => node.seq)
|
|
73
|
+
const nodes = next.nodes.slice()
|
|
74
|
+
nodes.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, type: event.type })
|
|
75
|
+
next = { ...next, nodes }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Boundary (fork point). The shadowed range is the splice's removal when
|
|
81
|
+
// the span was live; otherwise fall back to the marker's own citation.
|
|
82
|
+
if (isReplacementSurfaceEvent(event)) {
|
|
83
|
+
const cited = Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs : []
|
|
84
|
+
const replacedSeqs = shadowed !== null ? shadowed : cited
|
|
85
|
+
const boundary = {
|
|
86
|
+
seq: event.seq,
|
|
87
|
+
kind: classifyBoundaryKind(event),
|
|
88
|
+
replacedSeqs,
|
|
89
|
+
}
|
|
90
|
+
next = { ...next, boundaries: [...next.boundaries, boundary] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return next
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The client-visible wire value (what `session/projection` frames serve). */
|
|
97
|
+
export function viewForkmap(state) {
|
|
98
|
+
return {
|
|
99
|
+
nodes: state.nodes.map((node) => ({ seq: node.seq, type: node.type })),
|
|
100
|
+
boundaries: state.boundaries.map((boundary) => ({
|
|
101
|
+
seq: boundary.seq,
|
|
102
|
+
kind: boundary.kind,
|
|
103
|
+
replacedSeqs: boundary.replacedSeqs,
|
|
104
|
+
})),
|
|
105
|
+
}
|
|
106
|
+
}
|
package/lib/http.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* GET /api/plugins/retrace/versions?sessionId=
|
|
9
9
|
* — live projection snapshot (HTTP fallback channel; the push channel is
|
|
10
10
|
* `session/projection` frames via dsh-host-apiproxy).
|
|
11
|
+
* GET /api/plugins/retrace/forkmap?sessionId=
|
|
12
|
+
* — the fork-map projection snapshot (branch topology, P2.1; same
|
|
13
|
+
* push-frame + HTTP fallback dual channel as /versions).
|
|
11
14
|
* GET /api/plugins/retrace/event?sessionId=&seq=&before=&after=
|
|
12
15
|
* — one event + context window (sessionQuery.readEvent, lazy reads).
|
|
13
16
|
* GET /api/plugins/retrace/surface?sessionId=
|
|
@@ -87,6 +90,15 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
|
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
function handleForkmap(req, res, sessionId, config) {
|
|
94
|
+
seam.setConfig(sessionId, config)
|
|
95
|
+
try {
|
|
96
|
+
sendJson(res, 200, { ok: true, value: seam.snapshotForkmap(sessionId) })
|
|
97
|
+
} catch (error) {
|
|
98
|
+
sendError(res, error)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
async function handleEvent(req, res, searchParams) {
|
|
91
103
|
const sessionId = searchParams.get('sessionId') ?? ''
|
|
92
104
|
const seq = Number(searchParams.get('seq'))
|
|
@@ -249,6 +261,8 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
|
|
|
249
261
|
switch (op) {
|
|
250
262
|
case 'versions':
|
|
251
263
|
return handleVersions(req, res, sessionId, config)
|
|
264
|
+
case 'forkmap':
|
|
265
|
+
return handleForkmap(req, res, sessionId, config)
|
|
252
266
|
case 'event':
|
|
253
267
|
return void handleEvent(req, res, searchParams)
|
|
254
268
|
case 'surface':
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace — `retrace/forkmap` projection unit definition.
|
|
3
|
+
*
|
|
4
|
+
* Registered on the official `ctx.sessionProjections` registry alongside
|
|
5
|
+
* `retrace/versions` (PLAN §5.2, P2.1). Same contract as the versions unit:
|
|
6
|
+
*
|
|
7
|
+
* - `stateSchema` — zod schema of the RAW fold state (plain JSON; the
|
|
8
|
+
* durable checkpoint validates rows against it),
|
|
9
|
+
* - `init` / `apply` — the deterministic fold (lib/forkmap.js),
|
|
10
|
+
* - `wire` — the client-visible view ({viewSchema, view}); a unit WITHOUT
|
|
11
|
+
* `wire` registers as checkpoint-only and is never served in
|
|
12
|
+
* `session/projection` push frames or `snapshot()` (the framework
|
|
13
|
+
* contract the P0 versions definition missed; found by the P1 smoke).
|
|
14
|
+
*
|
|
15
|
+
* Unlike the versions unit, the fork-map state is NOT truncated: the whole
|
|
16
|
+
* branch topology must survive (VERSION_LIMIT=200 truncation would drop old
|
|
17
|
+
* fork points). Per-boundary payloads are tiny (seqs only), so the projection
|
|
18
|
+
* stays small even for long sessions; node content stays out of the fold
|
|
19
|
+
* (lazy reads via the HTTP surface when the client needs detail).
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod'
|
|
22
|
+
import {
|
|
23
|
+
applyForkmap,
|
|
24
|
+
createForkmapState,
|
|
25
|
+
viewForkmap,
|
|
26
|
+
} from '../forkmap.js'
|
|
27
|
+
|
|
28
|
+
const FORK_KINDS = ['recall', 'edit', 'regenerate', 'restore', 'compaction', 'replace']
|
|
29
|
+
|
|
30
|
+
const forkmapNodeSchema = z.object({
|
|
31
|
+
seq: z.number().int().nonnegative(),
|
|
32
|
+
type: z.string(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const forkmapBoundarySchema = z.object({
|
|
36
|
+
seq: z.number().int().nonnegative(),
|
|
37
|
+
kind: z.enum(FORK_KINDS),
|
|
38
|
+
replacedSeqs: z.array(z.number().int().nonnegative()),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** Raw fold-state schema (durable checkpoint rows validate against it). */
|
|
42
|
+
export const forkmapStateSchema = z.object({
|
|
43
|
+
nodes: z.array(forkmapNodeSchema),
|
|
44
|
+
boundaries: z.array(forkmapBoundarySchema),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** The wire view schema (what the registry validates and clients receive). */
|
|
48
|
+
export const forkmapViewSchema = forkmapStateSchema
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `retrace/forkmap` unit — register via
|
|
52
|
+
* `ctx.sessionProjections.register(forkmapProjectionDefinition)`.
|
|
53
|
+
*/
|
|
54
|
+
export const forkmapProjectionDefinition = {
|
|
55
|
+
key: 'retrace/forkmap',
|
|
56
|
+
stateSchema: forkmapStateSchema,
|
|
57
|
+
init: () => createForkmapState(),
|
|
58
|
+
apply: (state, event) => applyForkmap(state, event),
|
|
59
|
+
wire: {
|
|
60
|
+
viewSchema: forkmapViewSchema,
|
|
61
|
+
view: (state) => viewForkmap(state),
|
|
62
|
+
},
|
|
63
|
+
/** Bump when the serialized fold state or fold semantics change. */
|
|
64
|
+
stateVersion: 1,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Top-level aliases (unit tests drive the definition directly).
|
|
68
|
+
forkmapProjectionDefinition.schema = forkmapProjectionDefinition.wire.viewSchema
|
|
69
|
+
forkmapProjectionDefinition.view = forkmapProjectionDefinition.wire.view
|
package/lib/versioning.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
retraceDomainSpec,
|
|
25
25
|
} from './artifact-store.js'
|
|
26
26
|
import { versionsProjectionDefinition } from './projection/versions.js'
|
|
27
|
+
import { forkmapProjectionDefinition } from './projection/forkmap.js'
|
|
27
28
|
import { createGitAdapter, createSubprocessRunner } from './git-adapter.js'
|
|
28
29
|
|
|
29
30
|
/** Max bytes snapshotted per touched file (over → file skipped, version kept). */
|
|
@@ -221,6 +222,8 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
221
222
|
ctx.inject(['sessionProjections', 'sessionQuery', 'storageDomain'], (seam) => {
|
|
222
223
|
seamCtx = seam
|
|
223
224
|
disposers.push(seam.sessionProjections.register(versionsProjectionDefinition))
|
|
225
|
+
// P2.1 — the fork-map unit: pure fold (branch topology), no side effects.
|
|
226
|
+
disposers.push(seam.sessionProjections.register(forkmapProjectionDefinition))
|
|
224
227
|
disposers.push(
|
|
225
228
|
seam.sessionProjections.onChanged((session, key, value, seq) => {
|
|
226
229
|
if (key !== 'retrace/versions') return
|
|
@@ -243,7 +246,11 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
243
246
|
const global = opened.global.get()
|
|
244
247
|
if (global) {
|
|
245
248
|
// Seed session configs with the durable defaults (per-request overrides win).
|
|
246
|
-
|
|
249
|
+
// The domain schema spells the git switch `gitEnabled` (0.4.x review:
|
|
250
|
+
// it was merged in but never read — consumers use `config.git`).
|
|
251
|
+
const seeded = { ...global, git: global.gitEnabled ?? configs.get('')?.git ?? true }
|
|
252
|
+
delete seeded.gitEnabled
|
|
253
|
+
for (const [id, config] of configs) setConfig(id, { ...config, ...seeded })
|
|
247
254
|
}
|
|
248
255
|
})
|
|
249
256
|
.catch((error) => log(`retrace: versioning disabled: ${String(error)}`))
|
|
@@ -255,9 +262,16 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
255
262
|
|
|
256
263
|
/** HTTP fallback channel: the live projection snapshot for one session. */
|
|
257
264
|
function snapshot(sessionId) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
265
|
+
return snapshotKey(sessionId, 'retrace/versions', { enabled: false, versions: [] })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** HTTP fallback channel: the fork-map projection snapshot (P2.1). */
|
|
269
|
+
function snapshotForkmap(sessionId) {
|
|
270
|
+
return snapshotKey(sessionId, 'retrace/forkmap', { enabled: false, nodes: [], boundaries: [] })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function snapshotKey(sessionId, key, empty) {
|
|
274
|
+
if (!available()) return empty
|
|
261
275
|
const session = ctx.sessions.get(sessionId)
|
|
262
276
|
if (!session) {
|
|
263
277
|
const error = new Error(`session "${sessionId}" not found`)
|
|
@@ -265,8 +279,8 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
265
279
|
throw error
|
|
266
280
|
}
|
|
267
281
|
const cut = seamCtx.sessionProjections.snapshot(session)
|
|
268
|
-
const value = cut.values[
|
|
269
|
-
if (!value) return
|
|
282
|
+
const value = cut.values[key]
|
|
283
|
+
if (!value) return empty
|
|
270
284
|
return { enabled: configFor(sessionId).versioning, ...value }
|
|
271
285
|
}
|
|
272
286
|
|
|
@@ -367,6 +381,7 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
|
|
|
367
381
|
configFor,
|
|
368
382
|
setConfig,
|
|
369
383
|
snapshot,
|
|
384
|
+
snapshotForkmap,
|
|
370
385
|
readEvent,
|
|
371
386
|
readSurface,
|
|
372
387
|
agentOf,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.
|
|
4
|
+
"version": "0.4.5",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|