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/server.js
CHANGED
|
@@ -19,11 +19,25 @@ import {
|
|
|
19
19
|
} from "./store.js";
|
|
20
20
|
import { embeddingPublicStatus } from "./embed.js";
|
|
21
21
|
import { chipsEnabledOf, configPanelOpenOf, gitSidecarEnabledOf, listedWorkspaces, pluginLocaleOf, recallModeOf, sessionLayerEnabledOf, writeUiSettings } from "./settings.js";
|
|
22
|
+
import { TURN_ADDITIONAL_BUDGET } from "./context-budget.js";
|
|
22
23
|
import { discoverWorkbuddyFiles, discoverExternalFiles, exportMarkdown, importWorkbuddy, importExternal } from "./markdown.js";
|
|
23
24
|
import { checkNpmUpdate, installedVersion } from "./update.js";
|
|
24
25
|
import { writeGateMessage } from "./hygiene.js";
|
|
25
26
|
import { backfillSessionMap, cutSessionMap, renameEpisode, sessionMapSnapshot } from "./episode.js";
|
|
26
27
|
import { applyPinsUpdate, addChipsFromText, batchChipAction, listChips, readSessionPins } from "./pins.js";
|
|
28
|
+
import {
|
|
29
|
+
historyOf,
|
|
30
|
+
nodeDetail,
|
|
31
|
+
REVISION_CODES,
|
|
32
|
+
revisionMessage,
|
|
33
|
+
revokeSupersession,
|
|
34
|
+
supersede,
|
|
35
|
+
withWorkspaceQueue,
|
|
36
|
+
} from "./revisions.js";
|
|
37
|
+
import { provenanceOf } from "./provenance.js";
|
|
38
|
+
import { getTrace, listTraces, listTracesForCwd } from "./recall-trace.js";
|
|
39
|
+
import { recallPreview } from "./recall-preview.js";
|
|
40
|
+
import { sidecarHealth } from "./sidecar.js";
|
|
27
41
|
|
|
28
42
|
export { readUiSettings, writeUiSettings } from "./settings.js";
|
|
29
43
|
|
|
@@ -141,6 +155,22 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
141
155
|
lastInjectTokens: status.lastInjectTokens || 0,
|
|
142
156
|
lastInjectAt: status.lastInjectAt || null,
|
|
143
157
|
lastExtract: status.lastExtract || null,
|
|
158
|
+
// Estimates, not model tokens: the UI must label them as such.
|
|
159
|
+
mapTokenBudget: opts.mapTokenBudget || 400,
|
|
160
|
+
turnTokenBudget: TURN_ADDITIONAL_BUDGET,
|
|
161
|
+
lastContextPlan: status.lastContextPlan
|
|
162
|
+
? {
|
|
163
|
+
at: status.lastContextPlan.at,
|
|
164
|
+
tokens: status.lastContextPlan.tokens,
|
|
165
|
+
budget: status.lastContextPlan.budget,
|
|
166
|
+
records: status.lastContextPlan.records,
|
|
167
|
+
deferred: status.lastContextPlan.deferred,
|
|
168
|
+
}
|
|
169
|
+
: null,
|
|
170
|
+
preStep: status.preStep || null,
|
|
171
|
+
lastTrace: status.lastTrace
|
|
172
|
+
? { traceId: status.lastTrace.traceId, at: status.lastTrace.at, tokens: status.lastTrace.tokens }
|
|
173
|
+
: null,
|
|
144
174
|
version: installedVersion(),
|
|
145
175
|
recallMode: mode,
|
|
146
176
|
autoRecall: mode === "auto",
|
|
@@ -152,8 +182,10 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
152
182
|
pluginLocale: pluginLocaleOf(opts),
|
|
153
183
|
configPanelOpen: configPanelOpenOf(opts),
|
|
154
184
|
writeApproval: !!opts.writeApproval,
|
|
155
|
-
mapTokenBudget: opts.mapTokenBudget || 400,
|
|
156
185
|
workspaces: listOpenWorkspaces(),
|
|
186
|
+
// The graph is authoritative; the JSONL file is derived. Report its state
|
|
187
|
+
// instead of pretending the two files are one atomic unit.
|
|
188
|
+
derived: { sidecar: sidecarHealth() },
|
|
157
189
|
...embeddingPublicStatus(),
|
|
158
190
|
});
|
|
159
191
|
return;
|
|
@@ -294,6 +326,8 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
294
326
|
const q = url.searchParams.get("q") || "";
|
|
295
327
|
const includeArchived = url.searchParams.get("archived") === "1";
|
|
296
328
|
const includeStale = url.searchParams.get("stale") === "1";
|
|
329
|
+
// "Show expired" and "show replaced" are separate switches.
|
|
330
|
+
const includeSuperseded = url.searchParams.get("superseded") === "1";
|
|
297
331
|
const about = url.searchParams.get("about") || "";
|
|
298
332
|
sendJson(res, 200, {
|
|
299
333
|
ok: true,
|
|
@@ -303,12 +337,115 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
303
337
|
q: q || undefined,
|
|
304
338
|
includeArchived,
|
|
305
339
|
includeStale,
|
|
340
|
+
includeSuperseded,
|
|
306
341
|
aboutId: about || undefined,
|
|
307
342
|
}),
|
|
308
343
|
});
|
|
309
344
|
return;
|
|
310
345
|
}
|
|
311
346
|
|
|
347
|
+
/*
|
|
348
|
+
* Detail / provenance / history are read-on-demand surfaces. The detail
|
|
349
|
+
* response is a header only; full quotes and full history bodies stay behind
|
|
350
|
+
* their own endpoints so the list cannot accidentally pull all of it.
|
|
351
|
+
*/
|
|
352
|
+
const detailMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/detail$/);
|
|
353
|
+
if (detailMatch && method === "GET") {
|
|
354
|
+
const { cwd, db } = await dbForRequest(url);
|
|
355
|
+
if (!db) {
|
|
356
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const detail = nodeDetail(db, detailMatch[1]);
|
|
360
|
+
sendJson(res, detail ? 200 : 404, detail || { ok: false, message: "not found" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const provenanceMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/provenance$/);
|
|
365
|
+
if (provenanceMatch && method === "GET") {
|
|
366
|
+
const { db } = await dbForRequest(url);
|
|
367
|
+
if (!db) {
|
|
368
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const prov = provenanceOf(db, provenanceMatch[1]);
|
|
372
|
+
sendJson(res, prov ? 200 : 404, prov || { ok: false, message: "not found" });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const historyMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/history$/);
|
|
377
|
+
if (historyMatch && method === "GET") {
|
|
378
|
+
const { cwd, db } = await dbForRequest(url);
|
|
379
|
+
if (!db) {
|
|
380
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const result = historyOf(db, historyMatch[1], {
|
|
384
|
+
limit: url.searchParams.get("limit") || 20,
|
|
385
|
+
cursor: url.searchParams.get("cursor") || "",
|
|
386
|
+
});
|
|
387
|
+
if (!result.ok) {
|
|
388
|
+
sendJson(res, 409, { ok: false, reason: result.reason, message: "version chain unresolved", ...result });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
sendJson(res, 200, { ok: true, cwd, ...result });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (path === "/api/dsh-trivium/recall-traces" && method === "GET") {
|
|
396
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
|
|
397
|
+
if (url.searchParams.get("cwd") && !isKnownCwd(cwd)) {
|
|
398
|
+
sendJson(res, 400, { ok: false, message: "unknown workspace" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const sessionId = url.searchParams.get("session") || "";
|
|
402
|
+
const limit = url.searchParams.get("limit") || undefined;
|
|
403
|
+
sendJson(res, 200, {
|
|
404
|
+
ok: true,
|
|
405
|
+
cwd,
|
|
406
|
+
sessionId,
|
|
407
|
+
// No session filter means "every session of this workspace", which is
|
|
408
|
+
// still one workspace boundary. A session filter narrows it further.
|
|
409
|
+
traces: sessionId
|
|
410
|
+
? listTraces({ cwd, sessionId, limit })
|
|
411
|
+
: listTracesForCwd({ cwd, limit }),
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const traceMatch = path.match(/^\/api\/dsh-trivium\/recall-traces\/([A-Za-z0-9-]+)$/);
|
|
417
|
+
if (traceMatch && method === "GET") {
|
|
418
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
|
|
419
|
+
const found = getTrace(traceMatch[1], { cwd, sessionId: url.searchParams.get("session") || "" });
|
|
420
|
+
if (!found.ok) {
|
|
421
|
+
// 410 for an expired id, 404 for another workspace/session: the caller
|
|
422
|
+
// learns nothing about traces that are not theirs.
|
|
423
|
+
sendJson(res, found.status, { ok: false, message: found.message });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
sendJson(res, 200, { ok: true, cwd, trace: found.trace });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (path === "/api/dsh-trivium/recall-preview" && method === "POST") {
|
|
431
|
+
const { cwd, db } = await dbForRequest(url);
|
|
432
|
+
if (!db) {
|
|
433
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
437
|
+
const sessionId = body.sessionId || url.searchParams.get("sessionId") || "";
|
|
438
|
+
const opts = liveOptions();
|
|
439
|
+
const result = recallPreview(db, {
|
|
440
|
+
cwd,
|
|
441
|
+
sessionId,
|
|
442
|
+
query: body.query,
|
|
443
|
+
mode: body.mode || recallModeOf(opts),
|
|
444
|
+
});
|
|
445
|
+
sendJson(res, result.ok ? 200 : result.status || 400, { ...result, cwd, sessionId });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
312
449
|
const mergeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/merge$/);
|
|
313
450
|
if (mergeMatch && method === "POST") {
|
|
314
451
|
const { db } = await dbForRequest(url);
|
|
@@ -318,7 +455,61 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
318
455
|
}
|
|
319
456
|
const body = JSON.parse((await readBody(req)) || "{}");
|
|
320
457
|
const result = mergeNodes(db, mergeMatch[1], body.dropId);
|
|
321
|
-
sendJson(res, result.ok ? 200 : 400, result);
|
|
458
|
+
sendJson(res, result.status || (result.ok ? 200 : 400), result);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/*
|
|
463
|
+
* Revision writes. Every one of them is validated server-side inside the
|
|
464
|
+
* workspace queue: the client's hidden buttons and the prototype's demo
|
|
465
|
+
* state are not trusted, and the second window always loses on a stale ETag.
|
|
466
|
+
*/
|
|
467
|
+
const supersedeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/supersede$/);
|
|
468
|
+
if (supersedeMatch && method === "POST") {
|
|
469
|
+
const { cwd, db } = await dbForRequest(url);
|
|
470
|
+
if (!db) {
|
|
471
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
475
|
+
const result = await withWorkspaceQueue(cwd, () =>
|
|
476
|
+
supersede(db, {
|
|
477
|
+
oldId: supersedeMatch[1],
|
|
478
|
+
successorId: body.successorId,
|
|
479
|
+
reason: body.reason,
|
|
480
|
+
expectedEtags: body.expectedEtags,
|
|
481
|
+
operationId: body.operationId,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
sendJson(res, result.ok ? 200 : result.status || 400, {
|
|
485
|
+
...result,
|
|
486
|
+
cwd,
|
|
487
|
+
detail: result.ok ? null : revisionMessage(result.code),
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const revokeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/supersession\/revoke$/);
|
|
493
|
+
if (revokeMatch && method === "POST") {
|
|
494
|
+
const { cwd, db } = await dbForRequest(url);
|
|
495
|
+
if (!db) {
|
|
496
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
500
|
+
const result = await withWorkspaceQueue(cwd, () =>
|
|
501
|
+
revokeSupersession(db, {
|
|
502
|
+
oldId: revokeMatch[1],
|
|
503
|
+
successorId: body.successorId,
|
|
504
|
+
expectedEtags: body.expectedEtags,
|
|
505
|
+
operationId: body.operationId,
|
|
506
|
+
}),
|
|
507
|
+
);
|
|
508
|
+
sendJson(res, result.ok ? 200 : result.status || 400, {
|
|
509
|
+
...result,
|
|
510
|
+
cwd,
|
|
511
|
+
detail: result.ok ? null : revisionMessage(result.code),
|
|
512
|
+
});
|
|
322
513
|
return;
|
|
323
514
|
}
|
|
324
515
|
|
|
@@ -351,8 +542,17 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
351
542
|
return;
|
|
352
543
|
}
|
|
353
544
|
const id = Number(nodeMatch[1]);
|
|
354
|
-
|
|
355
|
-
|
|
545
|
+
try {
|
|
546
|
+
const ok = nodeMatch[2] === "archive" ? archiveNode(db, id) : deleteNode(db, id);
|
|
547
|
+
sendJson(res, ok ? 200 : 404, { ok, id });
|
|
548
|
+
} catch (err) {
|
|
549
|
+
// A node inside a revision chain needs its relation handled first.
|
|
550
|
+
if (err?.code === "REVISION_LINKED") {
|
|
551
|
+
sendJson(res, 409, { ok: false, id, code: err.code, message: revisionMessage(REVISION_CODES.revisionLinked) });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
356
556
|
return;
|
|
357
557
|
}
|
|
358
558
|
|
package/lib/sidecar.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { atomicWriteFile } from "./atomic.js";
|
|
7
|
-
import { BUSINESS_EDGES } from "./schema.js";
|
|
7
|
+
import { BUSINESS_EDGES, REVISION_EDGES, nowIso, weightOf } from "./schema.js";
|
|
8
8
|
import {
|
|
9
9
|
gitSidecarEnabledOf,
|
|
10
10
|
listedWorkspaces,
|
|
@@ -14,13 +14,44 @@ import {
|
|
|
14
14
|
} from "./settings.js";
|
|
15
15
|
|
|
16
16
|
export const SIDECAR_FORMAT = "dsh-trivium-jsonl";
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* v2 adds revision relations with their edge metadata. A v1 file is still read:
|
|
19
|
+
* it simply carries no supersedes rows. Downgrading is not supported in place —
|
|
20
|
+
* restore the pre-upgrade file group (see README) rather than letting an older
|
|
21
|
+
* build rewrite the chain away.
|
|
22
|
+
*/
|
|
23
|
+
export const SIDECAR_VERSION = 2;
|
|
18
24
|
export const SIDECAR_DEBOUNCE_MS = 1500;
|
|
19
25
|
export const BUSINESS_NODE_TYPES = Object.freeze(["entity", "preference", "decision", "experience"]);
|
|
20
26
|
|
|
21
27
|
const timers = new Map();
|
|
22
28
|
const memoryHash = new Map();
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Whether the derived JSONL file is currently in step with the graph.
|
|
32
|
+
*
|
|
33
|
+
* The graph is authoritative; this file is a projection written after the
|
|
34
|
+
* fact. A failure here is reported rather than hidden, and the next open
|
|
35
|
+
* (`syncSidecarOnOpen`) repairs it.
|
|
36
|
+
*/
|
|
37
|
+
const health = { ok: true, error: "", at: "" };
|
|
38
|
+
|
|
39
|
+
function noteSidecarError(err) {
|
|
40
|
+
health.ok = false;
|
|
41
|
+
health.error = String(err?.message || err || "sidecar write failed");
|
|
42
|
+
health.at = new Date().toISOString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noteSidecarOk() {
|
|
46
|
+
health.ok = true;
|
|
47
|
+
health.error = "";
|
|
48
|
+
health.at = new Date().toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sidecarHealth() {
|
|
52
|
+
return { ...health, enabled: gitSidecarEnabledOf(readUiSettings()) };
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
export function sidecarPath(cwd) {
|
|
25
56
|
return join(resolve(cwd || ""), ".dsh", "trivium.jsonl");
|
|
26
57
|
}
|
|
@@ -116,7 +147,9 @@ export function serializeGraph(db) {
|
|
|
116
147
|
nodes.sort((a, b) => String(a.uri).localeCompare(String(b.uri)));
|
|
117
148
|
const edges = [];
|
|
118
149
|
const seen = new Set();
|
|
119
|
-
|
|
150
|
+
// Business edges plus revision relations: a chain has to survive a clone, and
|
|
151
|
+
// the relation reason lives in the edge metadata.
|
|
152
|
+
const want = new Set([...BUSINESS_EDGES, ...REVISION_EDGES]);
|
|
120
153
|
for (const id of db.allNodeIds?.() || []) {
|
|
121
154
|
const fromUri = uriOf.get(Number(id));
|
|
122
155
|
if (!fromUri) continue;
|
|
@@ -129,7 +162,9 @@ export function serializeGraph(db) {
|
|
|
129
162
|
const key = `${fromUri}\0${toUri}\0${label}`;
|
|
130
163
|
if (seen.has(key)) continue;
|
|
131
164
|
seen.add(key);
|
|
132
|
-
|
|
165
|
+
const row = { op: "edge", from: fromUri, to: toUri, type: label };
|
|
166
|
+
if (e.metadata && typeof e.metadata === "object") row.meta = e.metadata;
|
|
167
|
+
edges.push(row);
|
|
133
168
|
}
|
|
134
169
|
}
|
|
135
170
|
edges.sort((a, b) => {
|
|
@@ -173,10 +208,15 @@ export function parseSidecar(text) {
|
|
|
173
208
|
}
|
|
174
209
|
if (op === "edge") {
|
|
175
210
|
const label = row.type || row.label || "";
|
|
176
|
-
if (!BUSINESS_EDGES.includes(label) || !row.from || !row.to) {
|
|
211
|
+
if ((!BUSINESS_EDGES.includes(label) && !REVISION_EDGES.includes(label)) || !row.from || !row.to) {
|
|
177
212
|
return { ok: false, message: "invalid edge" };
|
|
178
213
|
}
|
|
179
|
-
edges.push({
|
|
214
|
+
edges.push({
|
|
215
|
+
from: row.from,
|
|
216
|
+
to: row.to,
|
|
217
|
+
type: label,
|
|
218
|
+
meta: row.meta && typeof row.meta === "object" ? row.meta : null,
|
|
219
|
+
});
|
|
180
220
|
continue;
|
|
181
221
|
}
|
|
182
222
|
return { ok: false, message: "unknown jsonl row" };
|
|
@@ -276,10 +316,12 @@ export function writeSidecarNow(cwd, db, { force = false } = {}) {
|
|
|
276
316
|
const hash = hashText(text);
|
|
277
317
|
const file = sidecarPath(cwd);
|
|
278
318
|
if (!force && hash === knownHash(cwd) && existsSync(file)) {
|
|
319
|
+
noteSidecarOk();
|
|
279
320
|
return { path: file, hash, wrote: false };
|
|
280
321
|
}
|
|
281
322
|
atomicWriteFile(file, text);
|
|
282
323
|
rememberHash(cwd, hash);
|
|
324
|
+
noteSidecarOk();
|
|
283
325
|
return { path: file, hash, wrote: true };
|
|
284
326
|
}
|
|
285
327
|
|
|
@@ -293,8 +335,11 @@ export function scheduleSidecarWrite(cwd, db) {
|
|
|
293
335
|
timers.delete(key);
|
|
294
336
|
try {
|
|
295
337
|
writeSidecarNow(key, db);
|
|
296
|
-
|
|
297
|
-
|
|
338
|
+
noteSidecarOk();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// Disk errors are non-fatal, but they are visible: status reports them and
|
|
341
|
+
// reopening the workspace retries the sync.
|
|
342
|
+
noteSidecarError(err);
|
|
298
343
|
}
|
|
299
344
|
}, SIDECAR_DEBOUNCE_MS);
|
|
300
345
|
timers.set(key, timer);
|
|
@@ -314,6 +359,7 @@ export function flushSidecarNow(cwd, db) {
|
|
|
314
359
|
async function applySidecar(db, parsed) {
|
|
315
360
|
const store = await import("./store.js");
|
|
316
361
|
const uriSet = new Set();
|
|
362
|
+
const conflicts = [];
|
|
317
363
|
store.runWithoutSidecar(() => {
|
|
318
364
|
const idByUri = new Map();
|
|
319
365
|
for (const row of parsed.nodes) {
|
|
@@ -333,27 +379,63 @@ async function applySidecar(db, parsed) {
|
|
|
333
379
|
createdAt: row.createdAt || "",
|
|
334
380
|
updatedAt: row.updatedAt || "",
|
|
335
381
|
};
|
|
336
|
-
|
|
382
|
+
// Keep the empty label as an explicit clear when reconciling a snapshot.
|
|
337
383
|
if (!payload.untilAt) delete payload.untilAt;
|
|
338
384
|
if (!payload.fail) delete payload.fail;
|
|
339
385
|
if (!payload.fix) delete payload.fix;
|
|
340
386
|
let dest = store.findByUri(db, uri);
|
|
341
|
-
if (dest == null) dest = store.findMergeTarget(db, payload);
|
|
387
|
+
if (dest == null) dest = store.findMergeTarget(db, payload, { allowRevisionLinked: true });
|
|
342
388
|
if (dest != null) {
|
|
343
|
-
|
|
344
|
-
|
|
389
|
+
try {
|
|
390
|
+
store.mergeInto(db, dest, payload);
|
|
391
|
+
idByUri.set(uri, dest);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
// The file wants to rewrite a body that is confirmed history. Keep the
|
|
394
|
+
// live text, keep the store usable, and report the conflict.
|
|
395
|
+
if (err?.code === "REVISION_PROTECTED") {
|
|
396
|
+
conflicts.push({ code: "chain-body", uri });
|
|
397
|
+
idByUri.set(uri, dest);
|
|
398
|
+
} else {
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
345
402
|
} else {
|
|
346
403
|
idByUri.set(uri, store.insertNode(db, payload));
|
|
347
404
|
}
|
|
348
405
|
}
|
|
349
406
|
const wantEdges = new Set();
|
|
407
|
+
const wantRevision = new Set();
|
|
408
|
+
const revisionOps = [];
|
|
350
409
|
for (const e of parsed.edges) {
|
|
351
410
|
const from = idByUri.get(e.from) ?? store.findByUri(db, e.from);
|
|
352
411
|
const to = idByUri.get(e.to) ?? store.findByUri(db, e.to);
|
|
353
|
-
|
|
412
|
+
const revision = REVISION_EDGES.includes(e.type);
|
|
413
|
+
if (revision) wantRevision.add(`${e.from}\0${e.to}\0${e.type}`);
|
|
414
|
+
if (from == null || to == null || from === to) {
|
|
415
|
+
if (revision) conflicts.push({ code: "unmapped-relation", from: e.from, to: e.to });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (revision) {
|
|
419
|
+
revisionOps.push({
|
|
420
|
+
type: "upsertEdge",
|
|
421
|
+
src: from,
|
|
422
|
+
dst: to,
|
|
423
|
+
label: e.type,
|
|
424
|
+
weight: weightOf(e.type),
|
|
425
|
+
metadata: e.meta || { at: nowIso(), reason: "", source: "sidecar" },
|
|
426
|
+
});
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
354
429
|
store.ensureLink(db, from, to, e.type);
|
|
355
430
|
wantEdges.add(`${e.from}\0${e.to}\0${e.type}`);
|
|
356
431
|
}
|
|
432
|
+
if (revisionOps.length) {
|
|
433
|
+
try {
|
|
434
|
+
db.commitTransaction(revisionOps);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
conflicts.push({ code: "relation-transaction-failed", message: String(err.message || err) });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
357
439
|
const uriById = new Map();
|
|
358
440
|
for (const id of db.allNodeIds?.() || []) {
|
|
359
441
|
const payload = payloadOf(db, id);
|
|
@@ -375,6 +457,22 @@ async function applySidecar(db, parsed) {
|
|
|
375
457
|
}
|
|
376
458
|
}
|
|
377
459
|
}
|
|
460
|
+
/*
|
|
461
|
+
* A relation that exists locally but is absent from the file is NOT removed.
|
|
462
|
+
* Deleting a chain from a pull is a semantic change a snapshot cannot prove,
|
|
463
|
+
* so it is reported as a conflict and the user revokes it explicitly.
|
|
464
|
+
*/
|
|
465
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
466
|
+
const node = db.get(Number(id));
|
|
467
|
+
const fromUri = uriById.get(Number(id));
|
|
468
|
+
for (const edge of node?.edges || []) {
|
|
469
|
+
if (!REVISION_EDGES.includes(String(edge.label))) continue;
|
|
470
|
+
const toUri = uriById.get(edgeTarget(edge));
|
|
471
|
+
if (!fromUri || !toUri) continue;
|
|
472
|
+
if (wantRevision.has(`${fromUri}\0${toUri}\0${edge.label}`)) continue;
|
|
473
|
+
conflicts.push({ code: "relation-missing-in-file", from: fromUri, to: toUri });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
378
476
|
for (const id of db.allNodeIds?.() || []) {
|
|
379
477
|
const payload = payloadOf(db, id);
|
|
380
478
|
if (!isBusinessType(payload.type) || payload.status === "archived") continue;
|
|
@@ -382,6 +480,7 @@ async function applySidecar(db, parsed) {
|
|
|
382
480
|
if (!uri || !uriSet.has(uri)) store.archiveNode(db, id);
|
|
383
481
|
}
|
|
384
482
|
});
|
|
483
|
+
return { conflicts };
|
|
385
484
|
}
|
|
386
485
|
|
|
387
486
|
/**
|
|
@@ -401,7 +500,15 @@ export async function syncSidecarOnOpen(cwd, db) {
|
|
|
401
500
|
if (fileHash === knownHash(key)) return { action: "skip" };
|
|
402
501
|
const parsed = parseSidecar(text);
|
|
403
502
|
if (!parsed.ok) return { action: "skip-invalid", message: parsed.message };
|
|
404
|
-
await applySidecar(db, parsed);
|
|
503
|
+
const applied = await applySidecar(db, parsed);
|
|
405
504
|
rememberHash(key, fileHash);
|
|
406
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
action: "import",
|
|
507
|
+
hash: fileHash,
|
|
508
|
+
nodes: parsed.nodes.length,
|
|
509
|
+
edges: parsed.edges.length,
|
|
510
|
+
version: Number(parsed.header?.v) || 1,
|
|
511
|
+
// Conflicts are visible; the store stays usable and nothing is half-applied.
|
|
512
|
+
conflicts: applied?.conflicts || [],
|
|
513
|
+
};
|
|
407
514
|
}
|