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.
@@ -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 = dropLive;
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.75.1",
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/cosmokit": "~1.8.6-alpha.2"
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",