@north-light/crouter 0.3.183 → 0.3.184

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.
@@ -1,13 +1,6 @@
1
1
  import { type FlatRow, type FoldState, type SubtreeActivity } from './nav-render.js';
2
- import type { NodeMeta, SubscriptionRef } from './index.js';
2
+ import type { NodeMeta } from './index.js';
3
3
  export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, type Telemetry, type FlatRow, type FoldState, type SubtreeActivity, } from './nav-render.js';
4
- export declare function beginFrame(): void;
5
- /** getNode memoized for the current frame (meta.json read + sqlite row). */
6
- export declare function cNode(id: string): NodeMeta | null;
7
- /** subscriptionsOf (a node's reports) memoized for the current frame. */
8
- export declare function cSubscriptions(id: string): SubscriptionRef[];
9
- /** subscribersOf (a node's managers) memoized for the current frame. */
10
- export declare function cSubscribers(id: string): SubscriptionRef[];
11
4
  /** First manager (by created) — the UP step for the ancestry spine. */
12
5
  export declare function managerOf(id: string): string | undefined;
13
6
  /** A kind:'human' node is a control-plane ASK (a deck on the human's
@@ -15,74 +15,45 @@ import { activeFaultForDisplay } from './render.js';
15
15
  import { readFault } from '../runtime/fault.js';
16
16
  import { faultSummary } from './status-glyph.js';
17
17
  import { statusRank } from './node-order.js';
18
- import { beginFrameTelem, nodeGlyph, navLabel, shortId, tokensCell, activityCell, cycleBadge, askBadge, liveBelowBadge, truncate, fillBar, fillWidth, isAttached, BOLD, DIM, RESET, REVERSE, BG_ATTACHED, } from './nav-render.js';
18
+ import { nodeGlyph, navLabel, shortId, tokensCell, activityCell, cycleBadge, askBadge, liveBelowBadge, truncate, fillBar, fillWidth, isAttached, BOLD, DIM, RESET, REVERSE, BG_ATTACHED, } from './nav-render.js';
19
19
  // Re-export the pure render/telemetry half so existing nav-model importers
20
20
  // (chrome/canvas-panels, overlays/graph, resume) keep resolving these here.
21
21
  export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, } from './nav-render.js';
22
22
  // ---------------------------------------------------------------------------
23
- // Per-frame read cache
23
+ // Read primitives — UNCACHED, deliberately
24
24
  //
25
- // One render fans the SAME handful of nodes out 5-10× each: computeSubtreeActivity
26
- // walks the whole tree (getNode + subscriptions per node), buildGraphModel walks it
27
- // again (climbRoot, sortedChildIds), then every visible row re-reads getNode +
28
- // telemetry + subscriptions for its badges. getNode alone is an fs.readFileSync of
29
- // meta.json plus a sqlite row read, and readTelemetry another readFileSync — so on a
30
- // modest graph a single 'j' keystroke fanned out to DOZENS of redundant disk reads.
31
- // That redundancy is the GRAPH view's open/scroll lag.
25
+ // These once memoized into module-global Maps that a `beginFrame()` cleared at
26
+ // the top of each TUI render. That cache is gone. Nothing in this module is
27
+ // frame-scoped any more: the GRAPH overlay reads the canvas through crtrd's
28
+ // remote CanvasSource (clients/attach/overlays/graph.ts) and never imports this
29
+ // file, while the one live consumer left — crtrd's `GET /v1/nodes?under=` via
30
+ // `subtreeIds` — is a daemon that runs for HOURS between restarts.
32
31
  //
33
- // Each render is a synchronous snapshot, so we memoize the read primitives for the
34
- // duration of one frame and clear at the top of render(): the next keystroke (or the
35
- // ask-poll / inbox render) re-reads fresh, so a killed node, a new spawn, or a status
36
- // flip all surface on the very next frame. Nothing here writes, so a stale entry can
37
- // at worst be one frame old — and render() always reruns immediately after any action.
38
- // The telemetry cache lives in nav-render (readTelemetry); beginFrame clears it too.
32
+ // A process-lifetime memo there is not "at worst one frame stale", it is
33
+ // permanent: the first `?under=X` request that lands before X has spawned any
34
+ // children pinned `[]` under X and every later request returned it. Correctness
35
+ // on the daemon outranks a render amortization no caller asks for, so each read
36
+ // goes straight to the canvas db. The only frame cache still in play is
37
+ // nav-render's telemetry memo, whose TUI callers clear it themselves via
38
+ // `beginFrameTelem()`.
39
39
  // ---------------------------------------------------------------------------
40
- const frameNodes = new Map();
41
- const frameSubs = new Map();
42
- const frameMgrs = new Map();
43
- export function beginFrame() {
44
- frameNodes.clear();
45
- frameSubs.clear();
46
- frameMgrs.clear();
47
- beginFrameTelem();
48
- }
49
- /** getNode memoized for the current frame (meta.json read + sqlite row). */
50
- export function cNode(id) {
51
- if (frameNodes.has(id))
52
- return frameNodes.get(id) ?? null;
53
- const v = getNode(id);
54
- frameNodes.set(id, v);
55
- return v;
56
- }
57
- /** subscriptionsOf (a node's reports) memoized for the current frame. */
58
- export function cSubscriptions(id) {
59
- const hit = frameSubs.get(id);
60
- if (hit !== undefined)
61
- return hit;
62
- let v;
40
+ /** subscriptionsOf (a node's reports); an unreadable row reads as no edges. */
41
+ function subsOf(id) {
63
42
  try {
64
- v = subscriptionsOf(id);
43
+ return subscriptionsOf(id);
65
44
  }
66
45
  catch {
67
- v = [];
46
+ return [];
68
47
  }
69
- frameSubs.set(id, v);
70
- return v;
71
48
  }
72
- /** subscribersOf (a node's managers) memoized for the current frame. */
73
- export function cSubscribers(id) {
74
- const hit = frameMgrs.get(id);
75
- if (hit !== undefined)
76
- return hit;
77
- let v;
49
+ /** subscribersOf (a node's managers); an unreadable row reads as no edges. */
50
+ function mgrsOf(id) {
78
51
  try {
79
- v = subscribersOf(id);
52
+ return subscribersOf(id);
80
53
  }
81
54
  catch {
82
- v = [];
55
+ return [];
83
56
  }
84
- frameMgrs.set(id, v);
85
- return v;
86
57
  }
87
58
  // ---------------------------------------------------------------------------
88
59
  // Attachment — is a human currently WATCHING a node? A separate axis from
@@ -100,7 +71,7 @@ export function cSubscribers(id) {
100
71
  // ---------------------------------------------------------------------------
101
72
  /** First manager (by created) — the UP step for the ancestry spine. */
102
73
  export function managerOf(id) {
103
- return cSubscribers(id)[0]?.node_id;
74
+ return mgrsOf(id)[0]?.node_id;
104
75
  }
105
76
  /** A kind:'human' node is a control-plane ASK (a deck on the human's
106
77
  * screen), NOT a pi conversation — it has no session, so focusing/reviving it
@@ -110,17 +81,17 @@ export function managerOf(id) {
110
81
  * own. Drop it from every navigable list (the tree, BASE reports, child counts,
111
82
  * subtree expansion) so it can never be selected. */
112
83
  export function isHumanAsk(id) {
113
- return cNode(id)?.kind === 'human';
84
+ return getNode(id)?.kind === 'human';
114
85
  }
115
86
  /** A node's direct children that are navigable conversations — human-ask nodes
116
87
  * dropped. The one place the nav chrome enumerates children. */
117
88
  export function convoChildIds(id) {
118
- return cSubscriptions(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
89
+ return subsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
119
90
  }
120
91
  /** Live reports (active|idle) of a node — the DOWN set in BASE. */
121
92
  export function liveReports(id) {
122
93
  return convoChildIds(id).filter((cid) => {
123
- const st = cNode(cid)?.status;
94
+ const st = getNode(cid)?.status;
124
95
  return st === 'active' || st === 'idle';
125
96
  });
126
97
  }
@@ -175,7 +146,7 @@ export function childBadge(node) {
175
146
  * the tree and when stepping into a subtree (`l`). Array.sort is stable, so
176
147
  * equal-status siblings keep their creation order. */
177
148
  export function sortedChildIds(id) {
178
- return convoChildIds(id).sort((a, b) => statusRank(cNode(a)?.status) - statusRank(cNode(b)?.status));
149
+ return convoChildIds(id).sort((a, b) => statusRank(getNode(a)?.status) - statusRank(getNode(b)?.status));
179
150
  }
180
151
  // ---------------------------------------------------------------------------
181
152
  // GRAPH model — flatten the local graph fold-aware. Rebuilt every render (cheap
@@ -189,7 +160,7 @@ export function computeSubtreeActivity(root, self) {
189
160
  // Returns the count of active nodes in subtree(id) INCLUDING id, and whether
190
161
  // that subtree is worth revealing (holds an active node or self).
191
162
  const visit = (id) => {
192
- const selfActive = cNode(id)?.status === 'active';
163
+ const selfActive = getNode(id)?.status === 'active';
193
164
  if (seen.has(id))
194
165
  return { active: selfActive ? 1 : 0, reveal: selfActive || id === self };
195
166
  seen.add(id);
@@ -254,7 +225,7 @@ export function renderGraphRow(r, isCursor, focused, activeBelow, asks) {
254
225
  const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
255
226
  return wrap(line, false);
256
227
  }
257
- const node = cNode(r.id);
228
+ const node = getNode(r.id);
258
229
  const dot = nodeGlyph(node);
259
230
  const rawName = navLabel(node, r.id);
260
231
  const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
@@ -50,8 +50,8 @@ export declare function fillWidth(): number;
50
50
  * bar; the visible width is padded out to `width`; the line closes with a real
51
51
  * RESET so the style never bleeds into the editor below. */
52
52
  export declare function fillBar(content: string, width: number, open: string): string;
53
- /** Clear the per-frame telemetry cache. Called from nav-model's beginFrame so
54
- * the graph half and the render half share one frame boundary. */
53
+ /** Clear the per-frame telemetry cache. Every render pass calls this first, so
54
+ * one keystroke re-reads each node's telemetry exactly once. */
55
55
  export declare function beginFrameTelem(): void;
56
56
  export interface Telemetry {
57
57
  /** Live context-window size (pi's getContextUsage) — the figure pi's own footer
@@ -130,14 +130,16 @@ export function fillBar(content, width, open) {
130
130
  return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
131
131
  }
132
132
  // ---------------------------------------------------------------------------
133
- // Per-frame telemetry cache. Cleared at the top of each render pass (nav-model's
134
- // beginFrame calls beginFrameTelem) so token/activity cells read fresh once per
135
- // frame instead of once per row. Nothing here writes, so a stale entry can at
136
- // worst be one frame old.
133
+ // Per-frame telemetry cache. Its callers are TUI render passes ONLY (the GRAPH
134
+ // overlay and the canvas panels), each of which calls beginFrameTelem() at the
135
+ // top of the pass, so token/activity cells read fresh once per frame instead of
136
+ // once per row and a stale entry can at worst be one frame old. Never memoize a
137
+ // canvas read for a caller that is not a render pass: crtrd lives for hours, and
138
+ // a memo it never clears is permanent, not per-frame.
137
139
  // ---------------------------------------------------------------------------
138
140
  const frameTelem = new Map();
139
- /** Clear the per-frame telemetry cache. Called from nav-model's beginFrame so
140
- * the graph half and the render half share one frame boundary. */
141
+ /** Clear the per-frame telemetry cache. Every render pass calls this first, so
142
+ * one keystroke re-reads each node's telemetry exactly once. */
141
143
  export function beginFrameTelem() {
142
144
  frameTelem.clear();
143
145
  }
@@ -0,0 +1,63 @@
1
+ // Regression: crtrd served a PERMANENTLY stale `GET /v1/nodes?under=<id>`.
2
+ //
3
+ // `handleList`'s subtree scope came from nav-model's `subtreeIds`, whose reads
4
+ // memoized into module-global Maps that only a `beginFrame()` cleared — and
5
+ // `beginFrame()` had no call sites anywhere. In a TUI that re-renders, a memo is
6
+ // one frame stale; in crtrd, which lives for hours, it is forever. The first
7
+ // `?under=X` request that arrived BEFORE X had spawned children pinned `[]`
8
+ // under X, and every later request returned that `[]` — Northlight's onboarding
9
+ // progress bar sat at 0/100 for the life of the daemon while the unfiltered list
10
+ // showed all 23 children with correct parents.
11
+ //
12
+ // This drives the real route handler against a real canvas db in exactly that
13
+ // order: list the subtree while it is genuinely empty, THEN spawn children, then
14
+ // list again. Nothing here would have failed with a hand-checked subtree walk —
15
+ // only the request-order sequence catches it.
16
+ import { test, before, after, beforeEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { createNode, subscribe } from '../../core/canvas/canvas.js';
22
+ import { closeDb } from '../../core/canvas/db.js';
23
+ import { nodeRoutes } from '../api/handlers/nodes.js';
24
+ let home;
25
+ function node(id, parent) {
26
+ return {
27
+ node_id: id, name: id, created: new Date().toISOString(), cwd: '/tmp/work',
28
+ kind: 'general', mode: 'base', lifecycle: 'terminal', status: 'active',
29
+ ...(parent !== undefined ? { parent } : {}),
30
+ };
31
+ }
32
+ /** GET /v1/nodes?under=<id> through the registered route, as crtrd serves it. */
33
+ async function listUnder(id) {
34
+ const route = nodeRoutes.find((r) => r.method === 'GET' && r.pattern === '/v1/nodes');
35
+ const ctx = {
36
+ method: 'GET', path: '/v1/nodes', params: {},
37
+ query: new URLSearchParams({ under: id }), body: undefined,
38
+ };
39
+ const res = (await route.handler(ctx));
40
+ assert.equal(res.status, 200);
41
+ return res.body.map((r) => r.node_id);
42
+ }
43
+ before(() => { home = mkdtempSync(join(tmpdir(), 'crtr-subtree-')); process.env['CRTR_HOME'] = home; });
44
+ beforeEach(() => { closeDb(); rmSync(home, { recursive: true, force: true }); });
45
+ after(() => { closeDb(); rmSync(home, { recursive: true, force: true }); delete process.env['CRTR_HOME']; });
46
+ test('a subtree query answered before any child exists does not poison later queries on the same daemon process', async () => {
47
+ const coordinator = 'coordinator';
48
+ createNode(node(coordinator));
49
+ // The poisoning request: the gateway polls within a second of the coordinator
50
+ // being created, before it has spawned anything.
51
+ assert.deepEqual(await listUnder(coordinator), [], 'an empty subtree is genuinely empty');
52
+ // The coordinator now spawns its children (parent subscribes_to child).
53
+ createNode(node('research-gmail', coordinator));
54
+ createNode(node('research-misc', coordinator));
55
+ subscribe(coordinator, 'research-gmail');
56
+ subscribe(coordinator, 'research-misc');
57
+ assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc'], 'the same long-lived process must see children spawned after its first subtree query');
58
+ // A grandchild spawned later still surfaces — the walk is re-read at every
59
+ // level, not just the root's direct edges.
60
+ createNode(node('writer-connectivity', 'research-gmail'));
61
+ subscribe('research-gmail', 'writer-connectivity');
62
+ assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc', 'writer-connectivity'], 'a grandchild spawned after an earlier query surfaces too');
63
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.183",
3
+ "version": "0.3.184",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.183",
3
+ "version": "0.3.184",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.183",
9
+ "version": "0.3.184",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {