dsh-trivium 0.4.21 → 0.5.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/README.md +49 -6
- package/README.zh-CN.md +49 -6
- package/lib/client.js +948 -9
- package/lib/context-budget.js +348 -0
- package/lib/extract.js +25 -11
- package/lib/index.js +236 -63
- package/lib/markdown.js +0 -2
- package/lib/pins.js +66 -21
- package/lib/provenance.js +147 -0
- package/lib/recall-preview.js +128 -0
- package/lib/recall-trace.js +285 -0
- package/lib/revision-graph.js +103 -0
- package/lib/revisions.js +451 -0
- package/lib/schema.js +13 -1
- package/lib/server.js +204 -4
- package/lib/sidecar.js +122 -15
- package/lib/store.js +480 -62
- package/lib/tool-defs.js +93 -0
- package/lib/tool-output.js +88 -0
- package/lib/tools.js +69 -43
- package/lib/turn-ledger.js +184 -0
- package/lib/until.js +1 -13
- package/package.json +10 -4
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only recall preview for the management UI.
|
|
3
|
+
*
|
|
4
|
+
* Answers "if this turn were injected now, what would be sent and why" without
|
|
5
|
+
* writing to the database, running a tool, or asking for an embedding. With no
|
|
6
|
+
* query vector the semantic channel is reported as `not_run` — it is never
|
|
7
|
+
* simulated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { formatHit, namedRecallHits, searchNodes } from "./store.js";
|
|
11
|
+
import { pinCandidates } from "./pins.js";
|
|
12
|
+
import { makeRevisionResolver } from "./revisions.js";
|
|
13
|
+
import {
|
|
14
|
+
renderContextRecords,
|
|
15
|
+
selectContextRecords,
|
|
16
|
+
TURN_ADDITIONAL_BUDGET,
|
|
17
|
+
} from "./context-budget.js";
|
|
18
|
+
import { startTrace } from "./recall-trace.js";
|
|
19
|
+
|
|
20
|
+
/** A preview query is a probe, not a document. */
|
|
21
|
+
export const PREVIEW_QUERY_MAX = 500;
|
|
22
|
+
|
|
23
|
+
const FILTER_BY_DROP = {
|
|
24
|
+
"over-budget": "budget",
|
|
25
|
+
"duplicate-node": "duplicate",
|
|
26
|
+
"duplicate-in-turn": "duplicate",
|
|
27
|
+
"already-in-map": "duplicate",
|
|
28
|
+
stale: "stale",
|
|
29
|
+
archived: "archived",
|
|
30
|
+
dirty: "hygiene",
|
|
31
|
+
"no-current": "revision",
|
|
32
|
+
empty: "hygiene",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function candidateOf(db, hit) {
|
|
36
|
+
const row = formatHit(db, hit);
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
type: row.type,
|
|
40
|
+
l0: row.l0,
|
|
41
|
+
until: row.until,
|
|
42
|
+
stale: row.stale,
|
|
43
|
+
path: row.path,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function summaryNodes(db, ids) {
|
|
48
|
+
return (ids || []).map((raw) => {
|
|
49
|
+
const id = Number(raw);
|
|
50
|
+
const payload = db.get(id)?.payload || {};
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
type: payload.type || "unknown",
|
|
54
|
+
l0: String(payload.text || payload.name || "").slice(0, 80),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @returns `{ok:false, status, message}` for bad input, otherwise the plan, the
|
|
61
|
+
* real filter reasons and the exact text that would be sent.
|
|
62
|
+
*/
|
|
63
|
+
export function recallPreview(
|
|
64
|
+
db,
|
|
65
|
+
{ cwd = "", sessionId = "", query = "", mode = "anchor", budget = TURN_ADDITIONAL_BUDGET } = {},
|
|
66
|
+
) {
|
|
67
|
+
const text = String(query ?? "").trim();
|
|
68
|
+
if (text.length > PREVIEW_QUERY_MAX) {
|
|
69
|
+
return { ok: false, status: 400, message: `query must be <= ${PREVIEW_QUERY_MAX} characters` };
|
|
70
|
+
}
|
|
71
|
+
const trace = startTrace({ cwd, sessionId, query: text, mode, semantic: "not_run", source: "preview" });
|
|
72
|
+
const onStage = (stage, ids) => trace.stage(stage, summaryNodes(db, ids));
|
|
73
|
+
|
|
74
|
+
let hits = [];
|
|
75
|
+
if (text) {
|
|
76
|
+
hits =
|
|
77
|
+
mode === "anchor"
|
|
78
|
+
? namedRecallHits(db, text, { topK: 8, onStage }).slice(0, 8)
|
|
79
|
+
: searchNodes(db, text, { topK: 3, expandDepth: 1, includeStale: false, onStage }).slice(0, 3);
|
|
80
|
+
}
|
|
81
|
+
const resolveCurrent = makeRevisionResolver(db);
|
|
82
|
+
const pins = sessionId ? pinCandidates(db, sessionId, { resolveRevision: resolveCurrent }) : [];
|
|
83
|
+
const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
|
|
84
|
+
const plan = selectContextRecords({
|
|
85
|
+
pins,
|
|
86
|
+
recall: hits.map((hit) => candidateOf(db, hit)),
|
|
87
|
+
budget: Number(budget) || TURN_ADDITIONAL_BUDGET,
|
|
88
|
+
form,
|
|
89
|
+
resolveCurrent,
|
|
90
|
+
});
|
|
91
|
+
for (const [dropReason, filterReason] of Object.entries(FILTER_BY_DROP)) {
|
|
92
|
+
const ids = plan.dropped.filter((d) => d.reason === dropReason).map((d) => d.id);
|
|
93
|
+
if (ids.length) trace.filter(filterReason, summaryNodes(db, ids));
|
|
94
|
+
}
|
|
95
|
+
trace.selected({ ids: plan.records.map((r) => r.id), budget: plan.budget, tokens: plan.tokens, deferred: plan.deferred });
|
|
96
|
+
const recorded = trace.finish({ semantic: "not_run" });
|
|
97
|
+
const rendered = renderContextRecords(plan, { form });
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
query: text,
|
|
102
|
+
mode,
|
|
103
|
+
// Honest label: the preview never requested a query vector.
|
|
104
|
+
semantic: "not_run",
|
|
105
|
+
traceId: recorded?.traceId || "",
|
|
106
|
+
budget: plan.budget,
|
|
107
|
+
tokens: plan.tokens,
|
|
108
|
+
estimatedTokens: plan.tokens,
|
|
109
|
+
candidates: hits.map((hit) => candidateOf(db, hit)),
|
|
110
|
+
records: plan.records.map((r) => ({
|
|
111
|
+
id: r.id,
|
|
112
|
+
channel: r.channel,
|
|
113
|
+
channels: r.channels,
|
|
114
|
+
type: r.type,
|
|
115
|
+
l0: r.l0,
|
|
116
|
+
historical: r.historical === true,
|
|
117
|
+
matchedHistory: r.matchedHistory || null,
|
|
118
|
+
tokens: 0,
|
|
119
|
+
})),
|
|
120
|
+
dropped: plan.dropped,
|
|
121
|
+
deferred: plan.deferred,
|
|
122
|
+
text: {
|
|
123
|
+
pins: rendered.pins.text,
|
|
124
|
+
recall: rendered.recall.text,
|
|
125
|
+
total: rendered.total,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall traces: why did this turn get these memories?
|
|
3
|
+
*
|
|
4
|
+
* Only stages the plugin actually walked are recorded. 0.8.11 does not give a
|
|
5
|
+
* per-hit BM25/vector breakdown, so nothing here invents contribution
|
|
6
|
+
* percentages or calls a fused score a confidence. The trace says "native
|
|
7
|
+
* hybrid returned candidates" and leaves it at that.
|
|
8
|
+
*
|
|
9
|
+
* Retention is deliberately small and in-memory: 20 traces per
|
|
10
|
+
* workspace+session, 10 minute TTL, 64 KiB per trace, 100 node summaries per
|
|
11
|
+
* stage. When a cap cuts data the trace says `truncated` instead of pretending
|
|
12
|
+
* the list was complete.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TRACE_LIMIT = 20;
|
|
16
|
+
export const TRACE_TTL_MS = 10 * 60 * 1000;
|
|
17
|
+
export const TRACE_MAX_BYTES = 64 * 1024;
|
|
18
|
+
export const TRACE_STAGE_NODE_LIMIT = 100;
|
|
19
|
+
|
|
20
|
+
export const TRACE_STAGES = Object.freeze({
|
|
21
|
+
nativeHybrid: "native-hybrid",
|
|
22
|
+
keywordFallback: "keyword-fallback",
|
|
23
|
+
entityAnchor: "entity-anchor",
|
|
24
|
+
businessExpand: "business-expand",
|
|
25
|
+
sameAsNormalize: "same-as-normalize",
|
|
26
|
+
revisionNormalize: "revision-normalize",
|
|
27
|
+
deadlineScan: "deadline-scan",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const FILTER_REASONS = Object.freeze({
|
|
31
|
+
stale: "stale",
|
|
32
|
+
archived: "archived",
|
|
33
|
+
hygiene: "hygiene",
|
|
34
|
+
budget: "budget",
|
|
35
|
+
duplicate: "duplicate",
|
|
36
|
+
revision: "revision",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** traceId -> trace; insertion order is used as the age order. */
|
|
40
|
+
const traces = new Map();
|
|
41
|
+
const byBucket = new Map();
|
|
42
|
+
let traceSeq = 0;
|
|
43
|
+
|
|
44
|
+
function bucketKey(cwd, sessionId) {
|
|
45
|
+
return `${cwd || ""}\u0000${sessionId || ""}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pruneExpired(now = Date.now()) {
|
|
49
|
+
for (const [id, trace] of traces) {
|
|
50
|
+
if (now - trace.atMs > TRACE_TTL_MS) {
|
|
51
|
+
traces.delete(id);
|
|
52
|
+
const bucket = byBucket.get(trace.bucket);
|
|
53
|
+
if (bucket) {
|
|
54
|
+
bucket.delete(id);
|
|
55
|
+
if (!bucket.size) byBucket.delete(trace.bucket);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function byteLength(value) {
|
|
62
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shorten(value, max) {
|
|
66
|
+
const s = String(value ?? "");
|
|
67
|
+
return s.length > max ? `${s.slice(0, Math.max(0, max - 1))}…` : s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Keep a stage's node list inside the per-stage cap. */
|
|
71
|
+
function capNodes(nodes) {
|
|
72
|
+
const list = Array.isArray(nodes) ? nodes : [];
|
|
73
|
+
const mapped = list.slice(0, TRACE_STAGE_NODE_LIMIT).map((n) => ({
|
|
74
|
+
id: Number(n.id ?? n),
|
|
75
|
+
type: shorten(n.type ?? "", 24),
|
|
76
|
+
l0: shorten(n.l0 ?? "", 80),
|
|
77
|
+
}));
|
|
78
|
+
return { nodes: mapped, truncated: list.length > TRACE_STAGE_NODE_LIMIT, total: list.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Collect one recall decision. Call `finish()` once; it returns the stored trace
|
|
83
|
+
* or null when there was nothing worth keeping.
|
|
84
|
+
*/
|
|
85
|
+
export function startTrace({ cwd = "", sessionId = "", query = "", mode = "auto", semantic = "not_run", source = "pre-step" } = {}) {
|
|
86
|
+
const stages = [];
|
|
87
|
+
const filters = [];
|
|
88
|
+
let selection = { ids: [], budget: 0, tokens: 0, deferred: [] };
|
|
89
|
+
let done = false;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
/** Record that a pipeline branch produced candidates. */
|
|
93
|
+
stage(name, nodes) {
|
|
94
|
+
if (!name) return;
|
|
95
|
+
const existing = stages.find((s) => s.stage === name);
|
|
96
|
+
const capped = capNodes(nodes);
|
|
97
|
+
if (existing) {
|
|
98
|
+
const merged = [...existing.nodes, ...capped.nodes].slice(0, TRACE_STAGE_NODE_LIMIT);
|
|
99
|
+
existing.truncated = existing.truncated || capped.truncated || merged.length < existing.nodes.length + capped.nodes.length;
|
|
100
|
+
existing.nodes = merged;
|
|
101
|
+
existing.candidates = existing.nodes.length;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
stages.push({
|
|
105
|
+
stage: shorten(name, 40),
|
|
106
|
+
candidates: capped.nodes.length,
|
|
107
|
+
nodes: capped.nodes,
|
|
108
|
+
truncated: capped.truncated,
|
|
109
|
+
total: capped.total,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** Record nodes that were candidates and then excluded, with a real reason. */
|
|
114
|
+
filter(reason, nodes) {
|
|
115
|
+
if (!reason) return;
|
|
116
|
+
const capped = capNodes(nodes);
|
|
117
|
+
if (!capped.nodes.length) return;
|
|
118
|
+
const existing = filters.find((f) => f.reason === reason);
|
|
119
|
+
if (existing) {
|
|
120
|
+
existing.nodes = [...existing.nodes, ...capped.nodes].slice(0, TRACE_STAGE_NODE_LIMIT);
|
|
121
|
+
existing.truncated = existing.truncated || capped.truncated || existing.nodes.length < existing.total + capped.total;
|
|
122
|
+
existing.total += capped.total;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
filters.push({
|
|
126
|
+
reason: shorten(reason, 40),
|
|
127
|
+
nodes: capped.nodes,
|
|
128
|
+
truncated: capped.truncated,
|
|
129
|
+
total: capped.total,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/** Record the final selection, including budget and deferrals. */
|
|
134
|
+
selected({ ids = [], budget = 0, tokens = 0, deferred = [] } = {}) {
|
|
135
|
+
selection = { ids: [...ids], budget: Number(budget) || 0, tokens: Number(tokens) || 0, deferred: [...deferred] };
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Store the trace. `semantic` may be settled only after the search ran, so
|
|
140
|
+
* finish() accepts the final answer instead of trusting the constructor.
|
|
141
|
+
*/
|
|
142
|
+
finish({ semantic: finalSemantic = semantic } = {}) {
|
|
143
|
+
if (done) return null;
|
|
144
|
+
done = true;
|
|
145
|
+
return recordTrace({
|
|
146
|
+
cwd,
|
|
147
|
+
sessionId,
|
|
148
|
+
query,
|
|
149
|
+
mode,
|
|
150
|
+
semantic: finalSemantic,
|
|
151
|
+
source,
|
|
152
|
+
stages,
|
|
153
|
+
filters,
|
|
154
|
+
selection,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function recordTrace({ cwd, sessionId, query, mode, semantic, source, stages, filters, selection }) {
|
|
161
|
+
pruneExpired();
|
|
162
|
+
const bucket = bucketKey(cwd, sessionId);
|
|
163
|
+
traceSeq += 1;
|
|
164
|
+
const trace = {
|
|
165
|
+
traceId: `t${Date.now().toString(36)}-${traceSeq.toString(36)}`,
|
|
166
|
+
// Ties inside one millisecond are broken by insertion order, so "newest
|
|
167
|
+
// first" stays deterministic even for a burst.
|
|
168
|
+
seq: traceSeq,
|
|
169
|
+
atMs: Date.now(),
|
|
170
|
+
at: new Date().toISOString(),
|
|
171
|
+
bucket,
|
|
172
|
+
cwd: String(cwd || ""),
|
|
173
|
+
sessionId: String(sessionId || ""),
|
|
174
|
+
source: shorten(source, 24),
|
|
175
|
+
mode: shorten(mode, 24),
|
|
176
|
+
// No query vector means the semantic channel did not run; never imply it did.
|
|
177
|
+
semantic: semantic === "ran" ? "ran" : "not_run",
|
|
178
|
+
query: shorten(query, 500),
|
|
179
|
+
stages,
|
|
180
|
+
filters,
|
|
181
|
+
selected: selection.ids,
|
|
182
|
+
budget: selection.budget,
|
|
183
|
+
tokens: selection.tokens,
|
|
184
|
+
deferred: selection.deferred,
|
|
185
|
+
truncated: stages.some((s) => s.truncated) || filters.some((f) => f.truncated),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Shrink to the byte cap rather than dropping the trace: the reason codes are
|
|
189
|
+
// the point, so drop the longest node lists first.
|
|
190
|
+
let guard = 0;
|
|
191
|
+
while (byteLength(trace) > TRACE_MAX_BYTES && guard < 20) {
|
|
192
|
+
guard += 1;
|
|
193
|
+
const stage = [...trace.stages].sort((a, b) => b.nodes.length - a.nodes.length)[0];
|
|
194
|
+
const filter = [...trace.filters].sort((a, b) => b.nodes.length - a.nodes.length)[0];
|
|
195
|
+
if (stage && stage.nodes.length > (filter?.nodes.length || 0)) {
|
|
196
|
+
stage.nodes = stage.nodes.slice(0, Math.max(0, stage.nodes.length - 10));
|
|
197
|
+
stage.candidates = stage.nodes.length;
|
|
198
|
+
stage.truncated = true;
|
|
199
|
+
} else if (filter && filter.nodes.length) {
|
|
200
|
+
filter.nodes = filter.nodes.slice(0, Math.max(0, filter.nodes.length - 10));
|
|
201
|
+
filter.truncated = true;
|
|
202
|
+
} else {
|
|
203
|
+
trace.query = shorten(trace.query, 80);
|
|
204
|
+
trace.truncated = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
trace.bytes = byteLength(trace);
|
|
208
|
+
|
|
209
|
+
traces.set(trace.traceId, trace);
|
|
210
|
+
const bucketSet = byBucket.get(bucket) || new Set();
|
|
211
|
+
bucketSet.add(trace.traceId);
|
|
212
|
+
byBucket.set(bucket, bucketSet);
|
|
213
|
+
while (bucketSet.size > TRACE_LIMIT) {
|
|
214
|
+
const oldest = bucketSet.values().next().value;
|
|
215
|
+
bucketSet.delete(oldest);
|
|
216
|
+
traces.delete(oldest);
|
|
217
|
+
}
|
|
218
|
+
return trace;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Newest first, scoped to one workspace+session. */
|
|
222
|
+
export function listTraces({ cwd = "", sessionId = "", limit = TRACE_LIMIT } = {}) {
|
|
223
|
+
pruneExpired();
|
|
224
|
+
const ids = byBucket.get(bucketKey(cwd, sessionId));
|
|
225
|
+
if (!ids) return [];
|
|
226
|
+
const rows = [...ids]
|
|
227
|
+
.map((id) => traces.get(id))
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.sort((a, b) => b.atMs - a.atMs || b.seq - a.seq)
|
|
230
|
+
.slice(0, Math.max(1, Math.min(TRACE_LIMIT, Number(limit) || TRACE_LIMIT)));
|
|
231
|
+
return rows.map(summaryOf);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every trace for one workspace, newest first.
|
|
236
|
+
*
|
|
237
|
+
* Session isolation still holds: this only widens the query to "all sessions of
|
|
238
|
+
* the same workspace", which is the one boundary the settings panel can name.
|
|
239
|
+
*/
|
|
240
|
+
export function listTracesForCwd({ cwd = "", limit = TRACE_LIMIT } = {}) {
|
|
241
|
+
pruneExpired();
|
|
242
|
+
const prefix = `${cwd || ""}\u0000`;
|
|
243
|
+
const rows = [...traces.values()]
|
|
244
|
+
.filter((trace) => trace.bucket.startsWith(prefix))
|
|
245
|
+
.sort((a, b) => b.atMs - a.atMs || b.seq - a.seq)
|
|
246
|
+
.slice(0, Math.max(1, Math.min(TRACE_LIMIT, Number(limit) || TRACE_LIMIT)));
|
|
247
|
+
return rows.map(summaryOf);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fetch one trace. A trace from another workspace or session is reported as
|
|
252
|
+
* missing, never returned — isolation does not depend on the caller's cwd.
|
|
253
|
+
*/
|
|
254
|
+
export function getTrace(traceId, { cwd = "", sessionId = "" } = {}) {
|
|
255
|
+
pruneExpired();
|
|
256
|
+
const trace = traces.get(String(traceId || ""));
|
|
257
|
+
if (!trace) return { ok: false, status: 410, message: "trace expired or unknown" };
|
|
258
|
+
if (trace.bucket !== bucketKey(cwd, sessionId)) {
|
|
259
|
+
return { ok: false, status: 404, message: "not found" };
|
|
260
|
+
}
|
|
261
|
+
return { ok: true, trace };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function summaryOf(trace) {
|
|
265
|
+
return {
|
|
266
|
+
traceId: trace.traceId,
|
|
267
|
+
at: trace.at,
|
|
268
|
+
sessionId: trace.sessionId,
|
|
269
|
+
source: trace.source,
|
|
270
|
+
mode: trace.mode,
|
|
271
|
+
semantic: trace.semantic,
|
|
272
|
+
query: trace.query,
|
|
273
|
+
stages: trace.stages.map((s) => ({ stage: s.stage, candidates: s.candidates, truncated: s.truncated })),
|
|
274
|
+
filters: trace.filters.map((f) => ({ reason: f.reason, total: f.total, truncated: f.truncated })),
|
|
275
|
+
selected: trace.selected,
|
|
276
|
+
budget: trace.budget,
|
|
277
|
+
tokens: trace.tokens,
|
|
278
|
+
truncated: trace.truncated,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function resetTraces() {
|
|
283
|
+
traces.clear();
|
|
284
|
+
byBucket.clear();
|
|
285
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural checks for a set of supersedes edges.
|
|
3
|
+
*
|
|
4
|
+
* Pure: it never touches the database, so an import can validate a whole chain
|
|
5
|
+
* before a single node is written. The ids are whatever the caller works with —
|
|
6
|
+
* exported ids, remapped ids, or live ids.
|
|
7
|
+
*
|
|
8
|
+
* The first version is a linear chain: at most one direct successor and one
|
|
9
|
+
* direct predecessor per node, no self loops, no cycles, and a bounded walk.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { REVISION_WALK_LIMIT } from "./schema.js";
|
|
13
|
+
|
|
14
|
+
export const REVISION_CONFLICTS = Object.freeze({
|
|
15
|
+
invalidEdge: "invalid-edge",
|
|
16
|
+
self: "self",
|
|
17
|
+
missingNode: "missing-node",
|
|
18
|
+
nonDecision: "non-decision",
|
|
19
|
+
archived: "archived",
|
|
20
|
+
forkSuccessor: "fork-successor",
|
|
21
|
+
forkPredecessor: "fork-predecessor",
|
|
22
|
+
cycle: "cycle",
|
|
23
|
+
tooLong: "too-long",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param edges - `{from, to}` where `from` supersedes `to`.
|
|
28
|
+
* @param payloadOf - `(id) => payload | null`; null means the node is absent.
|
|
29
|
+
* @returns `{ok, conflicts}` — an empty conflict list is the only pass.
|
|
30
|
+
*/
|
|
31
|
+
export function validateRevisionStructure({
|
|
32
|
+
edges = [],
|
|
33
|
+
payloadOf = () => null,
|
|
34
|
+
limit = REVISION_WALK_LIMIT,
|
|
35
|
+
} = {}) {
|
|
36
|
+
const conflicts = [];
|
|
37
|
+
const successor = new Map();
|
|
38
|
+
const predecessors = new Map();
|
|
39
|
+
const seenEdges = new Set();
|
|
40
|
+
|
|
41
|
+
for (const edge of edges) {
|
|
42
|
+
const from = Number(edge?.from);
|
|
43
|
+
const to = Number(edge?.to);
|
|
44
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
45
|
+
conflicts.push({ code: REVISION_CONFLICTS.invalidEdge });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (from === to) {
|
|
49
|
+
conflicts.push({ code: REVISION_CONFLICTS.self, id: from });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = `${from}\u0000${to}`;
|
|
53
|
+
if (seenEdges.has(key)) continue;
|
|
54
|
+
seenEdges.add(key);
|
|
55
|
+
const fromPayload = payloadOf(from);
|
|
56
|
+
const toPayload = payloadOf(to);
|
|
57
|
+
if (!fromPayload || !toPayload) {
|
|
58
|
+
conflicts.push({ code: REVISION_CONFLICTS.missingNode, id: fromPayload ? to : from });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (fromPayload.type !== "decision" || toPayload.type !== "decision") {
|
|
62
|
+
conflicts.push({ code: REVISION_CONFLICTS.nonDecision, id: fromPayload.type !== "decision" ? from : to });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (fromPayload.status === "archived" || toPayload.status === "archived") {
|
|
66
|
+
conflicts.push({ code: REVISION_CONFLICTS.archived, id: fromPayload.status === "archived" ? from : to });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (successor.has(from) && successor.get(from) !== to) {
|
|
70
|
+
conflicts.push({ code: REVISION_CONFLICTS.forkSuccessor, id: from });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
successor.set(from, to);
|
|
74
|
+
const list = predecessors.get(to) || [];
|
|
75
|
+
if (!list.includes(from)) list.push(from);
|
|
76
|
+
predecessors.set(to, list);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [id, list] of predecessors) {
|
|
80
|
+
if (list.length > 1) conflicts.push({ code: REVISION_CONFLICTS.forkPredecessor, id });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const start of successor.keys()) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
let current = start;
|
|
86
|
+
let hops = 0;
|
|
87
|
+
while (successor.has(current)) {
|
|
88
|
+
if (seen.has(current)) {
|
|
89
|
+
conflicts.push({ code: REVISION_CONFLICTS.cycle, id: start });
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
seen.add(current);
|
|
93
|
+
current = successor.get(current);
|
|
94
|
+
hops += 1;
|
|
95
|
+
if (hops > limit) {
|
|
96
|
+
conflicts.push({ code: REVISION_CONFLICTS.tooLong, id: start });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: conflicts.length === 0, conflicts };
|
|
103
|
+
}
|