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
package/lib/revisions.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision relations between decisions.
|
|
3
|
+
*
|
|
4
|
+
* A decision does not expire when a newer one replaces it — it becomes
|
|
5
|
+
* historical. That is a separate axis from `until`, so it gets its own edge
|
|
6
|
+
* label and its own resolver instead of overloading `include_stale`.
|
|
7
|
+
*
|
|
8
|
+
* First version is a linear chain: one direct predecessor and one direct
|
|
9
|
+
* successor per node, confirmed by a human in the management UI. The edge is
|
|
10
|
+
* `successor --supersedes--> predecessor`, and it is never part of the default
|
|
11
|
+
* business-edge expansion, or every recall would drag the whole history along.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import { EDGE_LABELS, REVISION_EDGES, nowIso, weightOf } from "./schema.js";
|
|
16
|
+
import {
|
|
17
|
+
currentHeadOf,
|
|
18
|
+
isRevisionLinked,
|
|
19
|
+
persistAfterRevision,
|
|
20
|
+
predecessorOf,
|
|
21
|
+
revisionEdgeOf,
|
|
22
|
+
successorOf,
|
|
23
|
+
supersedesIn,
|
|
24
|
+
supersedesOut,
|
|
25
|
+
REVISION_WALK_LIMIT,
|
|
26
|
+
} from "./store.js";
|
|
27
|
+
import { isStalePayload } from "./until.js";
|
|
28
|
+
import { provenanceOf } from "./provenance.js";
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* The graph predicates live in store.js: the write guards there need them, and
|
|
32
|
+
* this module already imports from store.js, so defining them here would make
|
|
33
|
+
* the dependency circular. They are re-exported so callers have one import.
|
|
34
|
+
*/
|
|
35
|
+
export { currentHeadOf, isRevisionLinked, predecessorOf, successorOf, REVISION_WALK_LIMIT };
|
|
36
|
+
|
|
37
|
+
const SUPERSEDES = EDGE_LABELS.supersedes;
|
|
38
|
+
|
|
39
|
+
/** Reason text shown to the model when it tries to write a revision relation. */
|
|
40
|
+
export function refuseModelRevisionLabel(label) {
|
|
41
|
+
const value = String(label || "");
|
|
42
|
+
if (!REVISION_EDGES.includes(value)) return "";
|
|
43
|
+
return `Refused: "${value}" is confirmed by the user in the dsh-trivium management UI, not by a tool call. Ask the user to link the two decisions there.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function outgoingOf(db, id, label) {
|
|
47
|
+
const node = db.get(Number(id));
|
|
48
|
+
return (node?.edges || [])
|
|
49
|
+
.filter((e) => String(e.label) === label)
|
|
50
|
+
.map((e) => Number(e.targetId ?? e.target_id))
|
|
51
|
+
.filter((n) => Number.isFinite(n));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Revision state for one node.
|
|
56
|
+
*
|
|
57
|
+
* `current` and `superseded` are independent of `expired`: the UI shows them as
|
|
58
|
+
* two labels and the user filters them separately.
|
|
59
|
+
*/
|
|
60
|
+
export function revisionStateOf(db, id) {
|
|
61
|
+
const head = currentHeadOf(db, id);
|
|
62
|
+
if (!head.ok) {
|
|
63
|
+
return { ok: false, reason: head.reason, currentId: null, superseded: null, hops: head.hops };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
currentId: head.id,
|
|
68
|
+
superseded: head.id !== Number(id),
|
|
69
|
+
hops: head.hops,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolver for context selection.
|
|
75
|
+
*
|
|
76
|
+
* Returns `{currentId, archived, stale}` for the head of this node's chain, or
|
|
77
|
+
* null when the chain cannot be resolved — selection then drops the candidate
|
|
78
|
+
* rather than injecting a guess.
|
|
79
|
+
*/
|
|
80
|
+
export function makeRevisionResolver(db) {
|
|
81
|
+
return (id) => {
|
|
82
|
+
const state = revisionStateOf(db, id);
|
|
83
|
+
if (!state.ok) return null;
|
|
84
|
+
const payload = db.get(state.currentId)?.payload || {};
|
|
85
|
+
return {
|
|
86
|
+
currentId: state.currentId,
|
|
87
|
+
archived: payload.status === "archived",
|
|
88
|
+
stale: isStalePayload(payload),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Opaque entity tag over the content and the revision neighbours.
|
|
95
|
+
*
|
|
96
|
+
* Two browser windows that both edited through this value cannot silently
|
|
97
|
+
* overwrite each other: the second request re-reads inside the workspace queue
|
|
98
|
+
* and compares.
|
|
99
|
+
*/
|
|
100
|
+
export function etagOf(db, id) {
|
|
101
|
+
const node = db.get(Number(id));
|
|
102
|
+
if (!node) return "";
|
|
103
|
+
const payload = node.payload || {};
|
|
104
|
+
const parts = [
|
|
105
|
+
Number(id),
|
|
106
|
+
payload.type,
|
|
107
|
+
payload.name,
|
|
108
|
+
payload.text,
|
|
109
|
+
payload.until,
|
|
110
|
+
payload.untilAt,
|
|
111
|
+
payload.status,
|
|
112
|
+
successorOf(db, id) ?? "",
|
|
113
|
+
predecessorOf(db, id) ?? "",
|
|
114
|
+
];
|
|
115
|
+
return createHash("sha1").update(parts.map((v) => String(v ?? "")).join("\u0000")).digest("hex").slice(0, 16);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Expiry is its own axis: `expired`, `active` (has a deadline), or `none`. */export function expiryStateOf(payload, now) {
|
|
119
|
+
if (!payload?.until && !payload?.untilAt) return "none";
|
|
120
|
+
return isStalePayload(payload, now instanceof Date ? now : undefined) ? "expired" : "active";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `GET /nodes/:id/detail` payload.
|
|
125
|
+
*
|
|
126
|
+
* Full bodies, quotes and history stay behind their own endpoints; this is the
|
|
127
|
+
* small header the list and the detail pane need.
|
|
128
|
+
*/
|
|
129
|
+
export function nodeDetail(db, id) {
|
|
130
|
+
const node = db.get(Number(id));
|
|
131
|
+
if (!node) return null;
|
|
132
|
+
const payload = node.payload || {};
|
|
133
|
+
const revision = revisionStateOf(db, id);
|
|
134
|
+
const source = provenanceOf(db, id);
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
id: Number(id),
|
|
138
|
+
summary: {
|
|
139
|
+
type: payload.type || "unknown",
|
|
140
|
+
name: String(payload.name || ""),
|
|
141
|
+
l0: String(payload.text || payload.name || "").slice(0, 200),
|
|
142
|
+
status: payload.status || "active",
|
|
143
|
+
},
|
|
144
|
+
expiryState: expiryStateOf(payload),
|
|
145
|
+
// `unresolved` means the chain did not resolve; the UI must not claim a
|
|
146
|
+
// current version in that case.
|
|
147
|
+
revisionState: revision.ok ? (revision.superseded ? "superseded" : "current") : "unresolved",
|
|
148
|
+
revisionError: revision.ok ? "" : revision.reason,
|
|
149
|
+
currentId: revision.ok ? revision.currentId : null,
|
|
150
|
+
sourceAvailable: source?.status === "recorded",
|
|
151
|
+
sourceStatus: source?.status || "missing",
|
|
152
|
+
etag: etagOf(db, id),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `GET /nodes/:id/history` payload.
|
|
158
|
+
*
|
|
159
|
+
* Newest first, one page at a time, and short summaries only — the full body of
|
|
160
|
+
* an old version is fetched by reading that node. `relation` carries the reason
|
|
161
|
+
* and time recorded when the link was confirmed, which lives on the successor
|
|
162
|
+
* payload because 0.8.11 edges have no property bag.
|
|
163
|
+
*/
|
|
164
|
+
export function historyOf(db, id, { limit = 20, cursor = "" } = {}) {
|
|
165
|
+
const head = currentHeadOf(db, id);
|
|
166
|
+
if (!head.ok) {
|
|
167
|
+
return { ok: false, reason: head.reason, nodes: [], nextCursor: null, truncated: false };
|
|
168
|
+
}
|
|
169
|
+
const order = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
let current = head.id;
|
|
172
|
+
while (current != null && !seen.has(current) && order.length < REVISION_WALK_LIMIT) {
|
|
173
|
+
seen.add(current);
|
|
174
|
+
order.push(current);
|
|
175
|
+
current = predecessorOf(db, current);
|
|
176
|
+
}
|
|
177
|
+
const size = Math.max(1, Math.min(50, Number(limit) || 20));
|
|
178
|
+
const offset = Math.max(0, Number(cursor) || 0);
|
|
179
|
+
const page = order.slice(offset, offset + size);
|
|
180
|
+
const nodes = page.map((nodeId) => {
|
|
181
|
+
const payload = db.get(nodeId)?.payload || {};
|
|
182
|
+
const revision = payload.revision && typeof payload.revision === "object" ? payload.revision : null;
|
|
183
|
+
// Relation metadata lives on the supersedes edge (0.8.11 edges carry a
|
|
184
|
+
// metadata bag), so payload.revision is only a legacy fallback.
|
|
185
|
+
const edge = revisionEdgeOf(db, nodeId);
|
|
186
|
+
const meta = edge?.metadata && typeof edge.metadata === "object" ? edge.metadata : revision;
|
|
187
|
+
return {
|
|
188
|
+
id: nodeId,
|
|
189
|
+
type: payload.type || "unknown",
|
|
190
|
+
name: String(payload.name || ""),
|
|
191
|
+
l0: String(payload.text || payload.name || "").slice(0, 160),
|
|
192
|
+
status: payload.status || "active",
|
|
193
|
+
expiryState: expiryStateOf(payload),
|
|
194
|
+
isCurrent: nodeId === head.id,
|
|
195
|
+
isRequested: nodeId === Number(id),
|
|
196
|
+
relation: edge
|
|
197
|
+
? {
|
|
198
|
+
supersededBy: Number(edge.from),
|
|
199
|
+
reason: String(meta?.reason || ""),
|
|
200
|
+
at: String(meta?.at || ""),
|
|
201
|
+
}
|
|
202
|
+
: null,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
requestedId: Number(id),
|
|
208
|
+
currentId: head.id,
|
|
209
|
+
nodes,
|
|
210
|
+
nextCursor: offset + size < order.length ? String(offset + size) : null,
|
|
211
|
+
truncated: order.length >= REVISION_WALK_LIMIT,
|
|
212
|
+
total: order.length,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------------ writes */
|
|
217
|
+
|
|
218
|
+
/** One in-process queue per workspace: read → validate → commit never interleaves. */
|
|
219
|
+
const queues = new Map();
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Serialize a write against everything else touching this workspace.
|
|
223
|
+
*
|
|
224
|
+
* The validation inside reads the graph again, so a decision taken on a stale
|
|
225
|
+
* view can never be committed: the second caller either sees the new state and
|
|
226
|
+
* refuses, or matches the ETag it was given.
|
|
227
|
+
*/
|
|
228
|
+
export function withWorkspaceQueue(cwd, fn) {
|
|
229
|
+
const key = String(cwd || "");
|
|
230
|
+
const previous = queues.get(key) || Promise.resolve();
|
|
231
|
+
const run = previous.then(fn, fn);
|
|
232
|
+
queues.set(
|
|
233
|
+
key,
|
|
234
|
+
run.then(
|
|
235
|
+
() => undefined,
|
|
236
|
+
() => undefined,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
return run;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const REVISION_CODES = Object.freeze({
|
|
243
|
+
badInput: "bad-input",
|
|
244
|
+
notFound: "not-found",
|
|
245
|
+
self: "self",
|
|
246
|
+
nonDecision: "non-decision",
|
|
247
|
+
archived: "archived",
|
|
248
|
+
alreadySuperseded: "already-superseded",
|
|
249
|
+
successorHasPredecessor: "successor-has-predecessor",
|
|
250
|
+
cycle: "cycle",
|
|
251
|
+
tooLong: "too-long",
|
|
252
|
+
etag: "etag",
|
|
253
|
+
transactionFailed: "transaction-failed",
|
|
254
|
+
noRelation: "no-relation",
|
|
255
|
+
hasLaterVersion: "has-later-version",
|
|
256
|
+
revisionLinked: "REVISION_LINKED",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
function refuse(status, code, message, extra = {}) {
|
|
260
|
+
return { ok: false, status, code, message, ...extra };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function reasonText(value) {
|
|
264
|
+
return String(value ?? "")
|
|
265
|
+
.replace(/\s+/g, " ")
|
|
266
|
+
.trim()
|
|
267
|
+
.slice(0, 240);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Walk backwards (older) looking for a node, bounded by the hop limit. */
|
|
271
|
+
function ancestorsOf(db, id, limit = REVISION_WALK_LIMIT) {
|
|
272
|
+
const out = [];
|
|
273
|
+
let current = predecessorOf(db, id);
|
|
274
|
+
while (current != null && out.length < limit) {
|
|
275
|
+
out.push(current);
|
|
276
|
+
current = predecessorOf(db, current);
|
|
277
|
+
}
|
|
278
|
+
return { ids: out, overflow: current != null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Confirm `successorId --supersedes--> oldId`.
|
|
283
|
+
*
|
|
284
|
+
* Nothing is written until every check passed; the write itself is one native
|
|
285
|
+
* transaction, so a failure leaves both ends exactly as they were.
|
|
286
|
+
*/
|
|
287
|
+
export function supersede(db, input = {}) {
|
|
288
|
+
const oldId = Number(input.oldId);
|
|
289
|
+
const successorId = Number(input.successorId);
|
|
290
|
+
if (!Number.isFinite(oldId) || !Number.isFinite(successorId)) {
|
|
291
|
+
return refuse(400, REVISION_CODES.badInput, "需要两个节点 id。");
|
|
292
|
+
}
|
|
293
|
+
if (oldId === successorId) {
|
|
294
|
+
return refuse(409, REVISION_CODES.self, "不能把决策替代成它自己。");
|
|
295
|
+
}
|
|
296
|
+
const operationId = String(input.operationId || "").trim() || randomUUID();
|
|
297
|
+
|
|
298
|
+
// Idempotency first: a lost response is retried with the same operationId,
|
|
299
|
+
// and the edge metadata is the durable record that it already landed.
|
|
300
|
+
const existing = revisionEdgeOf(db, oldId);
|
|
301
|
+
if (existing && existing.metadata?.operationId === operationId) {
|
|
302
|
+
return { ok: true, alreadyApplied: true, oldId, successorId: existing.from, operationId };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const oldNode = db.get(oldId);
|
|
306
|
+
const newNode = db.get(successorId);
|
|
307
|
+
if (!oldNode || !newNode) return refuse(404, REVISION_CODES.notFound, "节点不存在。");
|
|
308
|
+
const oldPayload = oldNode.payload || {};
|
|
309
|
+
const newPayload = newNode.payload || {};
|
|
310
|
+
if (oldPayload.type !== "decision" || newPayload.type !== "decision") {
|
|
311
|
+
return refuse(409, REVISION_CODES.nonDecision, "只有两条决策之间可以建立替代关系。");
|
|
312
|
+
}
|
|
313
|
+
if (oldPayload.status === "archived" || newPayload.status === "archived") {
|
|
314
|
+
return refuse(409, REVISION_CODES.archived, "已归档的决策不能参与替代关系。");
|
|
315
|
+
}
|
|
316
|
+
if (existing && Number(existing.from) !== successorId) {
|
|
317
|
+
return refuse(409, REVISION_CODES.alreadySuperseded, `#${oldId} 已被 #${existing.from} 替代。`, {
|
|
318
|
+
currentId: Number(existing.from),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (existing && Number(existing.from) === successorId) {
|
|
322
|
+
// Same pair, different operationId: the relation already exists. Report it
|
|
323
|
+
// as applied rather than creating a parallel edge.
|
|
324
|
+
return { ok: true, alreadyApplied: true, oldId, successorId, operationId: existing.metadata?.operationId || "" };
|
|
325
|
+
}
|
|
326
|
+
const successorPredecessor = predecessorOf(db, successorId);
|
|
327
|
+
if (successorPredecessor != null) {
|
|
328
|
+
return refuse(
|
|
329
|
+
409,
|
|
330
|
+
REVISION_CODES.successorHasPredecessor,
|
|
331
|
+
`#${successorId} 已经替代了 #${successorPredecessor},首版只支持线性链。`,
|
|
332
|
+
{ predecessorId: successorPredecessor },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const forward = currentHeadOf(db, successorId);
|
|
336
|
+
if (!forward.ok) {
|
|
337
|
+
return refuse(409, forward.reason === "too-long" ? REVISION_CODES.tooLong : REVISION_CODES.cycle,
|
|
338
|
+
forward.reason === "too-long" ? `版本链超过 ${REVISION_WALK_LIMIT} 跳,拒绝给出不完整的当前版本。` : "版本链存在循环。");
|
|
339
|
+
}
|
|
340
|
+
if (Number(forward.id) === oldId) {
|
|
341
|
+
return refuse(409, REVISION_CODES.cycle, "这会形成循环替代。");
|
|
342
|
+
}
|
|
343
|
+
const ancestors = ancestorsOf(db, oldId);
|
|
344
|
+
if (ancestors.overflow) {
|
|
345
|
+
return refuse(409, REVISION_CODES.tooLong, `版本链超过 ${REVISION_WALK_LIMIT} 跳。`);
|
|
346
|
+
}
|
|
347
|
+
if (ancestors.ids.includes(successorId)) {
|
|
348
|
+
return refuse(409, REVISION_CODES.cycle, "这会形成循环替代:新决策已经是旧决策的前身。");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const want = input.expectedEtags || {};
|
|
352
|
+
const oldEtag = etagOf(db, oldId);
|
|
353
|
+
const newEtag = etagOf(db, successorId);
|
|
354
|
+
if (want.old !== undefined && String(want.old) !== oldEtag) {
|
|
355
|
+
return refuse(409, REVISION_CODES.etag, "旧决策已在别处变更,请刷新后再确认。", { etag: { old: oldEtag, new: newEtag } });
|
|
356
|
+
}
|
|
357
|
+
if (want.new !== undefined && String(want.new) !== newEtag) {
|
|
358
|
+
return refuse(409, REVISION_CODES.etag, "新决策已在别处变更,请刷新后再确认。", { etag: { old: oldEtag, new: newEtag } });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const at = nowIso();
|
|
362
|
+
const metadata = { reason: reasonText(input.reason), at, operationId };
|
|
363
|
+
try {
|
|
364
|
+
db.commitTransaction([
|
|
365
|
+
{
|
|
366
|
+
type: "upsertEdge",
|
|
367
|
+
src: successorId,
|
|
368
|
+
dst: oldId,
|
|
369
|
+
label: SUPERSEDES,
|
|
370
|
+
weight: weightOf(SUPERSEDES),
|
|
371
|
+
metadata,
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
// Native transaction failed: both ends untouched.
|
|
376
|
+
return refuse(409, REVISION_CODES.transactionFailed, `关系写入失败,两端保持原状:${err.message}`);
|
|
377
|
+
}
|
|
378
|
+
persistAfterRevision(db);
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
oldId,
|
|
382
|
+
successorId,
|
|
383
|
+
operationId,
|
|
384
|
+
at,
|
|
385
|
+
reason: metadata.reason,
|
|
386
|
+
etag: { old: etagOf(db, oldId), new: etagOf(db, successorId) },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Revoke the most recent supersession.
|
|
392
|
+
*
|
|
393
|
+
* Only the named edge is removed — other edges, including business edges
|
|
394
|
+
* between the same two nodes, survive. A successor that has itself been
|
|
395
|
+
* superseded is refused, because that chain would be split in the wrong place.
|
|
396
|
+
*/
|
|
397
|
+
export function revokeSupersession(db, input = {}) {
|
|
398
|
+
const oldId = Number(input.oldId);
|
|
399
|
+
const successorId = Number(input.successorId);
|
|
400
|
+
if (!Number.isFinite(oldId) || !Number.isFinite(successorId)) {
|
|
401
|
+
return refuse(400, REVISION_CODES.badInput, "需要两个节点 id。");
|
|
402
|
+
}
|
|
403
|
+
const existing = revisionEdgeOf(db, oldId);
|
|
404
|
+
if (!existing || Number(existing.from) !== successorId) {
|
|
405
|
+
return refuse(404, REVISION_CODES.noRelation, "没有找到这条替代关系。");
|
|
406
|
+
}
|
|
407
|
+
const later = successorOf(db, successorId);
|
|
408
|
+
if (later != null) {
|
|
409
|
+
return refuse(409, REVISION_CODES.hasLaterVersion,
|
|
410
|
+
`#${successorId} 之后已有 #${later},先处理更新的版本再撤销。`, { laterId: later });
|
|
411
|
+
}
|
|
412
|
+
const want = input.expectedEtags || {};
|
|
413
|
+
const oldEtag = etagOf(db, oldId);
|
|
414
|
+
const newEtag = etagOf(db, successorId);
|
|
415
|
+
if (want.old !== undefined && String(want.old) !== oldEtag) {
|
|
416
|
+
return refuse(409, REVISION_CODES.etag, "旧决策已在别处变更,请刷新后再撤销。", { etag: { old: oldEtag, new: newEtag } });
|
|
417
|
+
}
|
|
418
|
+
if (want.new !== undefined && String(want.new) !== newEtag) {
|
|
419
|
+
return refuse(409, REVISION_CODES.etag, "新决策已在别处变更,请刷新后再撤销。", { etag: { old: oldEtag, new: newEtag } });
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
db.commitTransaction([
|
|
423
|
+
{ type: "unlinkLabel", src: successorId, dst: oldId, label: SUPERSEDES },
|
|
424
|
+
]);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return refuse(409, REVISION_CODES.transactionFailed, `撤销失败,关系仍在:${err.message}`);
|
|
427
|
+
}
|
|
428
|
+
persistAfterRevision(db);
|
|
429
|
+
return { ok: true, oldId, successorId, etag: { old: etagOf(db, oldId), new: etagOf(db, successorId) } };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Human-readable refusal for the UI, without leaking workspace paths. */
|
|
433
|
+
export function revisionMessage(code) {
|
|
434
|
+
const map = {
|
|
435
|
+
[REVISION_CODES.badInput]: "输入不完整。",
|
|
436
|
+
[REVISION_CODES.notFound]: "节点不存在。",
|
|
437
|
+
[REVISION_CODES.self]: "不能把决策替代成它自己。",
|
|
438
|
+
[REVISION_CODES.nonDecision]: "只有决策之间可以建立替代关系。",
|
|
439
|
+
[REVISION_CODES.archived]: "已归档的决策不能参与替代关系。",
|
|
440
|
+
[REVISION_CODES.alreadySuperseded]: "这条决策已经被替代。",
|
|
441
|
+
[REVISION_CODES.successorHasPredecessor]: "首版只支持线性链。",
|
|
442
|
+
[REVISION_CODES.cycle]: "会形成循环替代。",
|
|
443
|
+
[REVISION_CODES.tooLong]: "版本链过长。",
|
|
444
|
+
[REVISION_CODES.etag]: "内容已在别处变更,请刷新。",
|
|
445
|
+
[REVISION_CODES.transactionFailed]: "写入失败,两端保持原状。",
|
|
446
|
+
[REVISION_CODES.noRelation]: "没有这条替代关系。",
|
|
447
|
+
[REVISION_CODES.hasLaterVersion]: "后面还有更新的版本。",
|
|
448
|
+
[REVISION_CODES.revisionLinked]: "节点在替代链上,请先处理关系。",
|
|
449
|
+
};
|
|
450
|
+
return map[code] || "操作未完成。";
|
|
451
|
+
}
|
package/lib/schema.js
CHANGED
|
@@ -21,10 +21,21 @@ export const EDGE_LABELS = Object.freeze({
|
|
|
21
21
|
fromSession: "from_session",
|
|
22
22
|
continues: "continues",
|
|
23
23
|
forksFrom: "forks_from",
|
|
24
|
+
supersedes: "supersedes",
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
export const BUSINESS_EDGES = Object.freeze(["about", "decided", "broke", "fixed"]);
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Revision relations are deliberately NOT in BUSINESS_EDGES: adding them would
|
|
31
|
+
* pull an entire decision history into every recall through neighbor expansion.
|
|
32
|
+
* They are read only through the revision resolver and the history API.
|
|
33
|
+
*/
|
|
34
|
+
export const REVISION_EDGES = Object.freeze(["supersedes"]);
|
|
35
|
+
|
|
36
|
+
/** Hard cap on a revision chain walk before the answer is "unresolved". */
|
|
37
|
+
export const REVISION_WALK_LIMIT = 32;
|
|
38
|
+
|
|
28
39
|
/** Weights written on link(). Engine neighbors / expandLabels can whitelist labels; we still persist weights. */
|
|
29
40
|
export const EDGE_WEIGHTS = Object.freeze({
|
|
30
41
|
about: 1,
|
|
@@ -36,6 +47,7 @@ export const EDGE_WEIGHTS = Object.freeze({
|
|
|
36
47
|
from_session: 0.1,
|
|
37
48
|
continues: 0.4,
|
|
38
49
|
forks_from: 0.6,
|
|
50
|
+
supersedes: 0.8,
|
|
39
51
|
});
|
|
40
52
|
|
|
41
53
|
export function weightOf(label) {
|
|
@@ -64,7 +76,7 @@ export function looksSecret(text) {
|
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
export function indexTextFor(payload) {
|
|
67
|
-
const parts = [payload.type, payload.name, payload.text, payload.uri, payload.fail, payload.fix, payload.until]
|
|
79
|
+
const parts = [payload.type, payload.name, ...(Array.isArray(payload.aliases) ? payload.aliases : []), payload.text, payload.uri, payload.fail, payload.fix, payload.until]
|
|
68
80
|
.filter(Boolean)
|
|
69
81
|
.map(String);
|
|
70
82
|
return parts.join(" ");
|