@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- 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
|
+
}
|