@crouton-kit/crouter 0.3.39 → 0.3.41

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.
Files changed (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -0,0 +1,77 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/session-cycles.test.ts
2
+ //
3
+ // `cycleAwareMessages` reuses pi's own `buildSessionContext` to replay a PRIOR
4
+ // cycle's branch, so a cycle that was compacted before it yielded renders its
5
+ // compaction summary (and only the kept + post-compaction messages) instead of
6
+ // every raw pre-compaction message. This is a light smoke test — the
7
+ // compaction resolution itself is SDK behavior being delegated to, not
8
+ // reimplemented here; this only proves the wiring is real.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { buildSessionContext } from '@earendil-works/pi-coding-agent';
12
+ import { cycleAwareMessages, CRTR_CYCLE_CUSTOM_TYPE } from '../runtime/session-cycles.js';
13
+ function userMsg(id, parentId, text) {
14
+ return {
15
+ type: 'message',
16
+ id,
17
+ parentId,
18
+ timestamp: new Date(0).toISOString(),
19
+ message: { role: 'user', content: text, timestamp: 0 },
20
+ };
21
+ }
22
+ test('cycleAwareMessages: a compacted prior cycle replays via buildSessionContext (one summary, no raw pre-compaction messages)', () => {
23
+ // Cycle 0: m1 -> m2 -> [compaction c1, keeping from m2] -> m3 (cycle 0's leaf
24
+ // at yield time). Cycle marker cy1 roots a FRESH branch (parentId: null, per
25
+ // resetLeaf()+appendCustomEntry semantics), fromLeaf points back at m3. Cycle
26
+ // 1: m4 is the live leaf.
27
+ const m1 = userMsg('m1', null, 'pre-compaction message — must NOT appear in the replay');
28
+ const m2 = userMsg('m2', 'm1', 'kept message — compaction keeps from here');
29
+ const c1 = {
30
+ type: 'compaction',
31
+ id: 'c1',
32
+ parentId: 'm2',
33
+ timestamp: new Date(0).toISOString(),
34
+ summary: 'summary of the abandoned context',
35
+ firstKeptEntryId: 'm2',
36
+ tokensBefore: 12_000,
37
+ };
38
+ const m3 = userMsg('m3', 'c1', 'post-compaction message, cycle 0 leaf at yield');
39
+ const cy1 = {
40
+ type: 'custom',
41
+ id: 'cy1',
42
+ parentId: null,
43
+ timestamp: new Date(1).toISOString(),
44
+ customType: CRTR_CYCLE_CUSTOM_TYPE,
45
+ data: { cycle: 2, fromLeaf: 'm3' },
46
+ };
47
+ const m4 = userMsg('m4', 'cy1', 'cycle 1 live message');
48
+ const entries = [m1, m2, c1, m3, cy1, m4];
49
+ const byId = new Map(entries.map((e) => [e.id, e]));
50
+ function branchFrom(fromId) {
51
+ const path = [];
52
+ let current = fromId ? byId.get(fromId) : undefined;
53
+ while (current) {
54
+ path.push(current);
55
+ current = current.parentId ? byId.get(current.parentId) : undefined;
56
+ }
57
+ return path.reverse();
58
+ }
59
+ // Minimal fake mirroring the real SDK's SessionManager just enough to drive
60
+ // cycleAwareMessages: buildSessionContext() (current leaf only, m4) plus the
61
+ // tree accessors it reads (getEntries/getBranch).
62
+ const sm = {
63
+ buildSessionContext: () => buildSessionContext(entries, 'm4', byId),
64
+ getEntries: () => entries,
65
+ getBranch: (fromId) => branchFrom(fromId ?? 'm4'),
66
+ };
67
+ const out = cycleAwareMessages(sm);
68
+ const summaries = out.filter((m) => m.role === 'compactionSummary');
69
+ assert.equal(summaries.length, 1, 'exactly one compactionSummary message from the replayed prior cycle');
70
+ const texts = out.map((m) => (typeof m.content === 'string' ? m.content : ''));
71
+ assert.ok(!texts.some((t) => t.includes('pre-compaction message')), 'the pre-compaction raw message (m1) must NOT appear');
72
+ assert.ok(texts.some((t) => t.includes('kept message')), 'the kept message (m2) DOES appear');
73
+ assert.ok(texts.some((t) => t.includes('cycle 0 leaf at yield')), 'the post-compaction message (m3) DOES appear');
74
+ assert.ok(texts.some((t) => t.includes('cycle 1 live message')), 'the live cycle (m4) DOES appear');
75
+ const dividers = out.filter((m) => m.customType === 'crtr-cycle-divider');
76
+ assert.equal(dividers.length, 1, 'one divider between the replayed prior cycle and the live one');
77
+ });
@@ -0,0 +1,85 @@
1
+ import { test, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { gitSync } from '../git.js';
7
+ import { createManagedWorktree, closeManagedWorktree, managedWorktreePath } from '../worktree.js';
8
+ import { createNode, getNode } from '../canvas/canvas.js';
9
+ import { closeDb } from '../canvas/db.js';
10
+ let home;
11
+ let remoteDir;
12
+ let sourceDir;
13
+ function run(args, cwd) {
14
+ const res = gitSync(args, cwd);
15
+ if (res.status !== 0) {
16
+ throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${res.stderr || res.stdout}`);
17
+ }
18
+ }
19
+ function node(id, over = {}) {
20
+ return {
21
+ node_id: id,
22
+ name: id,
23
+ created: new Date().toISOString(),
24
+ cwd: '/tmp/work',
25
+ kind: 'general',
26
+ mode: 'base',
27
+ lifecycle: 'terminal',
28
+ status: 'active',
29
+ ...over,
30
+ };
31
+ }
32
+ before(() => {
33
+ home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
34
+ process.env['CRTR_HOME'] = home;
35
+ // Bare "remote" the source repo pushes to, so `origin/main` resolves.
36
+ remoteDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-remote-'));
37
+ run(['init', '--bare'], remoteDir);
38
+ sourceDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-source-'));
39
+ run(['init', '-b', 'main'], sourceDir);
40
+ run(['config', 'user.email', 'test@example.com'], sourceDir);
41
+ run(['config', 'user.name', 'Test'], sourceDir);
42
+ writeFileSync(join(sourceDir, 'README.md'), 'hello\n');
43
+ run(['add', '.'], sourceDir);
44
+ run(['commit', '-m', 'init'], sourceDir);
45
+ run(['remote', 'add', 'origin', remoteDir], sourceDir);
46
+ run(['push', '-u', 'origin', 'main'], sourceDir);
47
+ });
48
+ beforeEach(() => {
49
+ closeDb();
50
+ rmSync(home, { recursive: true, force: true });
51
+ process.env['CRTR_HOME'] = home;
52
+ });
53
+ after(() => {
54
+ closeDb();
55
+ rmSync(home, { recursive: true, force: true });
56
+ rmSync(remoteDir, { recursive: true, force: true });
57
+ rmSync(sourceDir, { recursive: true, force: true });
58
+ delete process.env['CRTR_HOME'];
59
+ });
60
+ // Regression for #120: `git worktree remove` deletes the checkout directory
61
+ // that IS the calling node's own pi-process cwd. A process can never chdir
62
+ // itself out of a directory that vanishes out from under it, so once that
63
+ // dir is gone every subsequent bash in that node fails permanently and the
64
+ // mandated post-close `crtr push final` can never run. closeManagedWorktree
65
+ // must leave the path resolvable (as an empty tombstone dir) even though the
66
+ // git worktree registration itself is gone.
67
+ test('closeManagedWorktree recreates the worktree path as a tombstone dir so the node cwd still resolves', () => {
68
+ const nodeId = 'wt-close-1';
69
+ const wt = createManagedWorktree(sourceDir, nodeId);
70
+ assert.equal(wt.path, managedWorktreePath(nodeId));
71
+ assert.ok(existsSync(wt.path), 'worktree checkout should exist after create');
72
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
73
+ const result = closeManagedWorktree(nodeId);
74
+ assert.equal(result.worktree_removed, true);
75
+ // The path must still resolve as a directory — this is the actual fix: a
76
+ // node whose cwd was wt.path can still chdir there / run bash / push final.
77
+ assert.ok(existsSync(wt.path), 'worktree path must still exist after close (tombstone)');
78
+ assert.ok(statSync(wt.path).isDirectory(), 'recreated path must be a directory');
79
+ assert.deepEqual(readdirSync(wt.path), [], 'tombstone dir should be empty — git already removed the real checkout');
80
+ // git itself no longer tracks it as a worktree.
81
+ const list = gitSync(['worktree', 'list', '--porcelain'], sourceDir);
82
+ assert.ok(!list.stdout.includes(wt.path), 'git should no longer register the removed worktree');
83
+ const meta = getNode(nodeId);
84
+ assert.equal(meta?.managed_worktree?.state, 'closed');
85
+ });
@@ -1,6 +1,6 @@
1
1
  // Detects a boot change (a host reboot, or — the case this exists for — a
2
- // Blaxel/Firecracker microVM destroy+recreate) so callers can stop trusting any
3
- // `pi_pid` recorded before it.
2
+ // VM/sandbox destroy+recreate) so callers can stop trusting any `pi_pid`
3
+ // recorded before it.
4
4
  //
5
5
  // canvas.db is durable — it survives a VM/sandbox destroy+recreate or a host
6
6
  // reboot on whatever volume it lives on (see paths.ts / the storage-tiers
@@ -14,13 +14,13 @@
14
14
  //
15
15
  // The signal (primary): the Linux kernel's per-boot identity at
16
16
  // `/proc/sys/kernel/random/boot_id` — a fresh random UUID minted on every
17
- // kernel boot. A Firecracker microVM recreate (Blaxel's deployment shape) boots
17
+ // kernel boot. A microVM recreate (a remote/guest deployment's shape) boots
18
18
  // a brand-new kernel, so `boot_id` changes; a plain daemon restart within the
19
19
  // same boot leaves it identical. This is an EXACT identity, so on Linux we need
20
20
  // no wall-clock window at all. NOTE this detects a *kernel boot*, not an
21
21
  // arbitrary container recreate: an ordinary container that shares the host
22
22
  // kernel keeps the host's `boot_id` across recreation, so this would NOT fire
23
- // there — that shape is out of scope (Hearth runs on Firecracker microVMs).
23
+ // there — that shape is out of scope.
24
24
  //
25
25
  // The signal (fallback, non-Linux dev only): `os.uptime()` resets to ~0 on a
26
26
  // reboot and is otherwise monotonic, so `now - uptime` estimates the current
@@ -44,8 +44,8 @@ import { openDb } from './db.js';
44
44
  * dev). A false positive would clear a genuinely live pid and let reviveNode
45
45
  * double-spawn onto the same session; a false negative just falls back to the
46
46
  * manual `--fresh` status quo — so it is kept generous, biased against the
47
- * dangerous direction. The production target (Linux/Firecracker) never reaches
48
- * this branch; there `boot_id` is exact. */
47
+ * dangerous direction. The production target (Linux microVM guests) never
48
+ * reaches this branch; there `boot_id` is exact. */
49
49
  const BOOT_EPOCH_TOLERANCE_MS = 60_000;
50
50
  function readKernelBootId() {
51
51
  try {
@@ -135,7 +135,12 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
135
135
  const rows = db
136
136
  .prepare('SELECT node_id FROM nodes WHERE pi_pid IS NOT NULL')
137
137
  .all();
138
- const upd = db.prepare('UPDATE nodes SET pi_pid = NULL WHERE node_id = ?');
138
+ // Clear the launch-time identity baseline (crouter#98 review finding 1)
139
+ // alongside `pi_pid` — a boot resets the OS process table, so any recorded
140
+ // identity is as meaningless as the pid it was captured for; leaving it
141
+ // behind would let a coincidentally-reused pid on the NEW boot compare
142
+ // against a stale baseline from the OLD one.
143
+ const upd = db.prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?');
139
144
  const cleared = [];
140
145
  for (const row of rows) {
141
146
  upd.run(row.node_id);
@@ -32,9 +32,23 @@ export declare function setPresence(nodeId: string, presence: {
32
32
  window?: string | null;
33
33
  pane?: string | null;
34
34
  }): void;
35
- /** Record the live pi pid (daemon liveness signal). Atomic single-column write. */
35
+ /** Record the live pi pid (daemon liveness signal) PLUS its launch-time
36
+ * identity fingerprint, in one atomic write (crouter#98 review finding 1):
37
+ * `capturePidIdentities([pid])` is a signal-0-adjacent `ps -p <pid>` probe
38
+ * taken RIGHT NOW, while `pid` is the process we just spawned/bound and
39
+ * therefore definitely still exists — this is the LAUNCH-time baseline
40
+ * `headlessBrokerHost.teardown()` later compares against before signaling
41
+ * anything. A failed probe (rare — `ps` itself misbehaving) or a probe that
42
+ * finds no row for `pid` (raced away in the instant between spawn and this
43
+ * call) both write `NULL`: no baseline recorded, so the teardown guard fails
44
+ * open exactly like a node booted before this column existed — never a false
45
+ * identity that could itself cause a false mismatch later. */
36
46
  export declare function recordPid(nodeId: string, pid: number): void;
37
- /** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it). */
47
+ /** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it)
48
+ * AND its recorded launch-time identity — the two are written together by
49
+ * `recordPid` and must be cleared together, or a subsequent relaunch that
50
+ * reuses the pid namespace could compare its own fresh pid against a STALE
51
+ * identity left over from the pid this row held before. */
38
52
  export declare function clearPid(nodeId: string): void;
39
53
  /** All rows, optionally filtered by status. */
40
54
  export declare function listNodes(filter?: {
@@ -121,14 +135,19 @@ export declare function pruneNodes(opts: {
121
135
  * `max(maxNodes, liveCount)`. Bounds the otherwise-unbounded growth the TTL
122
136
  * sweep can't (a canvas that never crosses the TTL still grows without limit).
123
137
  *
124
- * Liveness (protected, always kept): status active|idle AND a live `pi_pid` —
125
- * the SAME signal `pruneNodes`'s `includeStale` and placement use (`isPidAlive`).
126
- * The caller ($CRTR_NODE_ID) is also always protected.
138
+ * Protected (never a prune candidate, regardless of recency rank): any
139
+ * non-terminal node status active|idle REGARDLESS of `pi_pid` liveness.
140
+ * A crashed-but-non-terminal row (dead pid, status still active/idle) must
141
+ * survive for daemon crash-recovery to resume, not be hard-deleted by a
142
+ * count-based sweep — only a genuinely terminal/dead-lifecycle row is
143
+ * eligible for count-based pruning. The caller ($CRTR_NODE_ID) is also always
144
+ * protected.
127
145
  *
128
146
  * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
129
- * to `created`); `target = max(maxNodes, liveCount)`; delete every node ranked
130
- * beyond `target` (0-indexed rank >= target) that is NOT live and NOT the caller.
131
- * Retained = all live + the newest terminal nodes up to `target`.
147
+ * to `created`); `target = max(maxNodes, protectedCount)`; delete every node
148
+ * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and
149
+ * NOT the caller. Retained = all protected + the newest terminal nodes up to
150
+ * `target`.
132
151
  *
133
152
  * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
134
153
  * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readFileSync, renameSync, readdirSync, rmSync, openSync, writeSync, fsyncSync, closeSync, statSync, } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
9
  import { openDb } from './db.js';
10
- import { isPidAlive } from './pid.js';
10
+ import { isPidAlive, capturePidIdentities } from './pid.js';
11
11
  import { cancelTriggersFor } from './triggers.js';
12
12
  import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, } from './paths.js';
13
13
  // ---------------------------------------------------------------------------
@@ -23,7 +23,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
23
23
  const IDENTITY_KEYS = [
24
24
  'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
25
25
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'passive_default',
26
- 'pi_session_id', 'pi_session_file', 'model_override', 'launch',
26
+ 'pi_session_id', 'pi_session_file', 'cycle_pending', 'model_override', 'launch', 'managed_worktree',
27
27
  ];
28
28
  /** Project any node object down to its durable-identity subset. */
29
29
  function toIdentity(m) {
@@ -45,9 +45,9 @@ function readMeta(nodeId) {
45
45
  // Tolerate an EMPTY or unparseable meta.json by treating it as MISSING (null)
46
46
  // rather than throwing. A bare write-temp-then-rename without fsync can leave a
47
47
  // 0-byte meta.json on an NFS-backed home after the holding process/sandbox dies
48
- // (see writeMeta + the Hearth M0 cold-boot bug); a single corrupt node must not
49
- // crash rebuildIndex/revive for the whole canvas. Recovery (re-seed from a
50
- // durable snapshot) is the caller's job.
48
+ // (see writeMeta + the guest cold-boot bug it guards against); a single corrupt
49
+ // node must not crash rebuildIndex/revive for the whole canvas. Recovery
50
+ // (re-seed from a durable snapshot) is the caller's job.
51
51
  const raw = readFileSync(p, 'utf8');
52
52
  if (raw.trim() === '')
53
53
  return null;
@@ -62,12 +62,13 @@ function readMeta(nodeId) {
62
62
  *
63
63
  * Durable write: fsync the temp file before the rename, then fsync the parent
64
64
  * directory so the rename itself is on disk. A bare writeFileSync+rename can
65
- * leave a 0-byte meta.json on an NFS-backed home (Hearth's durable Volume mounts
66
- * `local_lock=none,noac`): when meta.json is held open, the rename silly-renames
67
- * the open inode and the new content is never flushed, so a sandbox
65
+ * leave a 0-byte meta.json on an NFS-backed guest home (a durable network Volume
66
+ * mounted `local_lock=none,noac`): when meta.json is held open, the rename
67
+ * silly-renames the open inode and the new content is never flushed, so a sandbox
68
68
  * recreate-from-template loses it. meta.json is written rarely (birth, polymorph,
69
69
  * session-id capture, naming) so the per-write fsync cost is negligible.
70
- * Bug: Hearth M0 cold-boot 0-byte meta.json → rebuild-index/revive JSON.parse(''). */
70
+ * Bug guarded against: a guest cold-boot 0-byte meta.json → rebuild-index/revive
71
+ * JSON.parse(''). */
71
72
  function writeMeta(meta) {
72
73
  const p = nodeMetaPath(meta.node_id);
73
74
  const tmp = `${p}.tmp`;
@@ -143,6 +144,7 @@ function rowFrom(r) {
143
144
  created: r['created'],
144
145
  intent: r['intent'] ?? null,
145
146
  pi_pid: r['pi_pid'] ?? null,
147
+ pi_pid_identity: r['pi_pid_identity'] ?? null,
146
148
  window: r['window'] ?? null,
147
149
  tmux_session: r['tmux_session'] ?? null,
148
150
  pane: r['pane'] ?? null,
@@ -152,7 +154,7 @@ function rowFrom(r) {
152
154
  * no row exists yet (the hydration then falls back to whatever the meta held). */
153
155
  function runtimeFromRow(nodeId) {
154
156
  const r = openDb()
155
- .prepare('SELECT status, intent, pi_pid, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
157
+ .prepare('SELECT status, intent, pi_pid, pi_pid_identity, "window", tmux_session, pane FROM nodes WHERE node_id = ?')
156
158
  .get(nodeId);
157
159
  if (r === undefined)
158
160
  return null;
@@ -160,6 +162,7 @@ function runtimeFromRow(nodeId) {
160
162
  status: r['status'] ?? 'active',
161
163
  intent: r['intent'] ?? null,
162
164
  pi_pid: r['pi_pid'] ?? null,
165
+ pi_pid_identity: r['pi_pid_identity'] ?? null,
163
166
  window: r['window'] ?? null,
164
167
  tmux_session: r['tmux_session'] ?? null,
165
168
  pane: r['pane'] ?? null,
@@ -244,13 +247,30 @@ export function setPresence(nodeId, presence) {
244
247
  .prepare('UPDATE nodes SET tmux_session = ?, "window" = ?, pane = ? WHERE node_id = ?')
245
248
  .run(presence.tmux_session ?? null, presence.window ?? null, presence.pane ?? null, nodeId);
246
249
  }
247
- /** Record the live pi pid (daemon liveness signal). Atomic single-column write. */
250
+ /** Record the live pi pid (daemon liveness signal) PLUS its launch-time
251
+ * identity fingerprint, in one atomic write (crouter#98 review finding 1):
252
+ * `capturePidIdentities([pid])` is a signal-0-adjacent `ps -p <pid>` probe
253
+ * taken RIGHT NOW, while `pid` is the process we just spawned/bound and
254
+ * therefore definitely still exists — this is the LAUNCH-time baseline
255
+ * `headlessBrokerHost.teardown()` later compares against before signaling
256
+ * anything. A failed probe (rare — `ps` itself misbehaving) or a probe that
257
+ * finds no row for `pid` (raced away in the instant between spawn and this
258
+ * call) both write `NULL`: no baseline recorded, so the teardown guard fails
259
+ * open exactly like a node booted before this column existed — never a false
260
+ * identity that could itself cause a false mismatch later. */
248
261
  export function recordPid(nodeId, pid) {
249
- openDb().prepare('UPDATE nodes SET pi_pid = ? WHERE node_id = ?').run(pid, nodeId);
250
- }
251
- /** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it). */
262
+ const identity = capturePidIdentities([pid])?.get(pid) ?? null;
263
+ openDb()
264
+ .prepare('UPDATE nodes SET pi_pid = ?, pi_pid_identity = ? WHERE node_id = ?')
265
+ .run(pid, identity, nodeId);
266
+ }
267
+ /** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it)
268
+ * AND its recorded launch-time identity — the two are written together by
269
+ * `recordPid` and must be cleared together, or a subsequent relaunch that
270
+ * reuses the pid namespace could compare its own fresh pid against a STALE
271
+ * identity left over from the pid this row held before. */
252
272
  export function clearPid(nodeId) {
253
- openDb().prepare('UPDATE nodes SET pi_pid = NULL WHERE node_id = ?').run(nodeId);
273
+ openDb().prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?').run(nodeId);
254
274
  }
255
275
  /** All rows, optionally filtered by status. */
256
276
  export function listNodes(filter) {
@@ -522,14 +542,19 @@ function recencyMs(nodeId, created) {
522
542
  * `max(maxNodes, liveCount)`. Bounds the otherwise-unbounded growth the TTL
523
543
  * sweep can't (a canvas that never crosses the TTL still grows without limit).
524
544
  *
525
- * Liveness (protected, always kept): status active|idle AND a live `pi_pid` —
526
- * the SAME signal `pruneNodes`'s `includeStale` and placement use (`isPidAlive`).
527
- * The caller ($CRTR_NODE_ID) is also always protected.
545
+ * Protected (never a prune candidate, regardless of recency rank): any
546
+ * non-terminal node status active|idle REGARDLESS of `pi_pid` liveness.
547
+ * A crashed-but-non-terminal row (dead pid, status still active/idle) must
548
+ * survive for daemon crash-recovery to resume, not be hard-deleted by a
549
+ * count-based sweep — only a genuinely terminal/dead-lifecycle row is
550
+ * eligible for count-based pruning. The caller ($CRTR_NODE_ID) is also always
551
+ * protected.
528
552
  *
529
553
  * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
530
- * to `created`); `target = max(maxNodes, liveCount)`; delete every node ranked
531
- * beyond `target` (0-indexed rank >= target) that is NOT live and NOT the caller.
532
- * Retained = all live + the newest terminal nodes up to `target`.
554
+ * to `created`); `target = max(maxNodes, protectedCount)`; delete every node
555
+ * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and
556
+ * NOT the caller. Retained = all protected + the newest terminal nodes up to
557
+ * `target`.
533
558
  *
534
559
  * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
535
560
  * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
@@ -538,20 +563,23 @@ export function pruneToLimit(opts) {
538
563
  const dryRun = opts.dryRun ?? false;
539
564
  const selfId = process.env['CRTR_NODE_ID'] ?? '';
540
565
  const rows = listNodes();
541
- // Rank ALL nodes by recency DESC, tagging liveness as we go.
566
+ // Rank ALL nodes by recency DESC, tagging protection as we go. Protection is
567
+ // status-only (active|idle) — NOT gated on pi_pid liveness — so a node whose
568
+ // broker crashed but whose row is still non-terminal stays protected for
569
+ // crash-recovery instead of being hard-deleted here.
542
570
  const ranked = rows
543
571
  .map((r) => ({
544
572
  node_id: r.node_id,
545
573
  status: r.status,
546
574
  created: r.created,
547
575
  recency: recencyMs(r.node_id, r.created),
548
- live: (r.status === 'active' || r.status === 'idle') && isPidAlive(r.pi_pid),
576
+ protected: r.status === 'active' || r.status === 'idle',
549
577
  }))
550
578
  .sort((a, b) => b.recency - a.recency);
551
- const liveCount = ranked.filter((r) => r.live).length;
552
- const target = Math.max(opts.maxNodes, liveCount);
579
+ const protectedCount = ranked.filter((r) => r.protected).length;
580
+ const target = Math.max(opts.maxNodes, protectedCount);
553
581
  const candidates = ranked
554
- .filter((r, rank) => rank >= target && !r.live && r.node_id !== selfId)
582
+ .filter((r, rank) => rank >= target && !r.protected && r.node_id !== selfId)
555
583
  .map((r) => ({ node_id: r.node_id, status: r.status, created: r.created }));
556
584
  if (dryRun || candidates.length === 0)
557
585
  return { pruned: candidates, dryRun };
@@ -359,6 +359,19 @@ function addProfileIdColumn(db) {
359
359
  const columns = nodeColumns(db);
360
360
  addNodeColumnIfMissing(db, columns, 'profile_id', `ALTER TABLE nodes ADD COLUMN profile_id TEXT;`);
361
361
  }
362
+ /** v12 — additive runtime column `pi_pid_identity`: the LAUNCH-time process-
363
+ * identity fingerprint (`pid.ts`'s `composeIdentity`) recorded alongside
364
+ * `pi_pid` (v2) by `recordPid`, so `headlessBrokerHost.teardown()` can refuse
365
+ * to signal a `pi_pid` the OS has since recycled for an unrelated process
366
+ * (crouter#98 final review, Fix 1 — see `NodeRuntime.pi_pid_identity`).
367
+ * Defaults NULL ⇒ no baseline ⇒ the guard fails open, exactly like a node
368
+ * booted before this column existed. No data backfill: an existing `pi_pid`
369
+ * with no recorded identity simply has no launch-time baseline to compare
370
+ * against. Additive, forward-only. */
371
+ function addPidIdentityColumn(db) {
372
+ const columns = nodeColumns(db);
373
+ addNodeColumnIfMissing(db, columns, 'pi_pid_identity', `ALTER TABLE nodes ADD COLUMN pi_pid_identity TEXT;`);
374
+ }
362
375
  /** The ordered migration list. Index `i` is migration version `i + 1`; the db's
363
376
  * `user_version` tracks how many have been applied. Append only. */
364
377
  export const MIGRATIONS = [
@@ -373,6 +386,7 @@ export const MIGRATIONS = [
373
386
  /* v9 */ migrateWakeupsToTriggers,
374
387
  /* v10 */ addCanvasMetaTable,
375
388
  /* v11 */ addProfileIdColumn,
389
+ /* v12 */ addPidIdentityColumn,
376
390
  ];
377
391
  /** Bring `db` up to the latest schema version. Reads `user_version`, runs each
378
392
  * pending migration in order, and bumps `user_version` after each so the work
@@ -409,6 +409,7 @@ export function nodeArtifacts(nodeId, types) {
409
409
  created: node.created,
410
410
  intent: node.intent ?? null,
411
411
  pi_pid: node.pi_pid ?? null,
412
+ pi_pid_identity: node.pi_pid_identity ?? null,
412
413
  window: node.window ?? null,
413
414
  tmux_session: node.tmux_session ?? null,
414
415
  pane: node.pane ?? null,
@@ -6,19 +6,20 @@ export declare function crtrHome(): string;
6
6
  /** Absolute path to the SQLite canvas db file. Defaults to `canvas.db` under
7
7
  * the canvas home, but `CRTR_CANVAS_DB` overrides ONLY this file's location
8
8
  * (its `-wal`/`-shm` siblings follow it) while leaving `nodesRoot()` and all
9
- * other node data under `crtrHome()`. This split exists for the Hearth guest:
10
- * node data (meta.json/sessions, plain-file writes) stays on the durable NFS
11
- * Volume via `CRTR_HOME`, but canvas.db's SQLite fcntl/WAL locks would hang on
12
- * NFS (`local_lock=none`) — so it must live on local disk. Parent dir is
13
- * created on use so an unset-default and an override both just work. */
9
+ * other node data under `crtrHome()`. This split exists for a remote/guest
10
+ * deployment: node data (meta.json/sessions, plain-file writes) stays on a
11
+ * durable NFS-backed Volume via `CRTR_HOME`, but canvas.db's SQLite fcntl/WAL
12
+ * locks would hang on NFS (`local_lock=none`) — so it must live on local disk.
13
+ * Parent dir is created on use so an unset-default and an override both just
14
+ * work. */
14
15
  export declare function canvasDbPath(): string;
15
16
  /** Absolute path to a node's broker `view.sock`. Defaults to `view.sock` under
16
17
  * the node dir, but `CRTR_SOCK_DIR` overrides the PARENT dir (the socket becomes
17
18
  * `<CRTR_SOCK_DIR>/<nodeId>.sock`) while node data stays under `crtrHome()`.
18
- * Sibling of `CRTR_CANVAS_DB`, and exists for the same Hearth-guest reason: an
19
- * AF_UNIX socket cannot be bound on the durable NFS Volume (`bind()` → EACCES,
20
- * even where plain file writes into the same dir succeed), so the broker socket
21
- * must live on local disk. Unset (the default) is unchanged for normal
19
+ * Sibling of `CRTR_CANVAS_DB`, and exists for the same remote/guest-deployment
20
+ * reason: an AF_UNIX socket cannot be bound on the durable NFS-backed Volume
21
+ * (`bind()` → EACCES, even where plain file writes into the same dir succeed),
22
+ * so the broker socket must live on local disk. Unset (the default) is unchanged for normal
22
23
  * local-disk hosts. Parent dir is created on use so both paths just work. */
23
24
  export declare function viewSocketPath(nodeId: string): string;
24
25
  export declare function nodesRoot(): string;
@@ -30,11 +30,12 @@ export function crtrHome() {
30
30
  /** Absolute path to the SQLite canvas db file. Defaults to `canvas.db` under
31
31
  * the canvas home, but `CRTR_CANVAS_DB` overrides ONLY this file's location
32
32
  * (its `-wal`/`-shm` siblings follow it) while leaving `nodesRoot()` and all
33
- * other node data under `crtrHome()`. This split exists for the Hearth guest:
34
- * node data (meta.json/sessions, plain-file writes) stays on the durable NFS
35
- * Volume via `CRTR_HOME`, but canvas.db's SQLite fcntl/WAL locks would hang on
36
- * NFS (`local_lock=none`) — so it must live on local disk. Parent dir is
37
- * created on use so an unset-default and an override both just work. */
33
+ * other node data under `crtrHome()`. This split exists for a remote/guest
34
+ * deployment: node data (meta.json/sessions, plain-file writes) stays on a
35
+ * durable NFS-backed Volume via `CRTR_HOME`, but canvas.db's SQLite fcntl/WAL
36
+ * locks would hang on NFS (`local_lock=none`) — so it must live on local disk.
37
+ * Parent dir is created on use so an unset-default and an override both just
38
+ * work. */
38
39
  export function canvasDbPath() {
39
40
  const override = process.env['CRTR_CANVAS_DB'];
40
41
  if (override !== undefined && override !== '') {
@@ -46,10 +47,10 @@ export function canvasDbPath() {
46
47
  /** Absolute path to a node's broker `view.sock`. Defaults to `view.sock` under
47
48
  * the node dir, but `CRTR_SOCK_DIR` overrides the PARENT dir (the socket becomes
48
49
  * `<CRTR_SOCK_DIR>/<nodeId>.sock`) while node data stays under `crtrHome()`.
49
- * Sibling of `CRTR_CANVAS_DB`, and exists for the same Hearth-guest reason: an
50
- * AF_UNIX socket cannot be bound on the durable NFS Volume (`bind()` → EACCES,
51
- * even where plain file writes into the same dir succeed), so the broker socket
52
- * must live on local disk. Unset (the default) is unchanged for normal
50
+ * Sibling of `CRTR_CANVAS_DB`, and exists for the same remote/guest-deployment
51
+ * reason: an AF_UNIX socket cannot be bound on the durable NFS-backed Volume
52
+ * (`bind()` → EACCES, even where plain file writes into the same dir succeed),
53
+ * so the broker socket must live on local disk. Unset (the default) is unchanged for normal
53
54
  * local-disk hosts. Parent dir is created on use so both paths just work. */
54
55
  export function viewSocketPath(nodeId) {
55
56
  const override = process.env['CRTR_SOCK_DIR'];