@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,339 @@
1
+ // ---------------------------------------------------------------------------
2
+ // webui-command.ts — `goodvibes-daemon webui enable|disable|status`.
3
+ //
4
+ // WHAT THIS IS FOR
5
+ //
6
+ // The browser operator surface ships as a built bundle of static files, not as a
7
+ // fourth binary and not as a fourth service. The daemon already knows how to
8
+ // serve such a directory: with `controlPlane.webui.serve` on, its own HTTP
9
+ // router answers `/` from `controlPlane.webui.bundleDir` and falls back to
10
+ // index.html for app routes. So installing the web UI is two config writes and
11
+ // a directory on disk — and this is the command that makes those writes, so the
12
+ // curl installer does not have to know the key names, the file format, or which
13
+ // of the three settings tiers a daemon-owned key belongs in. Same reason
14
+ // `provision-wake-model` exists: the installer runs the binary it just placed
15
+ // and the one implementation owns the details.
16
+ //
17
+ // WHERE IT IS SERVED, AND WHY THE URL SAYS WHAT IT SAYS
18
+ //
19
+ // The bundle is served BY THE CONTROL-PLANE LISTENER, same origin as the API
20
+ // (that is the whole point — a same-origin bundle makes the browser's
21
+ // same-origin policy a non-issue and needs no CORS allowlist). So the URL that
22
+ // opens the web UI is the control-plane origin: `http://<host>:<controlPlane.port>`,
23
+ // not `web.port`. `web.port` is the surface's DECLARED endpoint, used for links
24
+ // and for `tailscale serve`; nothing binds it. This command therefore reports —
25
+ // and, when it is still sitting on the shipped placeholder, writes —
26
+ // `web.publicBaseUrl` as the origin that actually answers, so the printed URL,
27
+ // the pairing deep link and the running server all agree.
28
+ //
29
+ // EXPOSURE IS NEVER WIDENED AS A SIDE EFFECT
30
+ //
31
+ // `enable` turns serving on and names the bundle. It does not touch the
32
+ // listener's host mode in either direction, so a fresh install serves the web UI
33
+ // on loopback (the shipped `controlPlane.hostMode` default) and a host already
34
+ // deliberately bound to the LAN keeps that binding. Widening is its own explicit
35
+ // act: `--lan`. Narrowing back is `--loopback`. Both are stated in the receipt.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ import { isAbsolute, join, resolve } from 'node:path';
39
+ import { existsSync, statSync } from 'node:fs';
40
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
41
+ import { resolveRuntimeEndpointBinding } from '@pellux/goodvibes-terminal-shell';
42
+ import { probeStableHostInputs, stableUrlHostForBindHost, type StableHostInputs } from '@pellux/goodvibes-sdk/platform/pairing';
43
+
44
+ /** The shipped `web.publicBaseUrl` placeholder — a port nothing binds. */
45
+ const SHIPPED_PUBLIC_BASE_URL = 'http://127.0.0.1:3423';
46
+
47
+ export interface WebuiCommandResult {
48
+ readonly exitCode: number;
49
+ readonly lines: readonly string[];
50
+ }
51
+
52
+ export interface WebuiCommandDeps {
53
+ /** Reads and writes the daemon-owned `controlPlane.*` / `web.*` keys. */
54
+ readonly configManager: Pick<ConfigManager, 'get' | 'set'>;
55
+ /** True when the path names a directory. Injected in tests. */
56
+ readonly directoryExists?: ((path: string) => boolean) | undefined;
57
+ /** True when the path names a readable file. Injected in tests. */
58
+ readonly fileExists?: ((path: string) => boolean) | undefined;
59
+ /** Resolves a possibly-relative path against this base. Defaults to the cwd. */
60
+ readonly baseDirectory?: string | undefined;
61
+ /** Stable-name probe for the LAN origin. Injected in tests so nothing shells out. */
62
+ readonly probeStableHost?: (() => StableHostInputs) | undefined;
63
+ }
64
+
65
+ type Posture = 'lan' | 'loopback' | 'unchanged';
66
+
67
+ interface ParsedWebuiArgs {
68
+ readonly subcommand: 'enable' | 'disable' | 'status';
69
+ readonly bundleDir: string | undefined;
70
+ readonly posture: Posture;
71
+ readonly errors: readonly string[];
72
+ }
73
+
74
+ function parseWebuiArgs(argv: readonly string[]): ParsedWebuiArgs {
75
+ const errors: string[] = [];
76
+ let subcommand: 'enable' | 'disable' | 'status' = 'status';
77
+ let bundleDir: string | undefined;
78
+ let lan = false;
79
+ let loopback = false;
80
+ let sawSubcommand = false;
81
+
82
+ for (let index = 0; index < argv.length; index += 1) {
83
+ const arg = argv[index] ?? '';
84
+ if (arg === '--lan') {
85
+ lan = true;
86
+ continue;
87
+ }
88
+ if (arg === '--loopback') {
89
+ loopback = true;
90
+ continue;
91
+ }
92
+ if (arg === '--bundle-dir') {
93
+ const value = argv[index + 1];
94
+ if (value === undefined || value.startsWith('-')) {
95
+ errors.push('--bundle-dir needs a directory path');
96
+ continue;
97
+ }
98
+ bundleDir = value;
99
+ index += 1;
100
+ continue;
101
+ }
102
+ if (arg.startsWith('--bundle-dir=')) {
103
+ const value = arg.slice('--bundle-dir='.length);
104
+ if (!value) {
105
+ errors.push('--bundle-dir needs a directory path');
106
+ continue;
107
+ }
108
+ bundleDir = value;
109
+ continue;
110
+ }
111
+ if (arg.startsWith('-')) {
112
+ errors.push(`unknown option: ${arg}`);
113
+ continue;
114
+ }
115
+ if (sawSubcommand) {
116
+ errors.push(`unexpected argument: ${arg}`);
117
+ continue;
118
+ }
119
+ sawSubcommand = true;
120
+ if (arg === 'enable' || arg === 'disable' || arg === 'status') {
121
+ subcommand = arg;
122
+ } else {
123
+ errors.push(`unknown subcommand: ${arg} (expected enable, disable or status)`);
124
+ }
125
+ }
126
+
127
+ if (lan && loopback) {
128
+ errors.push('--lan and --loopback ask for opposite things; pass one of them');
129
+ }
130
+ const posture: Posture = lan ? 'lan' : loopback ? 'loopback' : 'unchanged';
131
+ return { subcommand, bundleDir, posture, errors };
132
+ }
133
+
134
+ function readString(config: Pick<ConfigManager, 'get'>, key: 'controlPlane.webui.bundleDir' | 'web.publicBaseUrl' | 'web.staticAssetsDir'): string {
135
+ const raw = config.get(key);
136
+ return typeof raw === 'string' ? raw.trim() : '';
137
+ }
138
+
139
+ /**
140
+ * The origin a browser opens to reach the web UI: the CONTROL-PLANE binding,
141
+ * because that is the listener serving the bundle. A wildcard bind resolves
142
+ * through the stable-name ladder (tailscale name, then `<host>.local`, then the
143
+ * routed address) so the printed URL survives a DHCP lease change where it can.
144
+ */
145
+ function servingOrigin(
146
+ config: Pick<ConfigManager, 'get'>,
147
+ probe: () => StableHostInputs,
148
+ ): { readonly origin: string; readonly loopback: boolean; readonly recognized: boolean } {
149
+ const binding = resolveRuntimeEndpointBinding(config, 'controlPlane');
150
+ const resolved = stableUrlHostForBindHost(binding.host, probe);
151
+ const loopback = resolved.host === '127.0.0.1' || resolved.host === 'localhost' || resolved.host === '::1';
152
+ return { origin: `http://${resolved.host}:${binding.port}`, loopback, recognized: binding.recognized };
153
+ }
154
+
155
+ /** A bundle directory is usable only when it exists and holds the app shell. */
156
+ function describeBundleProblem(
157
+ bundleDir: string,
158
+ directoryExists: (path: string) => boolean,
159
+ fileExists: (path: string) => boolean,
160
+ ): string | null {
161
+ if (!directoryExists(bundleDir)) {
162
+ return `no directory at ${bundleDir}`;
163
+ }
164
+ if (!fileExists(join(bundleDir, 'index.html'))) {
165
+ return `${bundleDir} holds no index.html, so it is not a built web UI bundle`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function postureLines(origin: string, loopback: boolean): string[] {
171
+ if (loopback) {
172
+ return [
173
+ ' reachable from this machine only (the control-plane listener is bound to loopback).',
174
+ ' To reach it from another device on your network: goodvibes-daemon webui enable --lan',
175
+ ];
176
+ }
177
+ return [
178
+ ` reachable from your network at ${origin} — the control-plane listener is bound to all interfaces.`,
179
+ ' To take it back to this machine only: goodvibes-daemon webui enable --loopback',
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * Run the command and report it. Never throws for an ordinary refusal — a bad
185
+ * argument, a missing bundle, an unwritable settings file all come back as an
186
+ * exit code and lines, because the caller is often an installer reading both.
187
+ */
188
+ export function runWebuiCommand(argv: readonly string[], deps: WebuiCommandDeps): WebuiCommandResult {
189
+ const parsed = parseWebuiArgs(argv);
190
+ if (parsed.errors.length > 0) {
191
+ return {
192
+ exitCode: 2,
193
+ lines: [
194
+ ...parsed.errors.map((error) => `webui: ${error}`),
195
+ 'Usage: goodvibes-daemon webui [enable|disable|status] [--bundle-dir <dir>] [--lan|--loopback]',
196
+ ],
197
+ };
198
+ }
199
+
200
+ const config = deps.configManager;
201
+ const directoryExists = deps.directoryExists ?? ((path: string) => existsSync(path) && statSync(path).isDirectory());
202
+ const fileExists = deps.fileExists ?? ((path: string) => existsSync(path) && statSync(path).isFile());
203
+ const base = deps.baseDirectory ?? process.cwd();
204
+ const probe = deps.probeStableHost ?? probeStableHostInputs;
205
+ const absolute = (path: string): string => (isAbsolute(path) ? path : resolve(base, path));
206
+
207
+ if (parsed.subcommand === 'status') {
208
+ return renderStatus(config, { directoryExists, fileExists, absolute, probe });
209
+ }
210
+
211
+ if (parsed.subcommand === 'disable') {
212
+ try {
213
+ config.set('controlPlane.webui.serve', false);
214
+ } catch (error) {
215
+ return { exitCode: 1, lines: [`webui: could not write settings — ${message(error)}`] };
216
+ }
217
+ const kept = readString(config, 'controlPlane.webui.bundleDir');
218
+ return {
219
+ exitCode: 0,
220
+ lines: [
221
+ 'web UI: no longer served by the daemon.',
222
+ ...(kept ? [` the bundle is left on disk at ${kept} — 'goodvibes-daemon webui enable' serves it again`] : []),
223
+ ' Restart the daemon for this to take effect on a running process.',
224
+ ],
225
+ };
226
+ }
227
+
228
+ // enable
229
+ const requested = parsed.bundleDir ?? readString(config, 'controlPlane.webui.bundleDir');
230
+ if (!requested) {
231
+ return {
232
+ exitCode: 2,
233
+ lines: [
234
+ 'webui: no bundle directory to serve.',
235
+ ' Pass one: goodvibes-daemon webui enable --bundle-dir <dir>',
236
+ ],
237
+ };
238
+ }
239
+ const bundleDir = absolute(requested);
240
+ const problem = describeBundleProblem(bundleDir, directoryExists, fileExists);
241
+ if (problem) {
242
+ return {
243
+ exitCode: 1,
244
+ lines: [
245
+ `webui: refusing to serve ${bundleDir} — ${problem}.`,
246
+ ' Nothing was changed.',
247
+ ],
248
+ };
249
+ }
250
+
251
+ try {
252
+ config.set('controlPlane.webui.bundleDir', bundleDir);
253
+ config.set('controlPlane.webui.serve', true);
254
+ config.set('web.enabled', true);
255
+ if (parsed.posture === 'lan') {
256
+ config.set('controlPlane.hostMode', 'network');
257
+ config.set('web.hostMode', 'network');
258
+ } else if (parsed.posture === 'loopback') {
259
+ config.set('controlPlane.hostMode', 'local');
260
+ config.set('web.hostMode', 'local');
261
+ }
262
+ } catch (error) {
263
+ return { exitCode: 1, lines: [`webui: could not write settings — ${message(error)}`] };
264
+ }
265
+
266
+ const serving = servingOrigin(config, probe);
267
+ const lines: string[] = [
268
+ `web UI: served by the daemon from ${bundleDir}`,
269
+ ` ${serving.origin}`,
270
+ ];
271
+
272
+ // The shipped `web.publicBaseUrl` names a port nothing binds. Replace it with
273
+ // the origin that actually answers so the printed URL, the pairing deep link
274
+ // and the running server agree. A value the operator chose is left alone.
275
+ const currentPublic = readString(config, 'web.publicBaseUrl');
276
+ if (!currentPublic || currentPublic === SHIPPED_PUBLIC_BASE_URL) {
277
+ try {
278
+ config.set('web.publicBaseUrl', serving.origin);
279
+ } catch (error) {
280
+ lines.push(` note: could not record web.publicBaseUrl (${message(error)}); links may name a different origin.`);
281
+ }
282
+ } else if (currentPublic !== serving.origin) {
283
+ lines.push(` note: web.publicBaseUrl is set to ${currentPublic}; links use that, the bundle is served at the URL above.`);
284
+ }
285
+
286
+ if (!serving.recognized) {
287
+ lines.push(' note: controlPlane.hostMode is not one of local|network|custom — the daemon cannot bind until that is corrected.');
288
+ }
289
+ lines.push(...postureLines(serving.origin, serving.loopback));
290
+ lines.push(' Restart the daemon for this to take effect on a running process.');
291
+ return { exitCode: 0, lines };
292
+ }
293
+
294
+ function renderStatus(
295
+ config: Pick<ConfigManager, 'get'>,
296
+ deps: {
297
+ readonly directoryExists: (path: string) => boolean;
298
+ readonly fileExists: (path: string) => boolean;
299
+ readonly absolute: (path: string) => string;
300
+ readonly probe: () => StableHostInputs;
301
+ },
302
+ ): WebuiCommandResult {
303
+ const serve = config.get('controlPlane.webui.serve') === true;
304
+ const configured = readString(config, 'controlPlane.webui.bundleDir');
305
+ const fallback = readString(config, 'web.staticAssetsDir');
306
+ const source = configured ? 'controlPlane.webui.bundleDir' : fallback ? 'web.staticAssetsDir' : '';
307
+ const directory = configured || fallback;
308
+
309
+ if (!serve) {
310
+ return {
311
+ exitCode: 0,
312
+ lines: [
313
+ 'web UI: not served by this daemon (controlPlane.webui.serve is off).',
314
+ ...(directory ? [` a bundle directory is configured (${source}): ${directory}`] : []),
315
+ ' Serve it: goodvibes-daemon webui enable --bundle-dir <dir>',
316
+ ],
317
+ };
318
+ }
319
+
320
+ const serving = servingOrigin(config, deps.probe);
321
+ const lines = [`web UI: served by the daemon at ${serving.origin}`];
322
+ if (!directory) {
323
+ lines.push(' no bundle directory is configured, so every request falls through to the API routes.');
324
+ } else {
325
+ const absoluteDir = deps.absolute(directory);
326
+ const problem = describeBundleProblem(absoluteDir, deps.directoryExists, deps.fileExists);
327
+ lines.push(problem ? ` bundle (${source}): ${absoluteDir} — UNUSABLE: ${problem}` : ` bundle (${source}): ${absoluteDir}`);
328
+ }
329
+ const currentPublic = readString(config, 'web.publicBaseUrl');
330
+ if (currentPublic && currentPublic !== serving.origin) {
331
+ lines.push(` note: web.publicBaseUrl is ${currentPublic}; links use that, the bundle is served at the URL above.`);
332
+ }
333
+ lines.push(...postureLines(serving.origin, serving.loopback));
334
+ return { exitCode: 0, lines };
335
+ }
336
+
337
+ function message(error: unknown): string {
338
+ return error instanceof Error ? error.message : String(error);
339
+ }
@@ -0,0 +1,110 @@
1
+ import { Notifier } from '@pellux/goodvibes-sdk/platform/integrations';
2
+ import { syncConfiguredServices } from '@/runtime/index.ts';
3
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
5
+ const { runBootMemoryFold } = operations;
6
+ import { createDaemonPluginLoaderDeps } from './plugin-composition.ts';
7
+ import type { RuntimeServices } from './runtime-services-types.ts';
8
+
9
+ /**
10
+ * The boot steps that belong to the graph rather than to the facade.
11
+ *
12
+ * The daemon facade starts the brokers, the automation manager and the memory
13
+ * store; these are the ones it does not know about because they are this
14
+ * product's own. A step that only runs in a client's bootstrap instead of
15
+ * here is a step that never happens for this daemon.
16
+ *
17
+ * Every one of them is best-effort: none of them is a reason for the daemon not
18
+ * to start, and each says so in the log if it fails.
19
+ */
20
+ export async function runDaemonBootTasks(services: RuntimeServices): Promise<void> {
21
+ // Fold a legacy per-project memory store into the canonical home-scoped one.
22
+ // Runs after the facade's memoryStore.init(), is id-keyed and idempotent, and
23
+ // never deletes the legacy file. The daemon is the only host of the canonical
24
+ // store now, so if it does not fold, nothing does.
25
+ await runBootMemoryFold(
26
+ services.memoryStore,
27
+ services.memoryEmbeddingRegistry,
28
+ services.workingDirectory,
29
+ logger,
30
+ );
31
+
32
+ // Provider health, rate limits and credential state become runtime events
33
+ // rather than something a caller has to poll for.
34
+ services.providerRegistry.startWatching(services.runtimeBus);
35
+
36
+ // Attach the SAME WebhookNotifier the notification verbs keep live, rather
37
+ // than a second boot-time-only instance. Attached unconditionally: `send()` is
38
+ // already a safe no-op with zero URLs configured, and attaching only when URLs
39
+ // exist at boot is how a URL added later reached some notifications and not
40
+ // the bus listeners until the next restart.
41
+ const webhookUrls = (services.configManager.getCategory('notifications') as { webhookUrls?: string[] }).webhookUrls ?? [];
42
+ if (webhookUrls.length > 0) {
43
+ services.webhookNotifier.setUrls(webhookUrls);
44
+ services.runtimeDispatch.syncIntegration({
45
+ id: 'webhooks',
46
+ displayName: 'Webhooks',
47
+ category: 'communication',
48
+ status: 'healthy',
49
+ enabled: true,
50
+ successCount: 0,
51
+ errorCount: 0,
52
+ meta: { urlCount: webhookUrls.length },
53
+ }, 'boot.webhooks');
54
+ }
55
+ services.webhookNotifier.attachToRuntimeBus(services.runtimeBus);
56
+
57
+ // Outbound delivery queues: attach the notifier to the bus when any queue is
58
+ // configured, and reflect each queue's health into the integrations read model
59
+ // so a client can show per-channel delivery state without inventing its own.
60
+ try {
61
+ const notifier = await Notifier.fromConfig(services.serviceRegistry);
62
+ const queueStatuses = notifier.getQueueStatus();
63
+ if (queueStatuses.length > 0) {
64
+ notifier.attachToRuntimeBus(services.runtimeBus);
65
+ for (const queueStatus of queueStatuses) {
66
+ services.runtimeDispatch.syncIntegration({
67
+ id: queueStatus.channel,
68
+ displayName: queueStatus.channel[0]!.toUpperCase() + queueStatus.channel.slice(1),
69
+ category: 'communication',
70
+ status: queueStatus.metrics.deadLettered > 0 ? 'degraded' : 'healthy',
71
+ enabled: true,
72
+ successCount: queueStatus.metrics.delivered,
73
+ errorCount: queueStatus.metrics.deadLettered,
74
+ ...(queueStatus.dlqEntries[0]?.deadAt ? { lastErrorAt: queueStatus.dlqEntries[0].deadAt } : {}),
75
+ ...(queueStatus.dlqEntries[0]?.finalError ? { lastError: queueStatus.dlqEntries[0].finalError } : {}),
76
+ meta: {
77
+ attempts: queueStatus.metrics.totalAttempts,
78
+ retrying: queueStatus.metrics.retrying,
79
+ deadLetters: queueStatus.metrics.deadLettered,
80
+ dlqSize: queueStatus.metrics.dlqSize,
81
+ sloEnforced: queueStatus.sloEnforced,
82
+ },
83
+ }, 'boot.notifier');
84
+ }
85
+ }
86
+ } catch (error) {
87
+ logger.warn('daemon: notifier queue integration sync failed (non-fatal)', { error: summarizeError(error) });
88
+ }
89
+
90
+ // Configured external services become integration rows too, so "is my Linear
91
+ // token set" is answerable from the daemon rather than from each client's own
92
+ // idea of the same question.
93
+ try {
94
+ await syncConfiguredServices(services.runtimeDispatch.syncIntegration, services.serviceRegistry);
95
+ } catch (error) {
96
+ logger.warn('daemon: configured-service integration sync failed (non-fatal)', { error: summarizeError(error) });
97
+ }
98
+
99
+ // Load the plugins this host can serve. The manager was constructed by the
100
+ // graph and never initialised, so it could list a plugin directory and never
101
+ // load anything out of it — `enable` persisted a flag that turned nothing on.
102
+ // Both hosts read the same directories; each takes the registrations it can
103
+ // serve (plugin-composition.ts). Best-effort like everything else here: a
104
+ // plugin that will not load is not a reason for the daemon not to start.
105
+ try {
106
+ await services.pluginManager.init(createDaemonPluginLoaderDeps(services));
107
+ } catch (error) {
108
+ logger.warn('daemon: plugin load failed (non-fatal)', { error: summarizeError(error) });
109
+ }
110
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * cluster-composition.ts — this daemon's seat in the LAN leader election.
3
+ *
4
+ * When the same goodvibes install runs more than once on one network — a
5
+ * laptop and a desktop, or two processes on one machine — every copy
6
+ * independently reads the shared inbox, so one message is picked up twice and
7
+ * answered twice. The SDK's cluster coordinator elects exactly one node to be
8
+ * responsible for inbound consumption; everything else stays warm and silent.
9
+ *
10
+ * This file exists because this daemon does NOT get that for free from the
11
+ * SDK facade. The facade gates the consumers IT owns (Telegram
12
+ * ingress, the ntfy/Slack/Discord provider runtime), but this repository
13
+ * composes an inbound consumer of its own — the Slack/Discord/email inbox
14
+ * poller in daemon/handlers/inbox — and the facade knows nothing about it.
15
+ * A fix that only landed in the facade would leave the poller double-reading
16
+ * on exactly the machine this product runs on.
17
+ *
18
+ * So this composition builds the ONE coordinator for the process, registers
19
+ * the inbox poller with it, and hands the same instance to the DaemonServer
20
+ * (via `DaemonConfig.clusterCoordinator`) so the SDK's consumers ride the same
21
+ * leadership rather than holding a second, competing election.
22
+ *
23
+ * What leadership does NOT gate, here or anywhere: outbound delivery,
24
+ * sessions, the control plane, the HTTP listener, the UI. A standby node is a
25
+ * complete goodvibes daemon that simply is not the one reading the inbox.
26
+ */
27
+ import {
28
+ ClusterCoordinator,
29
+ inboxSurface,
30
+ readClusterSettings,
31
+ type ClusterConsumerGate,
32
+ type ClusterTransport,
33
+ } from '@pellux/goodvibes-sdk/platform/cluster';
34
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
35
+ import type { ShellPathService } from '@/runtime/index.ts';
36
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
37
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
38
+ import { VERSION } from '../version.ts';
39
+
40
+ /** What an inbound poller must expose to be placed under leadership. */
41
+ export interface GatedPollerControl {
42
+ /** Begin polling. Must not resolve until polling has actually begun. */
43
+ start(): Promise<void>;
44
+ /** Stop polling. Must not resolve until no further poll can run. */
45
+ stop(): Promise<void>;
46
+ }
47
+
48
+ /**
49
+ * Build the process's single coordinator.
50
+ *
51
+ * Constructing it is inert: no socket is opened and no state is written until
52
+ * `start()` runs, so composing a runtime in a test never joins a network.
53
+ *
54
+ * The version is THIS BINARY's version, not the SDK package's — the same
55
+ * distinction daemon/cli.ts already makes for the self-update artifact. It is
56
+ * the first ranking tier, so getting it wrong would let a stale build hold the
57
+ * role through an update.
58
+ */
59
+ export function createClusterComposition(options: {
60
+ /**
61
+ * Narrowed to what is actually read, which is exactly what
62
+ * `readClusterSettings` asks for. The daemon passes a whole ConfigManager;
63
+ * the handler context carries only this slice, and a test standing in a
64
+ * minimal config object should not have to fabricate the full fifty-odd
65
+ * members to compose a coordinator — a cast there would hide a real shape
66
+ * mismatch rather than document one.
67
+ */
68
+ readonly configManager: Pick<ConfigManager, 'getCategory'>;
69
+ readonly shellPaths: ShellPathService;
70
+ /**
71
+ * The datagram transport to coordinate over.
72
+ *
73
+ * Supplied by the group layer (cluster-group-composition.ts), which owns the
74
+ * socket and wraps every election datagram in the group envelope, signed with
75
+ * the current group key. That is what stops a daemon belonging to somebody
76
+ * else on the same network from taking part in this election at all.
77
+ *
78
+ * Absent — the plain default — means the coordinator opens its own socket and
79
+ * coordinates with anything that answers on the configured port.
80
+ */
81
+ readonly transport?: ClusterTransport | undefined;
82
+ }): ClusterCoordinator {
83
+ return new ClusterCoordinator({
84
+ settings: readClusterSettings(options.configManager),
85
+ version: VERSION,
86
+ // Surface-scoped, alongside this surface's other durable state: the node
87
+ // identity is per-surface and must not leak across surface roots.
88
+ stateDirectory: options.shellPaths.resolveProjectPath(GOODVIBES_DAEMON_SURFACE_ROOT, 'cluster'),
89
+ logger,
90
+ ...(options.transport ? { transport: options.transport } : {}),
91
+ });
92
+ }
93
+
94
+ /**
95
+ * ONE inbox provider as a leadership gate.
96
+ *
97
+ * One gate per account, not one for the poller. Each inbox account is its own
98
+ * surface in the election, so this laptop can hold the work Slack account
99
+ * while the desktop holds the mailbox — and losing one machine moves only the
100
+ * accounts it was reading. A single gate covering the whole poller could not
101
+ * express that: it would hand every account over together, and a node with a
102
+ * credential for only one of them could never take part at all.
103
+ *
104
+ * The provider id is the surface's local discriminator and is hashed before it
105
+ * reaches the network, so what travels is a digest and not an account name.
106
+ *
107
+ * No replay cursor is threaded in, and that is deliberate rather than an
108
+ * omission: the inbox keeps a persisted per-provider cursor of its own
109
+ * (InboxCursorStore), so a node taking an account over resumes from where the
110
+ * previous one committed. ntfy needs an explicit `since=` because it has no
111
+ * server-side per-subscriber cursor; this poller does not.
112
+ */
113
+ export function inboxPollerGate(providerId: string, control: GatedPollerControl): ClusterConsumerGate {
114
+ return {
115
+ id: `inbox-poller:${providerId}`,
116
+ surface: inboxSurface(providerId),
117
+ start: async () => {
118
+ await control.start();
119
+ },
120
+ stop: async () => {
121
+ await control.stop();
122
+ },
123
+ };
124
+ }