total-recall-brain 3.21.2 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/frontend/dist/assets/index-C7_Xpk27.css +1 -0
  2. package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
  3. package/frontend/dist/index.html +2 -2
  4. package/frontend/src/App.spec.tsx +1 -1
  5. package/frontend/src/api/_base.ts +3 -3
  6. package/frontend/src/api/embeddings.ts +46 -0
  7. package/frontend/src/api/mesh.ts +47 -0
  8. package/frontend/src/api/network.spec.ts +9 -9
  9. package/frontend/src/api/network.ts +4 -4
  10. package/frontend/src/api/notifications.ts +5 -4
  11. package/frontend/src/api/webhooks.ts +1 -1
  12. package/frontend/src/components/BrainSelector.spec.tsx +4 -4
  13. package/frontend/src/components/BrainSelector.tsx +1 -1
  14. package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
  15. package/frontend/src/components/ContextualHelp.tsx +3 -1
  16. package/frontend/src/components/DocumentTable.spec.tsx +1 -1
  17. package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
  18. package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
  19. package/frontend/src/components/Graph3D.spec.tsx +4 -4
  20. package/frontend/src/components/Graph3D.tsx +3 -3
  21. package/frontend/src/components/MobilePairing.spec.tsx +1 -1
  22. package/frontend/src/components/MobilePairing.tsx +1 -1
  23. package/frontend/src/components/UsageChart.spec.tsx +4 -4
  24. package/frontend/src/components/UsageChart.tsx +1 -1
  25. package/frontend/src/components/VoiceInput.spec.tsx +6 -6
  26. package/frontend/src/components/VoiceInput.tsx +19 -4
  27. package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
  28. package/frontend/src/index.css +6 -0
  29. package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
  30. package/frontend/src/pages/ChatPage.spec.tsx +3 -3
  31. package/frontend/src/pages/CollabPage.spec.tsx +1 -2
  32. package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
  33. package/frontend/src/pages/FilesPage.spec.tsx +2 -2
  34. package/frontend/src/pages/GraphPage.spec.tsx +1 -1
  35. package/frontend/src/pages/HealthPage.spec.tsx +2 -2
  36. package/frontend/src/pages/HelpPage.spec.tsx +5 -5
  37. package/frontend/src/pages/InboxPage.spec.tsx +4 -4
  38. package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
  39. package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
  40. package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
  41. package/frontend/src/pages/MeshPage.spec.tsx +111 -2
  42. package/frontend/src/pages/MeshPage.tsx +95 -11
  43. package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
  44. package/frontend/src/pages/NetworkPage.tsx +12 -12
  45. package/frontend/src/pages/NotificationsPage.tsx +9 -9
  46. package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
  47. package/frontend/src/pages/OpenWikiPage.tsx +3 -3
  48. package/frontend/src/pages/RulesPage.tsx +3 -3
  49. package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
  50. package/frontend/src/pages/SecretsPage.tsx +13 -14
  51. package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
  52. package/frontend/src/pages/SettingsPage.tsx +2 -2
  53. package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
  54. package/frontend/src/pages/SkillsPage.tsx +3 -3
  55. package/frontend/src/pages/TasksPage.spec.tsx +3 -3
  56. package/frontend/src/pages/UsagePage.spec.tsx +2 -2
  57. package/frontend/src/pages/UsagePage.tsx +1 -1
  58. package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
  59. package/frontend/src/pages/WebhooksPage.tsx +19 -19
  60. package/frontend/src/setupTests.ts +4 -4
  61. package/package.json +1 -1
  62. package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
  63. package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
  64. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
  65. package/src/cli/secret.mjs +93 -12
  66. package/src/core/browser-session.mjs +185 -0
  67. package/src/core/daemon-loop.mjs +28 -1
  68. package/src/core/embeddings.mjs +52 -10
  69. package/src/core/headscale-client.mjs +227 -0
  70. package/src/core/mesh-enroll.mjs +439 -0
  71. package/src/core/ollama-embeddings.mjs +336 -0
  72. package/src/core/provider-catalog.mjs +175 -6
  73. package/src/core/provider-rotation-recipes.mjs +299 -0
  74. package/src/core/rotation-capability.mjs +189 -0
  75. package/src/core/secrets-rotate.mjs +192 -0
  76. package/src/server/rest.mjs +2 -0
  77. package/src/server/route-manifest.json +20 -0
  78. package/src/server/routes/embeddings.mjs +111 -0
  79. package/src/server/routes/headscale.mjs +21 -81
  80. package/src/server/routes/mesh.mjs +41 -0
  81. package/frontend/dist/assets/index-B4RASWbV.css +0 -1
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Automatic mesh node enrollment.
3
+ *
4
+ * Total Recall discovers mesh peers through the local Tailscale client, but a
5
+ * node that has never authenticated to the control server is invisible to the
6
+ * mesh. This module closes that gap: it reads local client state, and — when a
7
+ * Headscale credential is configured — mints a short-lived pre-auth key and
8
+ * brings the node up unattended. With no credential it still surfaces the
9
+ * interactive registration URL so the dashboard always has a working path.
10
+ *
11
+ * Portability rules (open source): no control-server URL, hostname, or device
12
+ * name is hardcoded. Everything is read from the local client, the operator's
13
+ * secret entry, or environment variables.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import crypto from 'node:crypto';
20
+ import { logger } from './logger.mjs';
21
+ import {
22
+ createHeadscalePreAuthKey,
23
+ describeHeadscaleAvailability,
24
+ headscaleUrlFromEnv,
25
+ normalizeControlUrl,
26
+ resolveHeadscaleUser,
27
+ } from './headscale-client.mjs';
28
+
29
+ /** Enrollment states surfaced to the API/UI. */
30
+ export const ENROLLMENT_STATES = Object.freeze({
31
+ ENROLLED: 'enrolled',
32
+ NEEDS_LOGIN: 'needs_login',
33
+ STOPPED: 'stopped',
34
+ CLIENT_UNAVAILABLE: 'client_unavailable',
35
+ UNKNOWN: 'unknown',
36
+ });
37
+
38
+ /** macOS ships the CLI inside the app bundle; PATH-only lookup misses it. */
39
+ const FALLBACK_BINARIES = [
40
+ '/usr/local/bin/tailscale',
41
+ '/opt/homebrew/bin/tailscale',
42
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale',
43
+ '/usr/bin/tailscale',
44
+ ];
45
+
46
+ const STATUS_TIMEOUT_MS = 5_000;
47
+ const UP_TIMEOUT_MS = 90_000;
48
+ const LOGIN_PROBE_TIMEOUT_MS = 15_000;
49
+
50
+ /** Auto-enrollment retry floor — a failing control server must not be hammered. */
51
+ const AUTO_RETRY_INTERVAL_MS = 10 * 60_000;
52
+
53
+ let lastAutoAttemptAt = 0;
54
+ let lastAutoResult = null;
55
+
56
+ /** Reset throttle state (tests, and after an explicit user-triggered enroll). */
57
+ export function resetAutoEnrollThrottle() {
58
+ lastAutoAttemptAt = 0;
59
+ lastAutoResult = null;
60
+ }
61
+
62
+ /** Resolve the Tailscale CLI, honoring an explicit override first. */
63
+ export function resolveTailscaleBinary() {
64
+ const override = (process.env.TR_TAILSCALE_BIN || '').trim();
65
+ if (override) return override;
66
+ for (const candidate of FALLBACK_BINARIES) {
67
+ try {
68
+ if (fs.existsSync(candidate)) return candidate;
69
+ } catch {
70
+ // permission errors fall through to PATH lookup
71
+ }
72
+ }
73
+ return 'tailscale';
74
+ }
75
+
76
+ function runTailscale(args, { timeout = STATUS_TIMEOUT_MS } = {}) {
77
+ const result = spawnSync(resolveTailscaleBinary(), args, {
78
+ encoding: 'utf8',
79
+ timeout,
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ });
82
+ return {
83
+ ok: result.status === 0,
84
+ status: result.status,
85
+ stdout: result.stdout || '',
86
+ stderr: result.stderr || '',
87
+ error: result.error || null,
88
+ };
89
+ }
90
+
91
+ /** Raw `tailscale status --json`, or null when the client is unavailable. */
92
+ export function readTailscaleStatus() {
93
+ const res = runTailscale(['status', '--json']);
94
+ if (!res.stdout) return null;
95
+ try {
96
+ return JSON.parse(res.stdout);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Local client preferences. `ControlURL` here is how we learn which control
104
+ * server this node already targets without hardcoding one.
105
+ */
106
+ export function readTailscalePrefs() {
107
+ const res = runTailscale(['debug', 'prefs']);
108
+ if (!res.stdout) return null;
109
+ try {
110
+ return JSON.parse(res.stdout);
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function classifyState(status, prefs) {
117
+ if (!status) return ENROLLMENT_STATES.CLIENT_UNAVAILABLE;
118
+ const backend = String(status.BackendState || '');
119
+ if (backend === 'Running' && (status.TailscaleIPs || []).length) {
120
+ return ENROLLMENT_STATES.ENROLLED;
121
+ }
122
+ if (backend === 'NeedsLogin' || prefs?.LoggedOut === true) {
123
+ return ENROLLMENT_STATES.NEEDS_LOGIN;
124
+ }
125
+ if (backend === 'Stopped') return ENROLLMENT_STATES.STOPPED;
126
+ if (backend === 'Starting') return ENROLLMENT_STATES.UNKNOWN;
127
+ return ENROLLMENT_STATES.UNKNOWN;
128
+ }
129
+
130
+ /**
131
+ * Which control server should this node use?
132
+ * Explicit argument → environment → configured secret → whatever the local
133
+ * client already points at. Never a compiled-in default.
134
+ */
135
+ export function resolveLoginServer({ explicit, secretUrl, prefs } = {}) {
136
+ const candidates = [explicit, headscaleUrlFromEnv(), secretUrl, prefs?.ControlURL];
137
+ for (const candidate of candidates) {
138
+ const value = normalizeControlUrl(candidate || '');
139
+ if (value) return value;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Full enrollment picture for the API/UI: local client state, whether an
146
+ * unattended enrollment is possible, and the interactive URL if one is pending.
147
+ */
148
+ export async function getEnrollmentStatus({ brainDir } = {}) {
149
+ const status = readTailscaleStatus();
150
+ const prefs = readTailscalePrefs();
151
+ const state = classifyState(status, prefs);
152
+ const headscale = await describeHeadscaleAvailability(brainDir);
153
+ const loginServer = resolveLoginServer({ secretUrl: headscale.url, prefs });
154
+
155
+ return {
156
+ state,
157
+ enrolled: state === ENROLLMENT_STATES.ENROLLED,
158
+ backend_state: status?.BackendState || null,
159
+ auth_url: status?.AuthURL || null,
160
+ ips: status?.TailscaleIPs || [],
161
+ hostname: status?.Self?.HostName || os.hostname(),
162
+ login_server: loginServer,
163
+ can_auto_enroll: headscale.configured && Boolean(loginServer),
164
+ auto_enroll_blocked_reason: headscale.configured ? null : headscale.reason,
165
+ auto_enroll_enabled: autoEnrollEnabled(),
166
+ client_available: Boolean(status),
167
+ checked_at: new Date().toISOString(),
168
+ };
169
+ }
170
+
171
+ /** Operators can disable unattended enrollment entirely. */
172
+ export function autoEnrollEnabled() {
173
+ const raw = String(process.env.TR_MESH_AUTO_ENROLL ?? '').trim().toLowerCase();
174
+ if (raw === '0' || raw === 'false' || raw === 'off') return false;
175
+ return true;
176
+ }
177
+
178
+ /**
179
+ * Write the pre-auth key to a 0600 temp file so it can be passed as
180
+ * `--auth-key file:<path>`. A key in argv is visible to every process on the
181
+ * box via `ps`; a key in a mode-0600 temp file is not.
182
+ */
183
+ function withAuthKeyFile(key, fn) {
184
+ const file = path.join(
185
+ fs.mkdtempSync(path.join(os.tmpdir(), 'tr-enroll-')),
186
+ 'authkey',
187
+ );
188
+ try {
189
+ fs.writeFileSync(file, key, { mode: 0o600 });
190
+ return fn(file);
191
+ } finally {
192
+ try {
193
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
194
+ } catch {
195
+ // best effort — the key expires on its own
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build `tailscale up` arguments.
202
+ *
203
+ * `tailscale up` refuses to run if any flag is passed and an *unmentioned*
204
+ * setting would change from its current value to the flag default. So whenever
205
+ * we pass a flag at all, we must re-state this node's existing settings.
206
+ * We restate them explicitly (rather than conditionally) so a preference can
207
+ * never silently flip: joining the mesh must not reconfigure the user's node.
208
+ *
209
+ * `reset: true` is the escape hatch for a setting we do not model — it clears
210
+ * unmentioned settings to defaults, so the flags below are still re-stated.
211
+ */
212
+ export function buildUpArgs({ loginServer, prefs, authKeyFile, reset = false }) {
213
+ const args = ['up'];
214
+ const current = normalizeControlUrl(prefs?.ControlURL || '');
215
+ const target = normalizeControlUrl(loginServer || '');
216
+ const repointing = Boolean(target) && target !== current;
217
+
218
+ // No flags at all — resuming an already-configured node — keeps every
219
+ // setting untouched and sidesteps the complete-set rule entirely.
220
+ if (!repointing && !authKeyFile && !reset) return args;
221
+
222
+ // `--reset` clears every unmentioned setting to its default, and ControlURL
223
+ // is one of them: without restating it here, a reset silently repoints the
224
+ // node at Tailscale SaaS and the control server rejects the pre-auth key.
225
+ if (repointing || (reset && target)) args.push(`--login-server=${target}`);
226
+ if (reset) args.push('--reset');
227
+
228
+ if (prefs) {
229
+ args.push(`--accept-routes=${prefs.RouteAll === true}`);
230
+ args.push(`--accept-dns=${prefs.CorpDNS !== false}`);
231
+ if (prefs.ShieldsUp === true) args.push('--shields-up');
232
+ if (prefs.RunSSH === true) args.push('--ssh');
233
+ if (prefs.Hostname) args.push(`--hostname=${prefs.Hostname}`);
234
+ if (Array.isArray(prefs.AdvertiseRoutes) && prefs.AdvertiseRoutes.length) {
235
+ args.push(`--advertise-routes=${prefs.AdvertiseRoutes.join(',')}`);
236
+ }
237
+ if (Array.isArray(prefs.AdvertiseTags) && prefs.AdvertiseTags.length) {
238
+ args.push(`--advertise-tags=${prefs.AdvertiseTags.join(',')}`);
239
+ }
240
+ if (prefs.ExitNodeIP) args.push(`--exit-node=${prefs.ExitNodeIP}`);
241
+ if (prefs.ExitNodeAllowLANAccess === true) args.push('--exit-node-allow-lan-access');
242
+ }
243
+
244
+ if (authKeyFile) args.push(`--auth-key=file:${authKeyFile}`);
245
+ return args;
246
+ }
247
+
248
+ /** Does this `tailscale up` failure mean "you didn't restate every setting"? */
249
+ export function isIncompleteSettingsError(stderr = '') {
250
+ return /requires mentioning all\s*\n?non-default flags|--reset/i.test(String(stderr));
251
+ }
252
+
253
+ /**
254
+ * Run `tailscale up`, retrying once with `--reset` if the client rejects the
255
+ * command for an unmodeled non-default setting.
256
+ */
257
+ function runUpWithResetFallback({ loginServer, prefs, authKeyFile }) {
258
+ const first = runTailscale(buildUpArgs({ loginServer, prefs, authKeyFile }), {
259
+ timeout: UP_TIMEOUT_MS,
260
+ });
261
+ if (first.ok || !isIncompleteSettingsError(first.stderr)) {
262
+ return { ...first, usedReset: false };
263
+ }
264
+ // Without a known control server a reset would repoint the node to the
265
+ // default SaaS control plane — worse than failing.
266
+ if (!normalizeControlUrl(loginServer || '')) {
267
+ return { ...first, usedReset: false };
268
+ }
269
+ const retry = runTailscale(buildUpArgs({ loginServer, prefs, authKeyFile, reset: true }), {
270
+ timeout: UP_TIMEOUT_MS,
271
+ });
272
+ return { ...retry, usedReset: true };
273
+ }
274
+
275
+ /**
276
+ * Ask the client to start an interactive login and return the URL it prints.
277
+ * The probe is killed on timeout; the pending registration stays valid on the
278
+ * control server, and the URL is then also readable from `status --json`.
279
+ */
280
+ export function requestInteractiveAuthUrl({ loginServer } = {}) {
281
+ const args = ['login'];
282
+ const target = normalizeControlUrl(loginServer || '');
283
+ if (target) args.push(`--login-server=${target}`);
284
+
285
+ const res = runTailscale(args, { timeout: LOGIN_PROBE_TIMEOUT_MS });
286
+ const combined = `${res.stdout}\n${res.stderr}`;
287
+ const match = combined.match(/https?:\/\/\S+/);
288
+ if (match) return match[0];
289
+ return readTailscaleStatus()?.AuthURL || null;
290
+ }
291
+
292
+ /**
293
+ * Bring this node onto the mesh.
294
+ *
295
+ * Unattended when a Headscale credential exists; otherwise returns the
296
+ * interactive URL for the user to approve. Never throws — enrollment runs on a
297
+ * daemon heartbeat and must not take the loop down.
298
+ */
299
+ export async function enrollThisNode({ brainDir, loginServer: explicitServer, user, force } = {}) {
300
+ const before = await getEnrollmentStatus({ brainDir });
301
+
302
+ if (before.enrolled && !force) {
303
+ return { ok: true, changed: false, state: before.state, status: before, reason: 'already-enrolled' };
304
+ }
305
+ if (!before.client_available) {
306
+ return {
307
+ ok: false,
308
+ changed: false,
309
+ state: before.state,
310
+ status: before,
311
+ reason: 'tailscale-client-unavailable',
312
+ hint: 'Install Tailscale and start tailscaled, or set TR_TAILSCALE_BIN to the client path.',
313
+ };
314
+ }
315
+
316
+ const prefs = readTailscalePrefs();
317
+
318
+ // Already authenticated, just not running — no key needed.
319
+ if (before.state === ENROLLMENT_STATES.STOPPED) {
320
+ const res = runTailscale(buildUpArgs({ prefs, loginServer: null }), { timeout: UP_TIMEOUT_MS });
321
+ const after = await getEnrollmentStatus({ brainDir });
322
+ return {
323
+ ok: res.ok && after.enrolled,
324
+ changed: after.enrolled,
325
+ state: after.state,
326
+ status: after,
327
+ method: 'resume',
328
+ reason: res.ok ? null : (res.stderr || '').trim().slice(0, 300) || 'tailscale-up-failed',
329
+ };
330
+ }
331
+
332
+ const headscale = await describeHeadscaleAvailability(brainDir);
333
+ const target = resolveLoginServer({ explicit: explicitServer, secretUrl: headscale.url, prefs });
334
+
335
+ if (headscale.configured && target) {
336
+ try {
337
+ // Resolve once so the client can try id then name across API versions.
338
+ const userRef = await resolveHeadscaleUser(brainDir, user);
339
+ const keyUser = userRef?.name || user || 'default';
340
+ const { key } = await createHeadscalePreAuthKey({ userRef, ttlMinutes: 10 }, brainDir);
341
+ const res = withAuthKeyFile(key, (authKeyFile) =>
342
+ runUpWithResetFallback({ loginServer: target, prefs, authKeyFile }),
343
+ );
344
+ const after = await getEnrollmentStatus({ brainDir });
345
+ if (res.ok && after.enrolled) {
346
+ return {
347
+ ok: true,
348
+ changed: true,
349
+ state: after.state,
350
+ status: after,
351
+ method: 'preauth-key',
352
+ user: keyUser,
353
+ used_reset: res.usedReset === true,
354
+ };
355
+ }
356
+ return {
357
+ ok: false,
358
+ changed: false,
359
+ state: after.state,
360
+ status: after,
361
+ method: 'preauth-key',
362
+ used_reset: res.usedReset === true,
363
+ reason: (res.stderr || '').trim().slice(0, 300) || 'tailscale-up-did-not-connect',
364
+ };
365
+ } catch (err) {
366
+ // Fall through to the interactive path — a broken API key should not
367
+ // leave the user with no way to enroll.
368
+ logger.info('mesh-enroll', `Pre-auth enrollment failed, falling back to interactive: ${err.message}`);
369
+ }
370
+ }
371
+
372
+ const authUrl = before.auth_url || requestInteractiveAuthUrl({ loginServer: target });
373
+ const after = await getEnrollmentStatus({ brainDir });
374
+ return {
375
+ ok: false,
376
+ changed: false,
377
+ state: after.state,
378
+ status: { ...after, auth_url: authUrl || after.auth_url },
379
+ method: 'interactive',
380
+ auth_url: authUrl || after.auth_url,
381
+ reason: headscale.configured ? 'awaiting-user-approval' : headscale.reason,
382
+ hint: headscale.configured
383
+ ? 'Approve the registration URL to finish joining the mesh.'
384
+ : 'Add a Headscale API key (provider: headscale) with its control-server URL to enroll automatically.',
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Daemon entry point: enroll if needed, throttled, never throwing.
390
+ * Returns a skip descriptor when there is nothing to do.
391
+ */
392
+ export async function ensureEnrolled({ brainDir, now = Date.now() } = {}) {
393
+ if (!autoEnrollEnabled()) {
394
+ return { skipped: true, reason: 'auto-enroll-disabled' };
395
+ }
396
+ if (lastAutoAttemptAt && now - lastAutoAttemptAt < AUTO_RETRY_INTERVAL_MS) {
397
+ return { skipped: true, reason: 'throttled', last_result: lastAutoResult };
398
+ }
399
+
400
+ try {
401
+ const status = await getEnrollmentStatus({ brainDir });
402
+ if (status.enrolled) {
403
+ lastAutoResult = { ok: true, state: status.state };
404
+ return { skipped: true, reason: 'already-enrolled', state: status.state };
405
+ }
406
+ if (!status.client_available) {
407
+ lastAutoAttemptAt = now;
408
+ lastAutoResult = { ok: false, state: status.state };
409
+ return { skipped: true, reason: 'tailscale-client-unavailable', state: status.state };
410
+ }
411
+ if (!status.can_auto_enroll && status.state !== ENROLLMENT_STATES.STOPPED) {
412
+ // Nothing unattended is possible; the dashboard shows the interactive URL.
413
+ lastAutoAttemptAt = now;
414
+ lastAutoResult = { ok: false, state: status.state };
415
+ return {
416
+ skipped: true,
417
+ reason: status.auto_enroll_blocked_reason || 'no-automatic-path',
418
+ state: status.state,
419
+ auth_url: status.auth_url,
420
+ };
421
+ }
422
+
423
+ lastAutoAttemptAt = now;
424
+ const result = await enrollThisNode({ brainDir });
425
+ lastAutoResult = { ok: result.ok, state: result.state };
426
+ logger.info(
427
+ 'mesh-enroll',
428
+ result.ok
429
+ ? `Node enrolled on the mesh via ${result.method || 'unknown'} (state=${result.state})`
430
+ : `Automatic enrollment incomplete: ${result.reason || 'unknown'} (state=${result.state})`,
431
+ );
432
+ return result;
433
+ } catch (err) {
434
+ lastAutoAttemptAt = now;
435
+ lastAutoResult = { ok: false, error: err.message };
436
+ logger.info('mesh-enroll', `Automatic enrollment error: ${err.message}`);
437
+ return { skipped: true, reason: 'error', error: err.message };
438
+ }
439
+ }