@crouton-kit/crouter 0.3.65 → 0.3.67
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/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +632 -632
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +65 -11
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/node-lifecycle-revive.js +9 -2
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/pid.d.ts +71 -13
- package/dist/core/canvas/pid.js +73 -29
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/config.js +4 -3
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/runtime/revive-all.js +8 -2
- package/dist/core/runtime/revive.js +18 -8
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/substrate/on-read.js +13 -2
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/daemon/crtrd.js +18 -8
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/dist/core/canvas/pid.js
CHANGED
|
@@ -60,50 +60,92 @@ export function isPidAlive(pid) {
|
|
|
60
60
|
}
|
|
61
61
|
return !isZombie(pid);
|
|
62
62
|
}
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
63
|
+
/** Split a `composeIdentity` fingerprint (`lstart` or `lstart#ticks`) into its
|
|
64
|
+
* coarse (`lstart`, second-granular, portable) and fine (`ticks`, Linux-only
|
|
65
|
+
* jiffies, best-effort) parts. `#` never appears in `lstart` itself (a `ps`
|
|
66
|
+
* timestamp), so the first `#` unambiguously separates them. */
|
|
67
|
+
function splitIdentity(identity) {
|
|
68
|
+
const i = identity.indexOf('#');
|
|
69
|
+
return i === -1 ? { base: identity, ticks: undefined } : { base: identity.slice(0, i), ticks: identity.slice(i + 1) };
|
|
70
|
+
}
|
|
71
|
+
/** THE single precision-aware identity compare, used EVERYWHERE two
|
|
72
|
+
* `composeIdentity` fingerprints are compared (this module's own
|
|
73
|
+
* `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
|
|
74
|
+
* `killProcessTreePids`'s reuse check) — centralized so no call site can
|
|
75
|
+
* drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
|
|
76
|
+
* fix).
|
|
77
|
+
*
|
|
78
|
+
* The hazard this closes: `composeIdentity` appends the `#<ticks>` Linux
|
|
79
|
+
* discriminator only when `/proc/<pid>/stat` happens to be readable at
|
|
80
|
+
* capture time — so the SAME live process can be recorded plain `lstart` on
|
|
81
|
+
* one capture and `lstart#ticks` on another (a transient `/proc` read
|
|
82
|
+
* failure, or capturing on a platform/container without procfs). A naive
|
|
83
|
+
* `===` then reads that as a reuse (different string) and reports `dead` for
|
|
84
|
+
* a broker that never actually died — the false-DEAD direction, which can
|
|
85
|
+
* make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
|
|
86
|
+
* onto a still-live session.
|
|
87
|
+
*
|
|
88
|
+
* Rule: split both on `#`. Bases (`lstart`) differ → definitely NOT a match
|
|
89
|
+
* (different process, or the same pid reused in a different second). Bases
|
|
90
|
+
* match and BOTH carry a `#ticks` suffix → match iff the suffixes are equal
|
|
91
|
+
* (this still catches genuine same-second reuse, the case `procStartTicks`
|
|
92
|
+
* exists for). Bases match and EITHER lacks the suffix → coarse-vs-fine,
|
|
93
|
+
* can't distinguish → MATCH (fail-open to same-process, matching this
|
|
94
|
+
* module's universal discipline: only a POSITIVE mismatch counts as reuse,
|
|
95
|
+
* never an absence of evidence). */
|
|
96
|
+
export function identitiesMatch(a, b) {
|
|
97
|
+
const ia = splitIdentity(a);
|
|
98
|
+
const ib = splitIdentity(b);
|
|
99
|
+
if (ia.base !== ib.base)
|
|
100
|
+
return false;
|
|
101
|
+
if (ia.ticks !== undefined && ib.ticks !== undefined)
|
|
102
|
+
return ia.ticks === ib.ticks;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
export function recordedPidLiveness(pid, expectedIdentity) {
|
|
77
106
|
const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
|
|
78
107
|
const alive = isPidAlive(pid);
|
|
79
108
|
if (!alive) {
|
|
80
109
|
if (debug)
|
|
81
|
-
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead',
|
|
82
|
-
return
|
|
110
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead', 'dead');
|
|
111
|
+
return 'dead'; // gone/zombie → dead (unchanged path)
|
|
83
112
|
}
|
|
84
113
|
if (expectedIdentity == null) {
|
|
85
114
|
if (debug)
|
|
86
|
-
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive',
|
|
87
|
-
return
|
|
115
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive', 'alive');
|
|
116
|
+
return 'alive'; // no baseline → trust isPidAlive (legacy behavior)
|
|
88
117
|
}
|
|
89
118
|
const current = capturePidIdentities([pid]);
|
|
90
119
|
if (current == null) {
|
|
91
120
|
if (debug)
|
|
92
|
-
logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed →
|
|
93
|
-
return
|
|
121
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → indeterminate', 'indeterminate');
|
|
122
|
+
return 'indeterminate'; // probe itself failed → we don't know (THE fix: was fail-open alive)
|
|
94
123
|
}
|
|
95
124
|
const actual = current.get(pid);
|
|
96
125
|
if (actual === undefined) {
|
|
97
126
|
if (debug)
|
|
98
|
-
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) →
|
|
99
|
-
return
|
|
127
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → dead', 'dead');
|
|
128
|
+
return 'dead'; // isPidAlive said alive but it's gone now → dead
|
|
100
129
|
}
|
|
101
|
-
const
|
|
130
|
+
const matches = identitiesMatch(actual, expectedIdentity);
|
|
102
131
|
if (debug)
|
|
103
|
-
logPidLivenessDecision(pid, alive, expectedIdentity, 'value',
|
|
104
|
-
return
|
|
132
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
|
|
133
|
+
return matches ? 'alive' : 'dead';
|
|
134
|
+
}
|
|
135
|
+
/** Boolean teardown-direction adapter over `recordedPidLiveness`, kept so
|
|
136
|
+
* every pre-existing signalling caller stays behavior-identical: `dead` is
|
|
137
|
+
* the ONLY thing that reads false. `indeterminate` (can't confirm either
|
|
138
|
+
* way) reads true alongside `alive` — never signal a maybe-ours pid on mere
|
|
139
|
+
* uncertainty, matching this module's universal discipline (a POSITIVE
|
|
140
|
+
* mismatch is the only thing that changes behavior, never an absence of
|
|
141
|
+
* evidence). Callers that need to distinguish "confirmed alive" from
|
|
142
|
+
* "can't confirm" (e.g. a double-launch guard, where treating uncertainty as
|
|
143
|
+
* alive risks silently skipping a needed relaunch) must call
|
|
144
|
+
* `recordedPidLiveness` directly instead of this adapter. */
|
|
145
|
+
export function isRecordedPidAlive(pid, expectedIdentity) {
|
|
146
|
+
return recordedPidLiveness(pid, expectedIdentity) !== 'dead';
|
|
105
147
|
}
|
|
106
|
-
/** Diagnostic-only decision trace for `
|
|
148
|
+
/** Diagnostic-only decision trace for `recordedPidLiveness`, gated entirely
|
|
107
149
|
* behind `CRTR_DEBUG_PID_LIVENESS` — callers must check the env var
|
|
108
150
|
* themselves before calling this so the (tiny) formatting cost is paid ONLY
|
|
109
151
|
* when the env var is set; this function does no env check of its own.
|
|
@@ -129,7 +171,7 @@ function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOu
|
|
|
129
171
|
`branch=${JSON.stringify(branch)}`,
|
|
130
172
|
`returned=${returned}`,
|
|
131
173
|
];
|
|
132
|
-
process.stderr.write(`[
|
|
174
|
+
process.stderr.write(`[recordedPidLiveness] ${parts.join(' ')}\n`);
|
|
133
175
|
}
|
|
134
176
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
135
177
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
@@ -339,7 +381,9 @@ export function captureTeardownSnapshot(rootPid, expectedIdentity) {
|
|
|
339
381
|
if (table === null)
|
|
340
382
|
return { tree: [rootPid], identities: null, reused: false };
|
|
341
383
|
const currentRootIdentity = table.identities.get(rootPid);
|
|
342
|
-
const reused = expectedIdentity != null &&
|
|
384
|
+
const reused = expectedIdentity != null &&
|
|
385
|
+
currentRootIdentity !== undefined &&
|
|
386
|
+
!identitiesMatch(currentRootIdentity, expectedIdentity);
|
|
343
387
|
if (reused)
|
|
344
388
|
return { tree: [], identities: null, reused: true };
|
|
345
389
|
const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)];
|
|
@@ -407,7 +451,7 @@ export function killProcessTreePids(pids, signal = 'SIGTERM', identities) {
|
|
|
407
451
|
if (identities != null && current != null) {
|
|
408
452
|
const expected = identities.get(pid);
|
|
409
453
|
const actual = current.get(pid);
|
|
410
|
-
const reused = expected !== undefined && actual !== undefined && actual
|
|
454
|
+
const reused = expected !== undefined && actual !== undefined && !identitiesMatch(actual, expected);
|
|
411
455
|
if (reused)
|
|
412
456
|
continue;
|
|
413
457
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CanvasSource } from './source.js';
|
|
2
|
+
import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
|
|
3
|
+
/** The exact JSON shape `crtr --json node inspect show <id>` emits (see
|
|
4
|
+
* `nodeShow`'s `run`, `src/commands/node.ts`) — mirrored here, not imported,
|
|
5
|
+
* to avoid a commands→canvas value dependency. */
|
|
6
|
+
export interface RemoteShowResponse {
|
|
7
|
+
node: NodeMeta;
|
|
8
|
+
reports: {
|
|
9
|
+
node_id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
kind: string;
|
|
12
|
+
status: string;
|
|
13
|
+
active: boolean;
|
|
14
|
+
}[];
|
|
15
|
+
managers: {
|
|
16
|
+
node_id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
status: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}[];
|
|
22
|
+
artifacts: {
|
|
23
|
+
report: number;
|
|
24
|
+
doc: number;
|
|
25
|
+
roadmap: number;
|
|
26
|
+
};
|
|
27
|
+
pending_triggers: number;
|
|
28
|
+
paths: {
|
|
29
|
+
context_dir: string;
|
|
30
|
+
reports_dir: string;
|
|
31
|
+
session_file: string | null;
|
|
32
|
+
};
|
|
33
|
+
follow_up: string;
|
|
34
|
+
}
|
|
35
|
+
export declare class RemoteCanvasSource implements CanvasSource {
|
|
36
|
+
private readonly previewEndpoint;
|
|
37
|
+
private readonly relayToken;
|
|
38
|
+
private readonly exec;
|
|
39
|
+
private nodeCache;
|
|
40
|
+
private rosterCache;
|
|
41
|
+
private viewCache;
|
|
42
|
+
constructor(previewEndpoint: string, relayToken: string);
|
|
43
|
+
/** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
|
|
44
|
+
* `'inbox'`) event so the next read re-fetches fresh state. */
|
|
45
|
+
invalidate(): void;
|
|
46
|
+
private execJson;
|
|
47
|
+
private fetchShow;
|
|
48
|
+
/** The full passthrough object `node inspect show` produces — used by
|
|
49
|
+
* `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
|
|
50
|
+
* same shape a local read would, without re-deriving it. */
|
|
51
|
+
rawShow(id: string): Promise<RemoteShowResponse | null>;
|
|
52
|
+
getNode(id: string): Promise<NodeMeta | null>;
|
|
53
|
+
getRow(id: string): Promise<NodeRow | null>;
|
|
54
|
+
listNodes(filter?: {
|
|
55
|
+
status?: NodeStatus | NodeStatus[];
|
|
56
|
+
}): Promise<NodeRow[]>;
|
|
57
|
+
subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
|
|
58
|
+
subscribersOf(id: string): Promise<SubscriptionRef[]>;
|
|
59
|
+
view(root: string): Promise<string[]>;
|
|
60
|
+
askCountsForView(root: string): Promise<Record<string, number>>;
|
|
61
|
+
hasActiveLiveSubscription(id: string): Promise<boolean>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// remote-canvas-source.ts — a CanvasSource backed by a remote canvas over the
|
|
2
|
+
// relay-token-authed `/__crtr/source` transport (see `transport-remote.ts`).
|
|
3
|
+
// Every method shells out to an EXISTING `crtr --json <subcommand>` in-guest —
|
|
4
|
+
// no new in-guest command, no remote SQLite read, no drive-plane verb. A
|
|
5
|
+
// short-TTL cache coalesces the fan-out a single browse/inspect frame would
|
|
6
|
+
// otherwise make into the same node.
|
|
7
|
+
import { createRemoteExec } from '../view/transport-remote.js';
|
|
8
|
+
const CACHE_TTL_MS = 1000;
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Remote JSON shape validation.
|
|
11
|
+
//
|
|
12
|
+
// `execJson<T>` parses stdout and casts it to T with NO structural check — a
|
|
13
|
+
// remote subprocess that exits 0 with a wrong shape (e.g. `{}`, `{node:null}`)
|
|
14
|
+
// would otherwise pass through as a "valid" T. `fetchShow`/`listNodes`/
|
|
15
|
+
// `askCountsForView` validate before caching/returning; on a shape mismatch
|
|
16
|
+
// they degrade exactly like the existing network-failure path (null/[]/{}),
|
|
17
|
+
// never a malformed object a caller might dereference.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function isPlainObject(x) {
|
|
20
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
21
|
+
}
|
|
22
|
+
function isValidShowResponse(x) {
|
|
23
|
+
if (!isPlainObject(x))
|
|
24
|
+
return false;
|
|
25
|
+
const node = x['node'];
|
|
26
|
+
if (!isPlainObject(node) || typeof node['node_id'] !== 'string')
|
|
27
|
+
return false;
|
|
28
|
+
if (!Array.isArray(x['reports']) || !Array.isArray(x['managers']))
|
|
29
|
+
return false;
|
|
30
|
+
const artifacts = x['artifacts'];
|
|
31
|
+
if (!isPlainObject(artifacts) ||
|
|
32
|
+
typeof artifacts['report'] !== 'number' ||
|
|
33
|
+
typeof artifacts['doc'] !== 'number' ||
|
|
34
|
+
typeof artifacts['roadmap'] !== 'number')
|
|
35
|
+
return false;
|
|
36
|
+
if (typeof x['pending_triggers'] !== 'number')
|
|
37
|
+
return false;
|
|
38
|
+
const paths = x['paths'];
|
|
39
|
+
if (!isPlainObject(paths) ||
|
|
40
|
+
typeof paths['context_dir'] !== 'string' ||
|
|
41
|
+
typeof paths['reports_dir'] !== 'string' ||
|
|
42
|
+
(paths['session_file'] !== null && typeof paths['session_file'] !== 'string'))
|
|
43
|
+
return false;
|
|
44
|
+
if (typeof x['follow_up'] !== 'string')
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
|
|
49
|
+
* because `isValidShowResponse` only checks the array wrapper. Without this,
|
|
50
|
+
* a malformed neighbor (`null`, `{}`) survives into `subscriptionsOf`/
|
|
51
|
+
* `subscribersOf`'s `.map()` and node.ts's remote `show` passthrough render,
|
|
52
|
+
* both of which dereference `.node_id`/`.active` unguarded. */
|
|
53
|
+
function isValidNeighborEntry(x) {
|
|
54
|
+
return (isPlainObject(x) &&
|
|
55
|
+
typeof x['node_id'] === 'string' &&
|
|
56
|
+
typeof x['name'] === 'string' &&
|
|
57
|
+
typeof x['kind'] === 'string' &&
|
|
58
|
+
typeof x['status'] === 'string' &&
|
|
59
|
+
typeof x['active'] === 'boolean');
|
|
60
|
+
}
|
|
61
|
+
/** A `NodeRow` element from `node inspect list`'s `nodes` array — validated
|
|
62
|
+
* per-element because a remote success payload can be `{nodes:[null]}` (the
|
|
63
|
+
* outer array check alone lets that through). Requires the string identity/
|
|
64
|
+
* lifecycle fields browse's tree-building code dereferences unguarded
|
|
65
|
+
* (`browse/app.ts`'s `rebuildSnapshot`); the remaining nullable runtime
|
|
66
|
+
* fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
|
|
67
|
+
function isValidNodeRow(x) {
|
|
68
|
+
if (!isPlainObject(x))
|
|
69
|
+
return false;
|
|
70
|
+
return (typeof x['node_id'] === 'string' &&
|
|
71
|
+
typeof x['name'] === 'string' &&
|
|
72
|
+
typeof x['kind'] === 'string' &&
|
|
73
|
+
typeof x['mode'] === 'string' &&
|
|
74
|
+
typeof x['lifecycle'] === 'string' &&
|
|
75
|
+
typeof x['status'] === 'string' &&
|
|
76
|
+
typeof x['cwd'] === 'string' &&
|
|
77
|
+
typeof x['created'] === 'string');
|
|
78
|
+
}
|
|
79
|
+
export class RemoteCanvasSource {
|
|
80
|
+
previewEndpoint;
|
|
81
|
+
relayToken;
|
|
82
|
+
exec;
|
|
83
|
+
nodeCache = new Map();
|
|
84
|
+
rosterCache = new Map();
|
|
85
|
+
viewCache = new Map();
|
|
86
|
+
constructor(previewEndpoint, relayToken) {
|
|
87
|
+
this.previewEndpoint = previewEndpoint;
|
|
88
|
+
this.relayToken = relayToken;
|
|
89
|
+
this.exec = createRemoteExec(previewEndpoint, relayToken);
|
|
90
|
+
}
|
|
91
|
+
/** Drops every cached entry — called on a `RemoteEventStream` `'nodes'` (or
|
|
92
|
+
* `'inbox'`) event so the next read re-fetches fresh state. */
|
|
93
|
+
invalidate() {
|
|
94
|
+
this.nodeCache.clear();
|
|
95
|
+
this.rosterCache.clear();
|
|
96
|
+
this.viewCache.clear();
|
|
97
|
+
}
|
|
98
|
+
async execJson(args) {
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = await this.exec('crtr', ['--json', ...args]);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!raw.ok)
|
|
107
|
+
return null;
|
|
108
|
+
if (raw.exitCode !== undefined && raw.exitCode !== 0)
|
|
109
|
+
return null;
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(raw.stdout.trim());
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async fetchShow(id) {
|
|
118
|
+
const cached = this.nodeCache.get(id);
|
|
119
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
120
|
+
return cached.value;
|
|
121
|
+
const raw = await this.execJson(['node', 'inspect', 'show', id]);
|
|
122
|
+
// Sanitize reports/managers elements here (not just the outer array shape)
|
|
123
|
+
// so both consumers — subscriptionsOf/subscribersOf's `.map()` below AND
|
|
124
|
+
// node.ts's remote `show` passthrough render — see only well-formed
|
|
125
|
+
// neighbor entries, never a malformed one they'd dereference unguarded.
|
|
126
|
+
const res = isValidShowResponse(raw)
|
|
127
|
+
? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
|
|
128
|
+
: null;
|
|
129
|
+
this.nodeCache.set(id, { value: res, at: Date.now() });
|
|
130
|
+
return res;
|
|
131
|
+
}
|
|
132
|
+
/** The full passthrough object `node inspect show` produces — used by
|
|
133
|
+
* `nodeShow`'s remote branch (`src/commands/node.ts`) to return the exact
|
|
134
|
+
* same shape a local read would, without re-deriving it. */
|
|
135
|
+
async rawShow(id) {
|
|
136
|
+
return this.fetchShow(id);
|
|
137
|
+
}
|
|
138
|
+
async getNode(id) {
|
|
139
|
+
return (await this.fetchShow(id))?.node ?? null;
|
|
140
|
+
}
|
|
141
|
+
async getRow(id) {
|
|
142
|
+
const n = (await this.fetchShow(id))?.node;
|
|
143
|
+
if (!n)
|
|
144
|
+
return null;
|
|
145
|
+
return {
|
|
146
|
+
node_id: n.node_id,
|
|
147
|
+
name: n.name,
|
|
148
|
+
kind: n.kind,
|
|
149
|
+
mode: n.mode,
|
|
150
|
+
lifecycle: n.lifecycle,
|
|
151
|
+
status: n.status,
|
|
152
|
+
cwd: n.cwd,
|
|
153
|
+
host_kind: n.host_kind ?? null,
|
|
154
|
+
profile_id: n.profile_id ?? null,
|
|
155
|
+
parent: n.parent ?? null,
|
|
156
|
+
created: n.created,
|
|
157
|
+
intent: n.intent ?? null,
|
|
158
|
+
pi_pid: n.pi_pid ?? null,
|
|
159
|
+
pi_pid_identity: n.pi_pid_identity ?? null,
|
|
160
|
+
window: n.window ?? null,
|
|
161
|
+
tmux_session: n.tmux_session ?? null,
|
|
162
|
+
pane: n.pane ?? null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async listNodes(filter) {
|
|
166
|
+
const key = JSON.stringify(filter?.status ?? null);
|
|
167
|
+
const cached = this.rosterCache.get(key);
|
|
168
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
169
|
+
return cached.value;
|
|
170
|
+
const args = ['node', 'inspect', 'list'];
|
|
171
|
+
if (filter?.status) {
|
|
172
|
+
args.push('--status', (Array.isArray(filter.status) ? filter.status : [filter.status]).join(','));
|
|
173
|
+
}
|
|
174
|
+
const res = await this.execJson(args);
|
|
175
|
+
const rawNodes = isPlainObject(res) && Array.isArray(res['nodes']) ? res['nodes'] : [];
|
|
176
|
+
const nodes = rawNodes.filter(isValidNodeRow);
|
|
177
|
+
this.rosterCache.set(key, { value: nodes, at: Date.now() });
|
|
178
|
+
return nodes;
|
|
179
|
+
}
|
|
180
|
+
async subscriptionsOf(id) {
|
|
181
|
+
const reports = (await this.fetchShow(id))?.reports ?? [];
|
|
182
|
+
return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
183
|
+
}
|
|
184
|
+
async subscribersOf(id) {
|
|
185
|
+
const managers = (await this.fetchShow(id))?.managers ?? [];
|
|
186
|
+
return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
187
|
+
}
|
|
188
|
+
async view(root) {
|
|
189
|
+
const cached = this.viewCache.get(root);
|
|
190
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
191
|
+
return cached.value;
|
|
192
|
+
const seen = new Set([root]);
|
|
193
|
+
const out = [];
|
|
194
|
+
const queue = (await this.subscriptionsOf(root)).map((s) => s.node_id);
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const id = queue.shift();
|
|
197
|
+
if (seen.has(id))
|
|
198
|
+
continue;
|
|
199
|
+
seen.add(id);
|
|
200
|
+
out.push(id);
|
|
201
|
+
for (const s of await this.subscriptionsOf(id)) {
|
|
202
|
+
if (!seen.has(s.node_id))
|
|
203
|
+
queue.push(s.node_id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.viewCache.set(root, { value: out, at: Date.now() });
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
async askCountsForView(root) {
|
|
210
|
+
const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
|
|
211
|
+
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
212
|
+
}
|
|
213
|
+
async hasActiveLiveSubscription(id) {
|
|
214
|
+
const subs = (await this.subscriptionsOf(id)).filter((s) => s.active);
|
|
215
|
+
for (const s of subs) {
|
|
216
|
+
const row = await this.getRow(s.node_id);
|
|
217
|
+
if (row && (row.status === 'active' || row.status === 'idle'))
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type RemoteEventKind = 'nodes' | 'inbox';
|
|
2
|
+
export interface RemoteEventStreamOptions {
|
|
3
|
+
/** Initial reconnect backoff (ms). Default 1000; injectable so tests don't
|
|
4
|
+
* have to wait out the production backoff window. */
|
|
5
|
+
initialBackoffMs?: number;
|
|
6
|
+
/** Reconnect backoff cap (ms). Default 30_000. */
|
|
7
|
+
backoffCapMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class RemoteEventStream {
|
|
10
|
+
private readonly previewEndpoint;
|
|
11
|
+
private readonly relayToken;
|
|
12
|
+
private controller;
|
|
13
|
+
private stopped;
|
|
14
|
+
private readonly listeners;
|
|
15
|
+
private readonly initialBackoffMs;
|
|
16
|
+
private backoffMs;
|
|
17
|
+
private readonly BACKOFF_CAP_MS;
|
|
18
|
+
private reconnectTimer;
|
|
19
|
+
constructor(previewEndpoint: string, relayToken: string, opts?: RemoteEventStreamOptions);
|
|
20
|
+
subscribe(fn: (kind: RemoteEventKind) => void): () => void;
|
|
21
|
+
start(): void;
|
|
22
|
+
stop(): void;
|
|
23
|
+
private loop;
|
|
24
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// remote-event-stream.ts — a Node SSE client over a remote canvas's
|
|
2
|
+
// `/__crtr/events` endpoint, driving cache invalidation + re-render for a
|
|
3
|
+
// remote-backed `canvas browse`/viewer. No new npm dependency: Node 18+
|
|
4
|
+
// `fetch` returns a web-standard `ReadableStream` body, read manually with
|
|
5
|
+
// `getReader()` + `TextDecoder` and split on the SSE `\n\n` event delimiter.
|
|
6
|
+
export class RemoteEventStream {
|
|
7
|
+
previewEndpoint;
|
|
8
|
+
relayToken;
|
|
9
|
+
controller;
|
|
10
|
+
stopped = false;
|
|
11
|
+
listeners = new Set();
|
|
12
|
+
initialBackoffMs;
|
|
13
|
+
backoffMs;
|
|
14
|
+
BACKOFF_CAP_MS;
|
|
15
|
+
reconnectTimer;
|
|
16
|
+
constructor(previewEndpoint, relayToken, opts = {}) {
|
|
17
|
+
this.previewEndpoint = previewEndpoint;
|
|
18
|
+
this.relayToken = relayToken;
|
|
19
|
+
this.initialBackoffMs = opts.initialBackoffMs ?? 1000;
|
|
20
|
+
this.backoffMs = this.initialBackoffMs;
|
|
21
|
+
this.BACKOFF_CAP_MS = opts.backoffCapMs ?? 30_000;
|
|
22
|
+
}
|
|
23
|
+
subscribe(fn) {
|
|
24
|
+
this.listeners.add(fn);
|
|
25
|
+
return () => this.listeners.delete(fn);
|
|
26
|
+
}
|
|
27
|
+
start() {
|
|
28
|
+
this.stopped = false;
|
|
29
|
+
void this.loop();
|
|
30
|
+
}
|
|
31
|
+
stop() {
|
|
32
|
+
this.stopped = true;
|
|
33
|
+
this.controller?.abort();
|
|
34
|
+
if (this.reconnectTimer !== undefined) {
|
|
35
|
+
clearTimeout(this.reconnectTimer);
|
|
36
|
+
this.reconnectTimer = undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async loop() {
|
|
40
|
+
while (!this.stopped) {
|
|
41
|
+
this.controller = new AbortController();
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(new URL('/__crtr/events', this.previewEndpoint), {
|
|
44
|
+
headers: { authorization: `Bearer ${this.relayToken}` },
|
|
45
|
+
signal: this.controller.signal,
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok || !res.body)
|
|
48
|
+
throw new Error(`events ${res.status}`);
|
|
49
|
+
this.backoffMs = this.initialBackoffMs; // reset on a successful connect
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
let buf = '';
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done)
|
|
56
|
+
break;
|
|
57
|
+
buf += decoder.decode(value, { stream: true });
|
|
58
|
+
let idx;
|
|
59
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
60
|
+
const rawEvent = buf.slice(0, idx);
|
|
61
|
+
buf = buf.slice(idx + 2);
|
|
62
|
+
for (const line of rawEvent.split('\n')) {
|
|
63
|
+
if (!line.startsWith('data:'))
|
|
64
|
+
continue; // skip comments (: ping, : connected)
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(line.slice(5).trim());
|
|
67
|
+
if (payload.kind === 'nodes' || payload.kind === 'inbox') {
|
|
68
|
+
for (const l of this.listeners)
|
|
69
|
+
l(payload.kind);
|
|
70
|
+
}
|
|
71
|
+
// unknown kind → silently ignored (forward-compat)
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// malformed data line — ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// connection error/abort — fall through to backoff+retry unless stopped
|
|
82
|
+
}
|
|
83
|
+
if (this.stopped)
|
|
84
|
+
return;
|
|
85
|
+
await new Promise((resolve) => {
|
|
86
|
+
this.reconnectTimer = setTimeout(() => {
|
|
87
|
+
this.reconnectTimer = undefined;
|
|
88
|
+
resolve();
|
|
89
|
+
}, this.backoffMs);
|
|
90
|
+
});
|
|
91
|
+
this.backoffMs = Math.min(this.backoffMs * 2, this.BACKOFF_CAP_MS);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -119,6 +119,10 @@ export declare function dashboardRows(rootId: string): DashboardRow[];
|
|
|
119
119
|
* viewport on demand. See {@link enrichRow}, {@link enrichRows}, {@link loadPreview}.
|
|
120
120
|
*/
|
|
121
121
|
export declare function dashboardRowsAll(): DashboardRow[];
|
|
122
|
+
/** A remote source provides only graph structure (the 8 CanvasSource methods);
|
|
123
|
+
* every other per-node field below is a local disk/SQLite read and is suppressed
|
|
124
|
+
* to a safe default for a remote source — `listFocuses()` (SQLite) is skipped
|
|
125
|
+
* entirely rather than queried and discarded. */
|
|
122
126
|
export declare function dashboardRowsAllFromSource(source: CanvasSource): Promise<DashboardRow[]>;
|
|
123
127
|
/** Fold the cheap-boot row's deferred fields in for ONE row: the full label
|
|
124
128
|
* (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
|
|
@@ -129,11 +133,19 @@ export declare function enrichRow(row: DashboardRow): DashboardRow;
|
|
|
129
133
|
* distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
|
|
130
134
|
* Use this for a whole viewport / the full forest; mutates each row in place. */
|
|
131
135
|
export declare function enrichRows(rows: DashboardRow[]): void;
|
|
132
|
-
|
|
136
|
+
/** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
|
|
137
|
+
* remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
|
|
138
|
+
* `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
|
|
139
|
+
* root; the caller's job, since the right root set is a browse/snapshot
|
|
140
|
+
* concern, not this per-row enrichment loop's). See `runBrowse`'s
|
|
141
|
+
* `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
|
|
142
|
+
export declare function enrichRowsFromSource(source: CanvasSource, rows: DashboardRow[], remoteAsks?: Record<string, number>): Promise<void>;
|
|
133
143
|
/** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
|
|
134
144
|
* the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
|
|
135
145
|
* folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
|
|
136
146
|
* in place; idempotent. Call only for the cursor row (and lazily/in-background to
|
|
137
147
|
* warm the prompt super-search corpus), never on the boot path. */
|
|
138
148
|
export declare function loadPreview(row: DashboardRow): DashboardRow;
|
|
149
|
+
/** goal (initial-prompt.md) and session parts are local disk reads — suppressed
|
|
150
|
+
* to undefined for a remote source. */
|
|
139
151
|
export declare function loadPreviewFromSource(source: CanvasSource, row: DashboardRow): Promise<DashboardRow>;
|