ygl-trees 0.75.1 → 0.76.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/CHANGELOG.md +6 -0
- package/dist/apps/cli/src/bin.js +15 -4
- package/dist/bundle/bin.cjs +1242 -1147
- package/dist/packages/core/database/src/plugin.js +22 -0
- package/dist/packages/core/text/src/eventbus.js +35 -0
- package/dist/packages/ops/tools/src/webui.js +27 -1
- package/dist/packages/research/pipeline/src/writes.js +6 -0
- package/dist/packages/techtree/model/src/writes.js +19 -13
- package/package.json +4 -4
|
@@ -12,6 +12,7 @@ const cordis_1 = require("@deepseek-ai/cordis");
|
|
|
12
12
|
const db_1 = require("./db");
|
|
13
13
|
class DatabaseService extends cordis_1.Service {
|
|
14
14
|
db;
|
|
15
|
+
disposed = false;
|
|
15
16
|
constructor(ctx, dbFile) {
|
|
16
17
|
super(ctx, "database");
|
|
17
18
|
// GPT-6.1 SOL fix: the DB lock was only applied in the profile-boot
|
|
@@ -23,6 +24,27 @@ class DatabaseService extends cordis_1.Service {
|
|
|
23
24
|
const lock = process.env.YGL_TEST !== "1" && process.env.YGL_SERVE !== "1";
|
|
24
25
|
this.db = new db_1.DB(dbFile, { lock });
|
|
25
26
|
}
|
|
27
|
+
/** Lifecycle: Cordis calls this when the Context is disposed — the
|
|
28
|
+
* "Services stop with Context, resources auto-clean" promise made real.
|
|
29
|
+
* Closes the SQLite handle AND the cached ORM DataSource. */
|
|
30
|
+
stop() {
|
|
31
|
+
if (this.disposed)
|
|
32
|
+
return;
|
|
33
|
+
this.disposed = true;
|
|
34
|
+
try {
|
|
35
|
+
// Close the cached ORM DataSource if it was opened on this file
|
|
36
|
+
const { closeOrm } = require("./orm");
|
|
37
|
+
// closeOrm is async but stop() is sync in Cordis — fire-and-forget is
|
|
38
|
+
// safe here because db.close() below releases the file handle the ORM
|
|
39
|
+
// depends on; the ORM will see a closed connection and GC naturally.
|
|
40
|
+
closeOrm().catch(() => { });
|
|
41
|
+
}
|
|
42
|
+
catch { /* orm module not loaded — fine */ }
|
|
43
|
+
try {
|
|
44
|
+
this.db.close(); // releases .ygllock + closes SQLite handle
|
|
45
|
+
}
|
|
46
|
+
catch { /* already closed */ }
|
|
47
|
+
}
|
|
26
48
|
}
|
|
27
49
|
exports.DatabaseService = DatabaseService;
|
|
28
50
|
function apply(ctx, config) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.emitEvent = emitEvent;
|
|
4
|
+
exports.onEvent = onEvent;
|
|
5
|
+
exports.onEventKind = onEventKind;
|
|
6
|
+
/**
|
|
7
|
+
* Cross-domain event bus — connects logEvent writes to real-time consumers.
|
|
8
|
+
*
|
|
9
|
+
* Architecture: a process-wide EventEmitter singleton. Write services call
|
|
10
|
+
* `emitEvent()` after logEvent persists to the events table; consumers
|
|
11
|
+
* (webui SSE, user plugins, future watchers) subscribe with `onEvent()`.
|
|
12
|
+
*
|
|
13
|
+
* This makes "ctx.on reactivity" real without requiring a full Cordis
|
|
14
|
+
* event system — a lightweight bridge that the webui and plugins can use.
|
|
15
|
+
*
|
|
16
|
+
* @module @ygl/core-text/eventbus
|
|
17
|
+
*/
|
|
18
|
+
const node_events_1 = require("node:events");
|
|
19
|
+
const bus = new node_events_1.EventEmitter();
|
|
20
|
+
bus.setMaxListeners(50); // SSE connections + plugins
|
|
21
|
+
/** Publish a cross-domain event (called after events table write). */
|
|
22
|
+
function emitEvent(evt) {
|
|
23
|
+
bus.emit("ygl", evt);
|
|
24
|
+
bus.emit(`ygl:${evt.kind}`, evt); // specific subscription channel
|
|
25
|
+
}
|
|
26
|
+
/** Subscribe to all events. Returns an unsubscribe function. */
|
|
27
|
+
function onEvent(handler) {
|
|
28
|
+
bus.on("ygl", handler);
|
|
29
|
+
return () => bus.off("ygl", handler);
|
|
30
|
+
}
|
|
31
|
+
/** Subscribe to a specific kind (e.g. "node.unlock"). Returns unsubscribe. */
|
|
32
|
+
function onEventKind(kind, handler) {
|
|
33
|
+
bus.on(`ygl:${kind}`, handler);
|
|
34
|
+
return () => bus.off(`ygl:${kind}`, handler);
|
|
35
|
+
}
|
|
@@ -852,6 +852,23 @@ function createDashboardServer(env) {
|
|
|
852
852
|
if (typeof h.unref === "function")
|
|
853
853
|
h.unref();
|
|
854
854
|
}
|
|
855
|
+
// ── 0.76 Event-driven SSE: subscribe to the cross-domain event bus.
|
|
856
|
+
// Writes (node unlock, claim resolve, kb add, etc.) now emit instantly —
|
|
857
|
+
// SSE pushes fresh data on write instead of waiting for the 1.5s tick.
|
|
858
|
+
// The fingerprint tick above stays as a fallback/degradation path.
|
|
859
|
+
let unsubBus = null;
|
|
860
|
+
try {
|
|
861
|
+
const { onEvent } = require("../../../core/text/src/eventbus");
|
|
862
|
+
unsubBus = onEvent((evt) => {
|
|
863
|
+
if (!sseClients.size)
|
|
864
|
+
return;
|
|
865
|
+
// Event received — push immediately (no polling delay)
|
|
866
|
+
push("data", gatherData(env));
|
|
867
|
+
// Also push the event itself as a named SSE event for fine-grained consumers
|
|
868
|
+
push(evt.kind.replace(/\//g, "-"), evt);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
catch { /* eventbus optional — fingerprint tick still covers */ }
|
|
855
872
|
const server = http.createServer((req, res) => {
|
|
856
873
|
const u = new URL(req.url || "/", "http://127.0.0.1");
|
|
857
874
|
const url = u.pathname;
|
|
@@ -962,7 +979,16 @@ function createDashboardServer(env) {
|
|
|
962
979
|
sseClients.clear();
|
|
963
980
|
};
|
|
964
981
|
server.on("close", dropLive);
|
|
965
|
-
server.closeLive =
|
|
982
|
+
server.closeLive = () => {
|
|
983
|
+
dropLive();
|
|
984
|
+
if (unsubBus) {
|
|
985
|
+
try {
|
|
986
|
+
unsubBus();
|
|
987
|
+
}
|
|
988
|
+
catch { /* */ }
|
|
989
|
+
unsubBus = null;
|
|
990
|
+
}
|
|
991
|
+
};
|
|
966
992
|
return server;
|
|
967
993
|
}
|
|
968
994
|
/** Live dashboard: blocks until SIGINT/SIGTERM, then resolves so the CLI can
|
|
@@ -40,6 +40,12 @@ async function logEvent(mgr, kind, entity, entityId, payload, level = "info") {
|
|
|
40
40
|
payload: p,
|
|
41
41
|
level,
|
|
42
42
|
});
|
|
43
|
+
// 0.76: cross-domain event bus — notify real-time consumers (SSE/plugins)
|
|
44
|
+
try {
|
|
45
|
+
const { emitEvent } = require("../../../core/text/src/eventbus");
|
|
46
|
+
emitEvent({ kind, entity, entityId, payload: String(payload), ts: new Date().toISOString() });
|
|
47
|
+
}
|
|
48
|
+
catch { /* eventbus optional — never block the write path */ }
|
|
43
49
|
}
|
|
44
50
|
// ================================================================ queries
|
|
45
51
|
async function queryAdd(ds, projectId, text, engine = "", force = false) {
|
|
@@ -26,6 +26,12 @@ async function logEvent(mgr, kind, entityId, payload, level = "info") {
|
|
|
26
26
|
const run = process.env.YGL_RUN_ID || "";
|
|
27
27
|
const body = run ? JSON.stringify({ ...payload, run }) : JSON.stringify(payload ?? {});
|
|
28
28
|
await mgr.query(`INSERT INTO events(id,ts,actor,kind,entity,entity_id,payload,level) VALUES(?,?,?,?,?,?,?,?)`, [(0, text_1.shortId)([kind, entityId], 12), (0, text_1.nowIso)(), "cli", kind, "node", entityId, body, level]);
|
|
29
|
+
// 0.76: cross-domain event bus — notify real-time consumers (SSE/plugins)
|
|
30
|
+
try {
|
|
31
|
+
const { emitEvent } = require("../../../core/text/src/eventbus");
|
|
32
|
+
emitEvent({ kind, entity: "node", entityId, payload: String(body), ts: new Date().toISOString() });
|
|
33
|
+
}
|
|
34
|
+
catch { /* eventbus optional — never block the write path */ }
|
|
29
35
|
}
|
|
30
36
|
async function reaches(mgr, treeId, fromId, toId) {
|
|
31
37
|
const rows = (await mgr.query(`SELECT 1 AS hit FROM node_closure WHERE tree_id=? AND ancestor=? AND descendant=? AND depth>0`, [treeId, fromId, toId]));
|
|
@@ -134,8 +140,8 @@ async function buildIndex(mgr, treeId) {
|
|
|
134
140
|
inDegree[n.key] = (requiredParents.get(n.id) || []).length;
|
|
135
141
|
for (const n of nodes)
|
|
136
142
|
await mgr.query(`UPDATE nodes SET tier=? WHERE id=? AND tier<>?`, [tierOf.get(n.id) ?? 0, n.id, tierOf.get(n.id) ?? 0]);
|
|
137
|
-
await mgr.query(`INSERT INTO node_index(tree_id,topo_order,tiers,in_degree,built_at,dirty) VALUES(?,?,?,?,?,0)
|
|
138
|
-
ON CONFLICT(tree_id) DO UPDATE SET topo_order=excluded.topo_order, tiers=excluded.tiers,
|
|
143
|
+
await mgr.query(`INSERT INTO node_index(tree_id,topo_order,tiers,in_degree,built_at,dirty) VALUES(?,?,?,?,?,0)
|
|
144
|
+
ON CONFLICT(tree_id) DO UPDATE SET topo_order=excluded.topo_order, tiers=excluded.tiers,
|
|
139
145
|
in_degree=excluded.in_degree, built_at=excluded.built_at, dirty=0`, [treeId, JSON.stringify(topo), JSON.stringify(tiers), JSON.stringify(inDegree), (0, text_1.nowIso)()]);
|
|
140
146
|
return { topo, tiers, inDegree };
|
|
141
147
|
}
|
|
@@ -222,10 +228,10 @@ async function link(ds, treeId, parentKey, childKey, o = {}) {
|
|
|
222
228
|
const before = (await mgr.query(`SELECT id FROM node_edges WHERE parent_id=? AND child_id=? AND rel=?`, [p[0].id, c[0].id, rel]));
|
|
223
229
|
const duplicate = before.length > 0;
|
|
224
230
|
const id = duplicate ? before[0].id : (0, text_1.shortId)(["edge", p[0].id, c[0].id, rel], 12);
|
|
225
|
-
await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
|
|
226
|
-
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
227
|
-
ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
|
|
228
|
-
mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
|
|
231
|
+
await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
|
|
232
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
233
|
+
ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
|
|
234
|
+
mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
|
|
229
235
|
weight=excluded.weight, note=excluded.note`, [id, treeId, p[0].id, c[0].id, rel, mode, o.required === false ? 0 : 1, o.min_level ?? 1, o.weight ?? 1, o.note || "", (0, text_1.nowIso)()]);
|
|
230
236
|
await rebuildClosure(mgr, treeId);
|
|
231
237
|
await logEvent(mgr, "node.link", id, { tree: treeId, parent: parentKey, child: childKey, rel, mode, required: o.required !== false, duplicate });
|
|
@@ -256,7 +262,7 @@ async function condAdd(ds, treeId, keyOrId, o) {
|
|
|
256
262
|
if (!db_1.COND_KINDS.includes(kind))
|
|
257
263
|
throw new Error(`kind must be one of ${db_1.COND_KINDS.join("|")}`);
|
|
258
264
|
const id = (0, text_1.shortId)(["cond", n[0].id, o.name], 12);
|
|
259
|
-
await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
|
|
265
|
+
await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
|
|
260
266
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, [id, n[0].id, o.name, kind, o.expr || o.name, o.op || ">=", o.value || "", o.required === false ? 0 : 1, o.effect || "gate", o.weight ?? 1, o.note || ""]);
|
|
261
267
|
await markDirty(mgr, treeId);
|
|
262
268
|
await logEvent(mgr, "node.cond", id, { tree: treeId, node: keyOrId, name: o.name, kind, op: o.op || ">=", required: o.required !== false });
|
|
@@ -269,7 +275,7 @@ async function stateSet(ds, treeId, keyOrId, status, context = "default", level
|
|
|
269
275
|
const n = (await mgr.query(`SELECT id FROM nodes WHERE tree_id=? AND (key=? OR id=?)`, [treeId, keyOrId, keyOrId]));
|
|
270
276
|
if (!n.length)
|
|
271
277
|
throw new Error(`node not found: ${keyOrId}`);
|
|
272
|
-
await mgr.query(`INSERT INTO node_state(tree_id,context,node_id,status,level,progress,updated) VALUES(?,?,?,?,?,?,?)
|
|
278
|
+
await mgr.query(`INSERT INTO node_state(tree_id,context,node_id,status,level,progress,updated) VALUES(?,?,?,?,?,?,?)
|
|
273
279
|
ON CONFLICT(tree_id,context,node_id) DO UPDATE SET status=excluded.status, level=excluded.level, updated=excluded.updated`, [treeId, context, n[0].id, status, level, status === "unlocked" ? 1 : 0, (0, text_1.nowIso)()]);
|
|
274
280
|
// R10.1: unlock/lock are the timeline anchors for R11's time axis
|
|
275
281
|
await logEvent(mgr, status === "unlocked" ? "node.unlock" : "node.lock", n[0].id, {
|
|
@@ -414,10 +420,10 @@ async function importTree(ds, spec, opts = {}) {
|
|
|
414
420
|
for (const e of edges) {
|
|
415
421
|
const pid = nodeId.get(String(e.parent));
|
|
416
422
|
const cid = nodeId.get(String(e.child));
|
|
417
|
-
await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
|
|
418
|
-
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
419
|
-
ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
|
|
420
|
-
mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
|
|
423
|
+
await mgr.query(`INSERT INTO node_edges(id,tree_id,parent_id,child_id,rel,mode,required,min_level,weight,note,created)
|
|
424
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
|
425
|
+
ON CONFLICT(parent_id,child_id,rel) DO UPDATE SET
|
|
426
|
+
mode=excluded.mode, required=excluded.required, min_level=excluded.min_level,
|
|
421
427
|
weight=excluded.weight, note=excluded.note`, [
|
|
422
428
|
(0, text_1.shortId)(["edge", tid, pid, cid, String(e.rel)], 12), tid, pid, cid,
|
|
423
429
|
String(e.rel), String(e.mode || "AND"), e.required === false ? 0 : 1,
|
|
@@ -427,7 +433,7 @@ async function importTree(ds, spec, opts = {}) {
|
|
|
427
433
|
for (const c of conds) {
|
|
428
434
|
const nid = nodeId.get(String(c.node));
|
|
429
435
|
await mgr.query(`DELETE FROM node_conditions WHERE node_id=? AND name=?`, [nid, String(c.name)]);
|
|
430
|
-
await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
|
|
436
|
+
await mgr.query(`INSERT INTO node_conditions(id,node_id,name,kind,expr,op,value,required,effect,weight,note)
|
|
431
437
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, [
|
|
432
438
|
(0, text_1.shortId)(["cond", tid, nid, String(c.name)], 10), nid, String(c.name),
|
|
433
439
|
String(c.kind || "flag"), String(c.expr || ""), String(c.op || ">="), String(c.value ?? ""),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ygl-trees",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "猿光 · 科技树 — database-centric CLI for research → production → knowledge reuse, built for AI agents.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/apps/cli/index.js",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"node-sqlite3-wasm": "^0.8.61",
|
|
26
26
|
"reflect-metadata": "^0.2.2",
|
|
27
27
|
"typeorm": "^1.1.2",
|
|
28
|
-
"@deepseek-ai/cordis": "~4.0.5-alpha.2",
|
|
29
|
-
"@deepseek-ai/schemastery": "~3.18.5-alpha.2",
|
|
30
28
|
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
|
|
31
|
-
"@deepseek-ai/
|
|
29
|
+
"@deepseek-ai/cordis": "~4.0.5-alpha.2",
|
|
30
|
+
"@deepseek-ai/cosmokit": "~1.8.6-alpha.2",
|
|
31
|
+
"@deepseek-ai/schemastery": "~3.18.5-alpha.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^20.0.0",
|