@north-light/crouter 0.3.166 → 0.3.168

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.
@@ -34,7 +34,6 @@ import { tmpdir } from 'node:os';
34
34
  import { dirname, join } from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
37
- import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
38
37
  import { buildBrokerSession } from '../runtime/broker.js';
39
38
  // M-9 (broker.ts fault-and-die model gate): buildBrokerSession with no cfg.model
40
39
  // now calls the REAL SDK's modelRegistry.getAvailable() and faults if it's empty
@@ -83,7 +82,7 @@ test('D2 broker fork branch: buildBrokerSession(cfg.forkFrom) yields a NEW id +
83
82
  // if broker.ts:~1106 reverts to that throw, this rejects and the test goes RED.
84
83
  const cfg = {
85
84
  cwd: dir,
86
- extensionPaths: [...CANVAS_EXTENSIONS, C3_EXT],
85
+ extensionPaths: [C3_EXT],
87
86
  forkFrom: srcFile,
88
87
  model: 'c3prov/c3model',
89
88
  };
@@ -84,7 +84,7 @@ function cfg(cwd, extra = {}) {
84
84
  test('C3 — services path registers an extension model provider; the broker session gets it', async () => {
85
85
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3-'));
86
86
  try {
87
- const { session, services } = await buildBrokerSession(realEngine, cfg(cwd, { model: 'c3prov/c3model' }));
87
+ const { session, services } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT], model: 'c3prov/c3model' }));
88
88
  try {
89
89
  // The extension's provider was registered into the SERVICES model runtime (this
90
90
  // is the registerProvider step plain createAgentSession skips).
@@ -107,7 +107,7 @@ test('C3 — services path registers an extension model provider; the broker ses
107
107
  test('C3b — broker splits model thinking suffix before SDK registry lookup', async () => {
108
108
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3b-'));
109
109
  try {
110
- const { session } = await buildBrokerSession(realEngine, cfg(cwd, { model: 'c3prov/c3model:high' }));
110
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT], model: 'c3prov/c3model:high' }));
111
111
  try {
112
112
  assert.equal(session.model?.id, 'c3model', 'C3b: suffixed model specs resolve by model id instead of falling back to SDK default');
113
113
  }
@@ -420,7 +420,7 @@ test('C5 — isLeadingEngineCommand against a REAL session (registered extension
420
420
  // proven against a plain object shaped like the real accessors.
421
421
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-c5-cmd-'));
422
422
  try {
423
- const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [...brokerExts(), C5_EXT], model: 'c3prov/c3model' }));
423
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT, C5_EXT], model: 'c3prov/c3model' }));
424
424
  try {
425
425
  assert.equal(isLeadingEngineCommand('/devcmd', session), true, 'a real registered extension command matches');
426
426
  assert.equal(isLeadingEngineCommand('/devcmd extra args', session), true, 'space-separated still matches against a real session');
@@ -744,7 +744,7 @@ test('Major 6 (broker half) — engineLeadingCommandTokens degrades to empty per
744
744
  test('Major 6 (broker half) — engineLeadingCommandTokens against a REAL session enumerates the real registered extension command', async () => {
745
745
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-m6-cmd-'));
746
746
  try {
747
- const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [...brokerExts(), C5_EXT], model: 'c3prov/c3model' }));
747
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT, C5_EXT], model: 'c3prov/c3model' }));
748
748
  try {
749
749
  const tokens = engineLeadingCommandTokens(session);
750
750
  assert.ok(tokens.includes('devcmd'), 'the real registered extension command name is present in the enumeration');
@@ -6,11 +6,12 @@
6
6
  // sender (`from`) is visible in both the read body and the artifact title.
7
7
  import { test, before, after, beforeEach } from 'node:test';
8
8
  import assert from 'node:assert/strict';
9
- import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
+ import { contextDir } from '../canvas/paths.js';
14
15
  import { appendInbox } from '../feed/inbox.js';
15
16
  import { buildCorpus, resolveRef } from '../canvas/history.js';
16
17
  let home;
@@ -39,6 +40,15 @@ after(() => {
39
40
  rmSync(home, { recursive: true, force: true });
40
41
  delete process.env['CRTR_HOME'];
41
42
  });
43
+ test('history corpus skips malformed frontmatter in another cwd', () => {
44
+ const broken = createNode(node('broken-frontmatter', '/tmp/hist-broken'));
45
+ const valid = createNode(node('valid-history', '/tmp/hist-valid'));
46
+ writeFileSync(join(contextDir(broken.node_id), 'copied-source.md'), '---\ndesc: "unterminated\n---\nsource text\n');
47
+ writeFileSync(join(contextDir(valid.node_id), 'result.md'), '---\ntitle: result\n---\n0a0afd613\n');
48
+ const corpus = buildCorpus({ allCwds: true }, { types: ['report', 'doc', 'roadmap'] });
49
+ assert.equal(corpus.some((a) => a.ref === `${broken.node_id}:context/copied-source.md`), false, 'invalid artifact is skipped');
50
+ assert.ok(corpus.some((a) => a.loadBody().includes('0a0afd613')), 'valid artifacts remain searchable');
51
+ });
42
52
  test('default history excludes inbox; --type inbox includes it, with full sender-attributed bodies', () => {
43
53
  const cwd = '/tmp/hist-work';
44
54
  createNode(node('sender-1', cwd));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import test from 'node:test';
8
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
9
+ const SELF_UPDATE = join(ROOT, 'scripts', 'self-update.mjs');
10
+ test('self-update installs the downloaded release without replacing the stable global shim', () => {
11
+ const directory = mkdtempSync(join(tmpdir(), 'crtr-self-update-test-'));
12
+ try {
13
+ const release = join(directory, 'release');
14
+ const archive = join(directory, 'release.tgz');
15
+ const bin = join(directory, 'bin');
16
+ const stableShim = join(bin, 'crtr');
17
+ const marker = join(directory, 'selected');
18
+ const calls = join(directory, 'npm-calls');
19
+ const fakeBin = join(directory, 'fake-bin');
20
+ mkdirSync(join(release, 'scripts'), { recursive: true });
21
+ mkdirSync(bin);
22
+ mkdirSync(fakeBin);
23
+ writeFileSync(join(release, 'scripts', 'install-runtime.mjs'), `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.CRTR_SELF_UPDATE_MARKER, 'selected');\n`);
24
+ execFileSync('tar', ['-czf', archive, '-C', release, '.']);
25
+ symlinkSync('../stable-crtr', stableShim);
26
+ writeFileSync(join(fakeBin, 'npm'), `#!/usr/bin/env node\nimport { copyFileSync, appendFileSync } from 'node:fs';\nimport { basename, join } from 'node:path';\nconst args = process.argv.slice(2);\nappendFileSync(process.env.CRTR_SELF_UPDATE_CALLS, args.join(' ') + '\\n');\nif (args[0] !== 'pack') process.exit(1);\nconst destination = args[args.indexOf('--pack-destination') + 1];\ncopyFileSync(process.env.CRTR_SELF_UPDATE_ARCHIVE, join(destination, basename(process.env.CRTR_SELF_UPDATE_ARCHIVE)));\nprocess.stdout.write(JSON.stringify([{ filename: basename(process.env.CRTR_SELF_UPDATE_ARCHIVE) }]));\n`);
27
+ chmodSync(join(fakeBin, 'npm'), 0o755);
28
+ const result = spawnSync(process.execPath, [SELF_UPDATE], {
29
+ encoding: 'utf8',
30
+ env: {
31
+ ...process.env,
32
+ PATH: `${fakeBin}:${process.env.PATH}`,
33
+ CRTR_SELF_UPDATE_ARCHIVE: archive,
34
+ CRTR_SELF_UPDATE_CALLS: calls,
35
+ CRTR_SELF_UPDATE_MARKER: marker,
36
+ },
37
+ });
38
+ assert.equal(result.status, 0, result.stderr);
39
+ assert.equal(readFileSync(marker, 'utf8'), 'selected');
40
+ assert.equal(readlinkSync(stableShim), '../stable-crtr');
41
+ assert.match(readFileSync(calls, 'utf8'), /^pack @north-light\/crouter@latest --json --pack-destination .+\/crtr-self-update-\S+\n$/);
42
+ }
43
+ finally {
44
+ rmSync(directory, { recursive: true, force: true });
45
+ }
46
+ });
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { readConfig, readState, updateState } from './config.js';
3
3
  import { nowIso } from './fs-utils.js';
4
4
  import { info } from './output.js';
5
- import { selfCheck, contentCheck } from './self-update.js';
5
+ import { SELF_UPDATE_SCRIPT, selfCheck, contentCheck } from './self-update.js';
6
6
  const HOUR_MS = 60 * 60 * 1000;
7
7
  const SKIP_SUBCOMMANDS = new Set([
8
8
  'update',
@@ -27,7 +27,7 @@ function withinInterval(lastIso, intervalHours) {
27
27
  return Date.now() - last < intervalHours * HOUR_MS;
28
28
  }
29
29
  function spawnDetachedSelfUpdate() {
30
- const child = spawn('npm', ['i', '-g', '@north-light/crouter@latest'], {
30
+ const child = spawn(process.execPath, [SELF_UPDATE_SCRIPT], {
31
31
  detached: true,
32
32
  stdio: 'ignore',
33
33
  });
@@ -15,6 +15,7 @@ import { getNode, listNodes, view } from './canvas.js';
15
15
  import { contextDir, reportsDir, nodeDir, inboxPath } from './paths.js';
16
16
  import { readTextIfExists, walkFiles, pathExists } from '../fs-utils.js';
17
17
  import { parseFrontmatterGeneric } from '../frontmatter.js';
18
+ import { warn } from '../output.js';
18
19
  /** The cwd the caller means by default: the calling node's cwd (resolved from
19
20
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
20
21
  * when not run as a node. */
@@ -242,6 +243,11 @@ function listDocFiles(nodeId) {
242
243
  return [];
243
244
  }
244
245
  }
246
+ /** Keep one malformed artifact from breaking a cross-node corpus scan. */
247
+ function warnInvalidFrontmatter(path, error) {
248
+ const message = (error instanceof Error ? error.message : String(error)).split('\n')[0];
249
+ warn(`invalid frontmatter in ${path}: ${message}`);
250
+ }
245
251
  /** The node desc lives in meta.json, not the row — read it once per survivor. */
246
252
  function nodeDescOf(nodeId) {
247
253
  const n = getNode(nodeId);
@@ -281,7 +287,15 @@ export function buildCorpus(scope, corpus) {
281
287
  out.push(metaArtifact(row, desc));
282
288
  if (want.has('report')) {
283
289
  for (const f of listReportFiles(row.node_id)) {
284
- const a = reportArtifact(row, desc, f);
290
+ const abs = join(reportsDir(row.node_id), f);
291
+ let a;
292
+ try {
293
+ a = reportArtifact(row, desc, f);
294
+ }
295
+ catch (e) {
296
+ warnInvalidFrontmatter(abs, e);
297
+ continue;
298
+ }
285
299
  if (a === null)
286
300
  continue;
287
301
  if (wantReportKind !== undefined && a.reportKind !== wantReportKind)
@@ -291,7 +305,14 @@ export function buildCorpus(scope, corpus) {
291
305
  }
292
306
  if (want.has('doc') || want.has('roadmap')) {
293
307
  for (const abs of listDocFiles(row.node_id)) {
294
- const a = docArtifact(row, desc, abs);
308
+ let a;
309
+ try {
310
+ a = docArtifact(row, desc, abs);
311
+ }
312
+ catch (e) {
313
+ warnInvalidFrontmatter(abs, e);
314
+ continue;
315
+ }
295
316
  if (a.source === 'roadmap' && !want.has('roadmap'))
296
317
  continue;
297
318
  if (a.source === 'doc' && !want.has('doc'))
@@ -1,17 +1,8 @@
1
- /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
2
- * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
3
- * it exists but isn't ours — still alive (a foreign process is never OUR
4
- * zombie, so the `isZombie` check is skipped for that branch — see below). A
5
- * null/undefined pid (legacy / never-booted) reads dead.
6
- *
7
- * The zombie check matters because EVERY broker this module supervises is
8
- * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
9
- * dead broker whose spawning process hasn't reaped it yet is a zombie, and
10
- * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
11
- * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
12
- * already-live-looking row, so a refreshed/crashed node whose broker zombied
13
- * out under a long-lived spawning process (see `isZombie`'s doc) never comes
14
- * back on its own. */
1
+ /** True if a process with `pid` is currently alive. `kill(pid, 0)` provides the
2
+ * initial existence check; EPERM means the foreign process exists. For our
3
+ * processes, the later `ps` read is authoritative because it also distinguishes
4
+ * zombies and processes reaped between the two probes. Only an unavailable
5
+ * `ps` probe fails open to alive. A null/undefined pid reads dead. */
15
6
  export declare function isPidAlive(pid: number | null | undefined): boolean;
16
7
  /** THE single precision-aware identity compare, used EVERYWHERE two
17
8
  * `composeIdentity` fingerprints are compared (this module's own
@@ -10,46 +10,31 @@
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { readKernelBootId } from './boot-id.js';
13
- /** Is `pid` a ZOMBIE (exited, awaiting reap by its parent)? `kill(pid, 0)`
14
- * reports a zombie as a perfectly normal alive process — its PID is still
15
- * occupied in the process table — so callers relying on that signal-0 probe
16
- * alone (see `isPidAlive`) misread a dead broker as alive forever whenever
17
- * its spawning process never reaps it (crouton-labs zombie-broker bug: the
18
- * front door's foreground attach session parks its OWN event loop for the
19
- * whole session — see `bootRoot` in runtime/boot-root.ts — so it can't process
20
- * the SIGCHLD that would otherwise reap its detached broker child in real
21
- * time). `ps -o stat=` reports a leading `Z` for a zombie on BOTH BSD ps
22
- * (macOS) and procps-ng (Linux), so one portable probe covers both platforms
23
- * without a Linux-only `/proc` special case. Best-effort: any probe failure
24
- * (spawn error, no matching row, non-zero exit) reads as NOT a zombie —
25
- * fail-open, matching this module's other guards; a probe failure must never
26
- * be misread as proof of death (a live pid IS alive; only a positive `Z`
27
- * read says otherwise). */
28
- function isZombie(pid) {
13
+ /** Classify the current `ps` row for a pid. BSD ps and procps-ng both report a
14
+ * leading `Z` for zombies and exit silently with empty output when no process
15
+ * matches. Only a probe that could not run is unknown; that is the sole
16
+ * fail-open result. */
17
+ function psProcessState(pid) {
29
18
  try {
30
19
  const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
31
- if (r.status !== 0 || typeof r.stdout !== 'string')
32
- return false;
33
- return r.stdout.trim().charAt(0) === 'Z';
20
+ if (r.error != null || r.signal != null || typeof r.stdout !== 'string')
21
+ return 'unknown';
22
+ if (typeof r.stderr === 'string' && r.stderr.trim() !== '')
23
+ return 'unknown';
24
+ const stat = r.stdout.trim();
25
+ if (stat === '')
26
+ return 'gone';
27
+ return stat.charAt(0) === 'Z' ? 'zombie' : 'live';
34
28
  }
35
29
  catch {
36
- return false;
30
+ return 'unknown';
37
31
  }
38
32
  }
39
- /** True if a process with `pid` is currently alive (signal-0 probe) AND not a
40
- * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
41
- * it exists but isn't ours — still alive (a foreign process is never OUR
42
- * zombie, so the `isZombie` check is skipped for that branch — see below). A
43
- * null/undefined pid (legacy / never-booted) reads dead.
44
- *
45
- * The zombie check matters because EVERY broker this module supervises is
46
- * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
47
- * dead broker whose spawning process hasn't reaped it yet is a zombie, and
48
- * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
49
- * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
50
- * already-live-looking row, so a refreshed/crashed node whose broker zombied
51
- * out under a long-lived spawning process (see `isZombie`'s doc) never comes
52
- * back on its own. */
33
+ /** True if a process with `pid` is currently alive. `kill(pid, 0)` provides the
34
+ * initial existence check; EPERM means the foreign process exists. For our
35
+ * processes, the later `ps` read is authoritative because it also distinguishes
36
+ * zombies and processes reaped between the two probes. Only an unavailable
37
+ * `ps` probe fails open to alive. A null/undefined pid reads dead. */
53
38
  export function isPidAlive(pid) {
54
39
  if (pid == null)
55
40
  return false;
@@ -59,7 +44,8 @@ export function isPidAlive(pid) {
59
44
  catch (e) {
60
45
  return e.code === 'EPERM';
61
46
  }
62
- return !isZombie(pid);
47
+ const state = psProcessState(pid);
48
+ return state !== 'zombie' && state !== 'gone';
63
49
  }
64
50
  /** Matches the kernel `boot_id` UUID shape (`/proc/sys/kernel/random/boot_id`),
65
51
  * used to tell a NEW-format identity base (a per-boot UUID) apart from a
@@ -1,3 +1,4 @@
1
+ export declare const SELF_UPDATE_SCRIPT: string;
1
2
  export declare function currentVersion(): string;
2
3
  export declare function selfUpdate(): void;
3
4
  /** Check whether a newer crtr version is available on npm.
@@ -15,16 +15,18 @@ const __dirname = dirname(__filename);
15
15
  // src/core/self-update.ts → up to src/ → up to pkg root
16
16
  const PKG_ROOT = join(__dirname, '..', '..');
17
17
  const PACKAGE_JSON_PATH = join(PKG_ROOT, 'package.json');
18
+ export const SELF_UPDATE_SCRIPT = join(PKG_ROOT, 'scripts', 'self-update.mjs');
18
19
  export function currentVersion() {
19
20
  const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
20
21
  const parsed = JSON.parse(raw);
21
22
  return parsed.version;
22
23
  }
23
24
  export function selfUpdate() {
24
- const res = spawnSync('npm', ['i', '-g', '@north-light/crouter@latest'], { stdio: 'inherit' });
25
- if (res.status !== 0) {
26
- throw general('npm install failed');
27
- }
25
+ const res = spawnSync(process.execPath, [SELF_UPDATE_SCRIPT], { stdio: 'inherit' });
26
+ if (res.error)
27
+ throw general(`self-update failed: ${res.error.message}`);
28
+ if (res.status !== 0)
29
+ throw general(`self-update failed (${res.status ?? res.signal ?? 'unknown'})`);
28
30
  }
29
31
  /** Check whether a newer crtr version is available on npm.
30
32
  * Warns to stderr if network unavailable; returns {current, latest} or null if unreachable. */