@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,448 @@
1
+ /**
2
+ * The unattended startup reconcile for the redundant install-script
3
+ * `goodvibes-daemon.service` unit — split out of `legacy-daemon-migration.ts`
4
+ * (which owns the shared unit definition, detection, and the CONSENTED
5
+ * `migrate-service` engine) so each module stays within the architecture
6
+ * gate's file-size cap. Same layer (`src/runtime/`), same injectable-seam
7
+ * discipline; see the sibling module's banner for the naming and layering
8
+ * rationale.
9
+ */
10
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
11
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
12
+ import {
13
+ defaultActionRunner,
14
+ defaultPortProbe,
15
+ legacyUnitPath,
16
+ parseMainPid,
17
+ LEGACY_SERVICE_UNIT_NAME,
18
+ SYSTEMCTL_TIMEOUT_MS,
19
+ type ManagedServiceActionRunner,
20
+ } from './legacy-daemon-migration.ts';
21
+
22
+ type ManagedServiceActionResult = ReturnType<ManagedServiceActionRunner>;
23
+
24
+ /**
25
+ * The exact marker string scripts/install.sh writes into every unit it
26
+ * creates (as a `# managed by goodvibes install.sh` comment). The reconcile
27
+ * check below keys on it to tell an installer-created legacy unit — safe to
28
+ * auto-retire — apart from a hand-written one that must only ever be reported.
29
+ * Kept in lockstep with `INSTALLER_MARKER` in scripts/install.sh.
30
+ */
31
+ export const INSTALLER_UNIT_MARKER = 'managed by goodvibes install.sh';
32
+
33
+
34
+
35
+ /**
36
+ * CUMULATIVE ceiling on the reconcile's whole boot-path pass. Per-call
37
+ * timeouts alone still allow ~5 sequential slow-but-completing calls to stack
38
+ * up (a degraded — not wedged — user bus), blocking the already-listening
39
+ * daemon's event loop for tens of seconds. One overall deadline covers the
40
+ * pass: once it is exceeded, every remaining call is skipped (reported as
41
+ * `status: null`, which the guards treat as unknown → refusal) and the result
42
+ * carries a notice saying so.
43
+ */
44
+ export const RECONCILE_DEADLINE_MS = 8_000;
45
+
46
+
47
+
48
+ /**
49
+ * Wrap a runner with the reconcile's cumulative deadline: calls after the
50
+ * deadline are skipped outright; calls near it get only the remaining budget
51
+ * as their per-call timeout (default runner only — injected runners are test
52
+ * fakes that answer instantly).
53
+ */
54
+ function makeDeadlineBoundRunner(
55
+ injected: ManagedServiceActionRunner | undefined,
56
+ perCallTimeoutMs: number,
57
+ deadlineMs: number,
58
+ ): { run: ManagedServiceActionRunner; deadlineHit: () => boolean } {
59
+ const startedAt = Date.now();
60
+ let hit = false;
61
+ const run: ManagedServiceActionRunner = (command, args) => {
62
+ const remaining = deadlineMs - (Date.now() - startedAt);
63
+ if (remaining <= 0) {
64
+ hit = true;
65
+ return { status: null, stdout: '', stderr: 'reconcile time budget exceeded — call skipped' } as ManagedServiceActionResult;
66
+ }
67
+ if (injected) return injected(command, args);
68
+ return defaultActionRunner(Math.min(perCallTimeoutMs, remaining))(command, args);
69
+ };
70
+ return { run, deadlineHit: () => hit };
71
+ }
72
+
73
+
74
+
75
+ export interface ReconcileRedundantLegacyUnitInput {
76
+ readonly homeDir: string;
77
+ /** The unit name this tool manages (e.g. 'goodvibes'). */
78
+ readonly trackedServiceName: string;
79
+ /**
80
+ * The endpoint CLIENTS resolve from settings.json (no runtime flag
81
+ * overrides). When provided, the reconcile requires this endpoint to be
82
+ * answering before it retires anything: a canonical daemon that is alive on
83
+ * some other port must never retire the unit clients actually reach.
84
+ */
85
+ readonly configuredEndpoint?: { readonly host: string; readonly port: number } | undefined;
86
+ /** Injectable probe of the configured endpoint. Defaults to `defaultPortProbe`. */
87
+ readonly endpointProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
88
+ /** Injectable existsSync so tests never touch the host filesystem. */
89
+ readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
90
+ /** Injectable unit-file read for the installer-marker check. */
91
+ readonly legacyUnitFileRead?: ((path: string) => string) | undefined;
92
+ /** Injectable removal of the legacy unit file. Defaults to a real `rmSync`. */
93
+ readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
94
+ /** Injectable systemctl runner (is-active/MainPID probes + disable/daemon-reload). */
95
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
96
+ /** Injectable liveness check for unit MainPIDs. Defaults to a signal-0 probe. */
97
+ readonly processAlive?: ((pid: number) => boolean) | undefined;
98
+ /** This process's pid, for the self-supervision guard. Defaults to `process.pid`. */
99
+ readonly ownPid?: number | undefined;
100
+ /** Injectable /proc/self/cgroup read, for the self-supervision guard. */
101
+ readonly readOwnCgroup?: (() => string) | undefined;
102
+ /** Timeout applied to the DEFAULT systemctl runner only. Defaults to SYSTEMCTL_TIMEOUT_MS. */
103
+ readonly systemctlTimeoutMs?: number | undefined;
104
+ /**
105
+ * Program the DEFAULT runner executes. Defaults to `systemctl` (resolved on
106
+ * PATH). Tests that deliberately exercise the default (non-injected) runner
107
+ * point this at an absolute stub path: Bun resolves a spawned program from
108
+ * the PATH captured at process start, so mutating `process.env.PATH` inside
109
+ * a test cannot redirect the call and the host's real systemctl would answer.
110
+ */
111
+ readonly systemctlCommand?: string | undefined;
112
+ /** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
113
+ readonly deadlineMs?: number | undefined;
114
+ }
115
+
116
+ export type ReconcileRedundantLegacyUnitReason =
117
+ | 'no-legacy-unit'
118
+ | 'canonical-not-active'
119
+ | 'canonical-mainpid-not-alive'
120
+ | 'self-supervised-by-legacy'
121
+ | 'legacy-state-unknown'
122
+ | 'legacy-running'
123
+ | 'configured-endpoint-unserved'
124
+ | 'hand-written'
125
+ | 'marker-unreadable'
126
+ | 'disable-failed'
127
+ | 'disable-timeout'
128
+ | 'retired';
129
+
130
+ export interface ReconcileRedundantLegacyUnitResult {
131
+ /**
132
+ * 'removed' = legacy unit auto-retired; 'notice' = left alone with a printed
133
+ * hint (hand-written, or unreadable); 'failed' = retirement was attempted but
134
+ * a destructive step failed or its outcome could not be confirmed (this tool
135
+ * removed nothing); 'noop' = guard refused or nothing to do.
136
+ */
137
+ readonly action: 'removed' | 'notice' | 'noop' | 'failed';
138
+ /** Machine-readable why, so callers can leave a breadcrumb for every refusal. */
139
+ readonly reason: ReconcileRedundantLegacyUnitReason;
140
+ readonly lines: readonly string[];
141
+ }
142
+
143
+ function defaultProcessAlive(pid: number): boolean {
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function defaultReadOwnCgroup(): string {
153
+ try {
154
+ return readFileSync('/proc/self/cgroup', 'utf-8');
155
+ } catch {
156
+ return '';
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Cheap, unattended startup reconcile for the exact host state the production
162
+ * incident surfaced: an installer-managed legacy `goodvibes-daemon.service`
163
+ * unit sitting ENABLED alongside the canonical `goodvibes.service` that is
164
+ * ENABLED + ACTIVE. Nothing ever disabled the redundant legacy unit, so a
165
+ * bare-args second daemon could boot and fight the real one for the port.
166
+ *
167
+ * This runs on daemon startup (no user invocation needed) and, guard-railed:
168
+ * - Only acts when the canonical unit is CONFIRMED serving: `is-active`
169
+ * reports active AND its MainPID resolves to a live process. `is-active`
170
+ * alone is not proof — a Type=simple unit reports active from fork onward,
171
+ * including the pre-bind window of a daemon that is about to crash-loop.
172
+ * - NEVER acts from inside the legacy unit itself: if this process IS the
173
+ * legacy unit's MainPID, or /proc/self/cgroup names the legacy unit,
174
+ * running `disable --now` would SIGTERM the very daemon executing this
175
+ * code (mid-boot, from inside a blocking spawnSync). Refuses instead.
176
+ * - NEVER stops a RUNNING legacy daemon: if the legacy unit's MainPID is a
177
+ * live process, it may be the daemon serving the endpoint clients are
178
+ * configured to reach (the wrong-port two-daemon state) — the unattended
179
+ * path only retires enabled-but-NOT-running legacy units; a running one
180
+ * gets a notice pointing at the consented migrate-service.
181
+ * - Requires the CONFIGURED endpoint (the one clients resolve from
182
+ * settings.json, no runtime overrides) to be answering before retiring —
183
+ * a canonical daemon alive on some other port proves nothing about the
184
+ * endpoint clients use.
185
+ * - AUTO-RETIRES only an installer-MARKER-managed legacy unit, and only
186
+ * removes its unit file AFTER `disable --now` reports success. A NONZERO
187
+ * exit leaves everything in place and says so; a TIMED-OUT disable
188
+ * (status null) has an UNKNOWN outcome — the enablement state is
189
+ * re-inspected before anything is printed, and the receipt states what
190
+ * was actually confirmed, never a blanket claim.
191
+ * - A hand-written legacy unit (no marker) is never touched — a one-line
192
+ * actionable notice. An UNREADABLE unit file is reported as unreadable,
193
+ * never misdiagnosed as hand-written.
194
+ * - The whole pass shares ONE cumulative time budget (RECONCILE_DEADLINE_MS)
195
+ * on top of the per-call timeout, so even a degraded-but-completing user
196
+ * bus cannot stack five slow calls into a half-minute boot stall.
197
+ * Every side effect goes through the same injectable seams the migration engine
198
+ * uses, so tests exercise it deterministically and never touch a real service.
199
+ */
200
+ export async function reconcileRedundantLegacyUnit(
201
+ input: ReconcileRedundantLegacyUnitInput,
202
+ ): Promise<ReconcileRedundantLegacyUnitResult> {
203
+ const path = legacyUnitPath(input.homeDir);
204
+ const fileExists = input.legacyUnitFileExists ?? existsSync;
205
+ if (!fileExists(path)) return { action: 'noop', reason: 'no-legacy-unit', lines: [] };
206
+
207
+ const { run, deadlineHit } = makeDeadlineBoundRunner(
208
+ input.actionRunner,
209
+ input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
210
+ input.deadlineMs ?? RECONCILE_DEADLINE_MS,
211
+ );
212
+ const systemctl = input.systemctlCommand ?? 'systemctl';
213
+ const canonicalUnit = `${input.trackedServiceName}.service`;
214
+ const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
215
+ const deadlineNote = (): string[] =>
216
+ deadlineHit()
217
+ ? ['note: the reconcile hit its overall time budget — remaining checks were skipped; it will re-run at the next daemon start.']
218
+ : [];
219
+
220
+ // Guard 1: the canonical unit must report active. (A timed-out, skipped, or
221
+ // failed probe — e.g. a wedged user bus — lands here too and refuses.)
222
+ const probe = run(systemctl, ['--user', 'is-active', canonicalUnit]);
223
+ const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
224
+ if (!canonicalActive) {
225
+ return {
226
+ action: 'noop',
227
+ reason: 'canonical-not-active',
228
+ lines: [
229
+ `legacy-unit reconcile: a ${legacyUnit} unit file exists at ${path} but ${canonicalUnit} is not confirmably active — ` +
230
+ 'left untouched (it may be the only daemon).',
231
+ ...deadlineNote(),
232
+ ],
233
+ };
234
+ }
235
+
236
+ // Guard 2: 'active' alone does not prove the canonical unit is the daemon
237
+ // actually serving (Type=simple reports active from fork onward). Require
238
+ // its MainPID to resolve and be a live process.
239
+ const canonicalPid = parseMainPid(run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
240
+ const alive = input.processAlive ?? defaultProcessAlive;
241
+ if (canonicalPid === undefined || !alive(canonicalPid)) {
242
+ return {
243
+ action: 'noop',
244
+ reason: 'canonical-mainpid-not-alive',
245
+ lines: [
246
+ `legacy-unit reconcile: ${canonicalUnit} reports active but its MainPID could not be confirmed alive — ` +
247
+ `left the ${legacyUnit} unit untouched.`,
248
+ ...deadlineNote(),
249
+ ],
250
+ };
251
+ }
252
+
253
+ // Guard 3: never disable the unit that launched THIS process. `disable
254
+ // --now` on our own supervising unit would SIGTERM this daemon's whole
255
+ // cgroup mid-boot, from inside the blocking systemctl call.
256
+ //
257
+ // The legacy MainPID probe is TRI-STATE, exactly like guards 1-2: a reply
258
+ // whose status is non-zero or null (bus flap, timeout, skipped-by-deadline)
259
+ // does NOT mean "MainPID=0, unit affirmatively stopped" — reading it that
260
+ // way let one transient systemctl failure on exactly this call authorize
261
+ // `disable --now` against a LIVE legacy daemon (and guard 5 cannot catch
262
+ // it: in the wrong-port state the legacy daemon itself answers the
263
+ // configured-endpoint probe). Unknown refuses.
264
+ const ownPid = input.ownPid ?? process.pid;
265
+ const legacyPidReply = run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
266
+ const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
267
+ const legacyPid = parseMainPid(legacyPidReply);
268
+ const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
269
+ if (legacyPid === ownPid || ownCgroup.includes(legacyUnit)) {
270
+ return {
271
+ action: 'noop',
272
+ reason: 'self-supervised-by-legacy',
273
+ lines: [
274
+ `legacy-unit reconcile: this daemon appears to be running UNDER ${legacyUnit} itself — refusing to disable ` +
275
+ `the unit supervising the current process. Migrate from the canonical side instead: goodvibes-daemon migrate-service`,
276
+ ],
277
+ };
278
+ }
279
+ if (!legacyPidKnown) {
280
+ return {
281
+ action: 'noop',
282
+ reason: 'legacy-state-unknown',
283
+ lines: [
284
+ `legacy-unit reconcile: could not determine whether ${legacyUnit} has a running daemon (the MainPID query ` +
285
+ 'failed or timed out) — refusing to act on a guess; it will be re-checked at the next daemon start.',
286
+ ...deadlineNote(),
287
+ ],
288
+ };
289
+ }
290
+
291
+ // Guard 4: never stop a RUNNING legacy daemon from the unattended path. A
292
+ // live legacy MainPID means a second daemon is actually serving something —
293
+ // possibly the endpoint clients resolve from settings.json (the wrong-port
294
+ // two-daemon state). Retiring an enabled-but-idle unit needs no --now kill;
295
+ // stopping a serving one needs consent. (A KNOWN reply of MainPID=0 is the
296
+ // affirmative "stopped" that permits proceeding.)
297
+ if (legacyPid !== undefined && alive(legacyPid)) {
298
+ return {
299
+ action: 'noop',
300
+ reason: 'legacy-running',
301
+ lines: [
302
+ `legacy-unit reconcile: ${legacyUnit} has a live main process (pid ${legacyPid}) — a second daemon is ` +
303
+ 'actually running, and it may be the one serving the endpoint your clients are configured to reach. ' +
304
+ 'Refusing to stop it unattended; migrate deliberately with: goodvibes-daemon migrate-service',
305
+ ],
306
+ };
307
+ }
308
+
309
+ // Guard 5: the endpoint clients resolve from settings.json must be answered
310
+ // before anything is retired. The canonical daemon being alive proves only
311
+ // that A daemon runs — not that the configured endpoint is served (its unit
312
+ // may pin different launch args).
313
+ if (input.configuredEndpoint) {
314
+ const endpointProbe = input.endpointProbe ?? defaultPortProbe;
315
+ const served = await endpointProbe(input.configuredEndpoint.host, input.configuredEndpoint.port);
316
+ if (!served) {
317
+ return {
318
+ action: 'noop',
319
+ reason: 'configured-endpoint-unserved',
320
+ lines: [
321
+ `legacy-unit reconcile: nothing is answering on the CONFIGURED endpoint ` +
322
+ `${input.configuredEndpoint.host}:${input.configuredEndpoint.port} — the canonical daemon is alive but not ` +
323
+ `provably serving what clients resolve from settings. Left the ${legacyUnit} unit untouched; check the ` +
324
+ "canonical unit's launch arguments against the controlPlane settings.",
325
+ ],
326
+ };
327
+ }
328
+ }
329
+
330
+ const readFile = input.legacyUnitFileRead ?? ((p: string) => readFileSync(p, 'utf-8'));
331
+ let marked = false;
332
+ let readError: string | undefined;
333
+ try {
334
+ marked = readFile(path).includes(INSTALLER_UNIT_MARKER);
335
+ } catch (error) {
336
+ readError = summarizeError(error);
337
+ }
338
+
339
+ if (readError !== undefined) {
340
+ // Fail closed, and say what actually happened: the file could not be READ.
341
+ // Asserting "hand-written (no installer marker)" here would be a false
342
+ // provenance claim about a file whose contents were never established.
343
+ return {
344
+ action: 'notice',
345
+ reason: 'marker-unreadable',
346
+ lines: [
347
+ `note: ${canonicalUnit} is active and a separate ${legacyUnit} exists at ${path}, but its unit file could not ` +
348
+ `be read (${readError}) — left untouched. Inspect it yourself; if it is redundant, retire it with: ` +
349
+ `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
350
+ ],
351
+ };
352
+ }
353
+
354
+ if (!marked) {
355
+ return {
356
+ action: 'notice',
357
+ reason: 'hand-written',
358
+ lines: [
359
+ `note: ${canonicalUnit} is active but a separate ${legacyUnit} also exists at ${path}. ` +
360
+ 'It is hand-written (no installer marker) so it was left untouched; retire it yourself to stop two daemons ' +
361
+ `competing for the same port: systemctl --user disable --now ${legacyUnit} && rm ${path} && ` +
362
+ 'systemctl --user daemon-reload',
363
+ ],
364
+ };
365
+ }
366
+
367
+ // Installer-marker-managed and redundant: disable first, and only remove the
368
+ // unit file if the disable actually succeeded — otherwise the enablement
369
+ // symlink dangles at a deleted file and this tool can never repair it (the
370
+ // next pass no-ops at the file-exists check).
371
+ const disableResult = run(systemctl, ['--user', 'disable', '--now', legacyUnit]);
372
+ const disableStatus = disableResult.status ?? 1;
373
+ if (disableStatus !== 0) {
374
+ if (disableResult.status === null) {
375
+ // TIMED OUT: the outcome is UNKNOWN, not failed — `disable --now`
376
+ // removes the enablement symlinks synchronously BEFORE waiting on the
377
+ // stop job, so the disable may well have taken effect even though the
378
+ // client was killed. Re-inspect instead of printing a blanket denial.
379
+ const reinspect = run(systemctl, ['--user', 'is-enabled', legacyUnit]);
380
+ const reinspectOut = (reinspect.stdout ?? '').trim();
381
+ const confirmedDisabled = (reinspect.status !== null)
382
+ && (reinspectOut === 'disabled' || reinspectOut === 'not-found');
383
+ if (confirmedDisabled) {
384
+ // The disable took effect; only the client timed out waiting on the
385
+ // stop job. Proceed as success, saying exactly that.
386
+ const removeFileAfterTimeout = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
387
+ let removeErrorAfterTimeout: string | undefined;
388
+ try {
389
+ removeFileAfterTimeout(path);
390
+ } catch (error) {
391
+ removeErrorAfterTimeout = summarizeError(error);
392
+ }
393
+ run(systemctl, ['--user', 'daemon-reload']);
394
+ const lines = [
395
+ `reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
396
+ `installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
397
+ 'Its stop may still be completing.',
398
+ ];
399
+ if (removeErrorAfterTimeout) {
400
+ lines.push(`note: could not remove ${path}: ${removeErrorAfterTimeout} — remove it by hand.`);
401
+ }
402
+ lines.push(...deadlineNote());
403
+ return { action: 'removed', reason: 'retired', lines };
404
+ }
405
+ return {
406
+ action: 'failed',
407
+ reason: 'disable-timeout',
408
+ lines: [
409
+ `legacy-unit reconcile: the disable of ${legacyUnit} timed out and its outcome is UNKNOWN — the enablement ` +
410
+ `state could not be re-confirmed. This tool removed nothing; the unit file at ${path} was left in place ` +
411
+ 'and will be re-checked at the next daemon start.',
412
+ `Verify it yourself: systemctl --user is-enabled ${legacyUnit}`,
413
+ ...deadlineNote(),
414
+ ],
415
+ };
416
+ }
417
+ const detail = (disableResult.stderr ?? disableResult.stdout ?? '').trim() || 'no output';
418
+ return {
419
+ action: 'failed',
420
+ reason: 'disable-failed',
421
+ lines: [
422
+ `legacy-unit reconcile: the disable of the redundant installer-managed ${legacyUnit} reported failure ` +
423
+ `(${detail}) — this tool removed nothing; the unit file at ${path} was left in place.`,
424
+ `Verify its state and retire it yourself: systemctl --user is-enabled ${legacyUnit} ; ` +
425
+ `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
426
+ ],
427
+ };
428
+ }
429
+
430
+ const removeFile = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
431
+ let removeError: string | undefined;
432
+ try {
433
+ removeFile(path);
434
+ } catch (error) {
435
+ removeError = summarizeError(error);
436
+ }
437
+ run(systemctl, ['--user', 'daemon-reload']);
438
+
439
+ const lines = [
440
+ `reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
441
+ `was disabled${removeError ? '' : ` and removed (${path})`}.`,
442
+ ];
443
+ if (removeError) {
444
+ lines.push(`note: could not remove ${path}: ${removeError} — remove it by hand.`);
445
+ }
446
+ lines.push(...deadlineNote());
447
+ return { action: 'removed', reason: 'retired', lines };
448
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * mail-composition.ts
3
+ *
4
+ * The deps that let the platform register this daemon's `email.*` verbs.
5
+ *
6
+ * `calendar.*` and `email.*` are served by the platform now, not by handlers in
7
+ * this repository — those were deleted when the platform gained an
8
+ * implementation the daemon could call. What is left for a product to supply is
9
+ * the wiring, and it is not optional: without `homeDirectory` the calendar
10
+ * composition returns null, without these deps the mail one does, and either way
11
+ * the verbs stay cataloged-but-unhandled while every surface reports the daemon
12
+ * unreachable on a daemon that is working perfectly.
13
+ *
14
+ * Extracted from services.ts rather than living there, because that file sits at
15
+ * the 800-line architecture cap and a composition step is exactly the kind of
16
+ * thing the cap exists to push into a module of its own.
17
+ */
18
+
19
+ import {
20
+ withSurfaceEmailConfig,
21
+ describeSurfaceEmailConfigProblem,
22
+ describeSenderClaimNeutrally,
23
+ type EmailServiceDeps,
24
+ type SurfaceEmailConfigProblem,
25
+ } from '@pellux/goodvibes-sdk/platform/email';
26
+ import { nodeEmailTransport } from '@pellux/goodvibes-sdk/platform/email/node';
27
+
28
+ /** The narrow slices this composition needs; the real managers satisfy them. */
29
+ interface MailCompositionInput {
30
+ readonly configManager: { get(key: never): unknown };
31
+ readonly secretsManager: { get(key: string): Promise<string | null> };
32
+ }
33
+
34
+ /**
35
+ * Build the mail deps and the not-configured describer that go to
36
+ * `registerGatewayVerbGroups`.
37
+ *
38
+ * The settings come from the daemon's own `surfaces.email.*` keys through
39
+ * `withSurfaceEmailConfig`, so the keys an operator has already set — and that
40
+ * the settings modal now shows — keep working unchanged, and a not-configured
41
+ * answer names the keys THIS operator actually has rather than the ones the
42
+ * service validates internally.
43
+ */
44
+ export function composeMailDeps(input: MailCompositionInput): {
45
+ readonly emailServiceDeps: EmailServiceDeps;
46
+ readonly describeEmailConfigProblem: () => Promise<SurfaceEmailConfigProblem | null>;
47
+ } {
48
+ const getConfig = (key: string): unknown => input.configManager.get(key as never);
49
+ const emailServiceDeps = withSurfaceEmailConfig({
50
+ getConfig,
51
+ secretsManager: input.secretsManager,
52
+ transport: nodeEmailTransport,
53
+ // The daemon has no wording of its own for a sender line, so it takes the
54
+ // platform's rather than growing a second implementation of a rule that is
55
+ // security-relevant: a From: header is a claim, sender authentication
56
+ // raises display confidence only, and commandAuthority is the literal
57
+ // 'none'.
58
+ describeSenderClaim: describeSenderClaimNeutrally,
59
+ } as never);
60
+ return {
61
+ emailServiceDeps,
62
+ describeEmailConfigProblem: () =>
63
+ describeSurfaceEmailConfigProblem(getConfig, input.secretsManager),
64
+ };
65
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * notification-dispatch.ts — how this daemon dispatches notices without a screen.
3
+ *
4
+ * The SDK's NotificationRouter decides where a domain notification goes and
5
+ * collapses bursts and batches. Its three targets — `conversation`,
6
+ * `status_bar`, `panel_only` — are all SCREEN targets: an inline conversation
7
+ * line, a status bar, a panel. There is no channel member in that type, and
8
+ * there never was. Which means the router is a surface mechanism end to end,
9
+ * and this process has no screen.
10
+ *
11
+ * This module used to wire the router to every curated domain anyway, writing
12
+ * into a bounded ring whose `list()` had no caller anywhere in this repository.
13
+ * Six domains of events, for the daemon's whole lifetime, into a buffer nobody
14
+ * read — and the type declaring it still described the ring as "the panel's
15
+ * live producer", for a product with no panels. That silent-success failure
16
+ * class is exactly what this module removes: the producer goes.
17
+ *
18
+ * What a headless process CAN do with a notice is send it, and the daemon
19
+ * already does that on the paths that are genuinely channel-shaped: automation
20
+ * failure notices through the delivery manager, occasion nudges through the
21
+ * channel delivery router, and the WebhookNotifier attached to the runtime bus
22
+ * at boot for agent and workflow outcomes.
23
+ *
24
+ * One notice had no such path: memory pressure. The MemoryGovernor measures the
25
+ * process it runs in, so the daemon's pressure is the daemon's own and no
26
+ * surface can report it — and the daemon that ran out of memory is exactly the
27
+ * one that cannot tell you afterwards. It now goes out over the operator's
28
+ * configured notice destination (`notifications.webhookUrls`, the same list the
29
+ * bus bridge uses), and says so at its own level in the activity log when no
30
+ * destination is configured. Sent, or written down; never dropped.
31
+ */
32
+
33
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
34
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
35
+ import { memoryPressureLine, memoryPressureLevel, type MemoryPressurePayload } from '@pellux/goodvibes-sdk/platform/runtime/memory';
36
+
37
+ /**
38
+ * Where a channel-shaped notice leaves the daemon.
39
+ *
40
+ * The WebhookNotifier's own shape, narrowed to the two members this needs, so a
41
+ * test can drive it without an HTTP client and so the composition can pass the
42
+ * one instance the notification verbs and the bus bridge already share.
43
+ */
44
+ export interface DaemonNoticeChannel {
45
+ isConfigured(): boolean;
46
+ send(text: string): Promise<unknown>;
47
+ }
48
+
49
+ /**
50
+ * Send the daemon's own memory-pressure notice to the operator's configured
51
+ * notice destination. Returns an unsubscribe function.
52
+ *
53
+ * The MemoryGovernor emits OPS_MEMORY_PRESSURE on the 'ops' domain when the
54
+ * pressure tier changes or the leak tripwire fires. That domain also carries
55
+ * high-churn audit and metric events, which is why this is a targeted bridge on
56
+ * one event type rather than a subscription to the domain: the tier change is
57
+ * the operator's business and the churn is not.
58
+ *
59
+ * Delivery failure is logged, never thrown — a webhook endpoint being down is
60
+ * not a reason for the process reporting memory pressure to also crash.
61
+ */
62
+ export function wireMemoryPressureChannelNotice(
63
+ runtimeBus: RuntimeEventBus,
64
+ channel: DaemonNoticeChannel,
65
+ ): () => void {
66
+ return runtimeBus.onDomain('ops', (envelope) => {
67
+ if (envelope.type !== 'OPS_MEMORY_PRESSURE') return;
68
+ const payload = envelope.payload as MemoryPressurePayload;
69
+ const level = memoryPressureLevel(payload);
70
+ const line = memoryPressureLine(payload);
71
+ if (!channel.isConfigured()) {
72
+ // No destination configured. The notice still exists — at its own
73
+ // severity, where the operator looks when the daemon misbehaves.
74
+ if (level === 'critical') logger.error(line);
75
+ else if (level === 'warning') logger.warn(line);
76
+ else logger.info(line);
77
+ return;
78
+ }
79
+ void Promise.resolve(channel.send(line)).catch((error: unknown) => {
80
+ logger.warn('Memory pressure notice could not be delivered', {
81
+ error: error instanceof Error ? error.message : String(error),
82
+ notice: line,
83
+ });
84
+ });
85
+ });
86
+ }