@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,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
|
+
}
|