@pellux/goodvibes-tui 1.18.0 → 1.19.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.
@@ -0,0 +1,439 @@
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
+ /** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
105
+ readonly deadlineMs?: number | undefined;
106
+ }
107
+
108
+ export type ReconcileRedundantLegacyUnitReason =
109
+ | 'no-legacy-unit'
110
+ | 'canonical-not-active'
111
+ | 'canonical-mainpid-not-alive'
112
+ | 'self-supervised-by-legacy'
113
+ | 'legacy-state-unknown'
114
+ | 'legacy-running'
115
+ | 'configured-endpoint-unserved'
116
+ | 'hand-written'
117
+ | 'marker-unreadable'
118
+ | 'disable-failed'
119
+ | 'disable-timeout'
120
+ | 'retired';
121
+
122
+ export interface ReconcileRedundantLegacyUnitResult {
123
+ /**
124
+ * 'removed' = legacy unit auto-retired; 'notice' = left alone with a printed
125
+ * hint (hand-written, or unreadable); 'failed' = retirement was attempted but
126
+ * a destructive step failed or its outcome could not be confirmed (this tool
127
+ * removed nothing); 'noop' = guard refused or nothing to do.
128
+ */
129
+ readonly action: 'removed' | 'notice' | 'noop' | 'failed';
130
+ /** Machine-readable why, so callers can leave a breadcrumb for every refusal. */
131
+ readonly reason: ReconcileRedundantLegacyUnitReason;
132
+ readonly lines: readonly string[];
133
+ }
134
+
135
+ function defaultProcessAlive(pid: number): boolean {
136
+ try {
137
+ process.kill(pid, 0);
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function defaultReadOwnCgroup(): string {
145
+ try {
146
+ return readFileSync('/proc/self/cgroup', 'utf-8');
147
+ } catch {
148
+ return '';
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Cheap, unattended startup reconcile for the exact host state the production
154
+ * incident surfaced: an installer-managed legacy `goodvibes-daemon.service`
155
+ * unit sitting ENABLED alongside the canonical `goodvibes.service` that is
156
+ * ENABLED + ACTIVE. Nothing ever disabled the redundant legacy unit, so a
157
+ * bare-args second daemon could boot and fight the real one for the port.
158
+ *
159
+ * This runs on daemon startup (no user invocation needed) and, guard-railed:
160
+ * - Only acts when the canonical unit is CONFIRMED serving: `is-active`
161
+ * reports active AND its MainPID resolves to a live process. `is-active`
162
+ * alone is not proof — a Type=simple unit reports active from fork onward,
163
+ * including the pre-bind window of a daemon that is about to crash-loop.
164
+ * - NEVER acts from inside the legacy unit itself: if this process IS the
165
+ * legacy unit's MainPID, or /proc/self/cgroup names the legacy unit,
166
+ * running `disable --now` would SIGTERM the very daemon executing this
167
+ * code (mid-boot, from inside a blocking spawnSync). Refuses instead.
168
+ * - NEVER stops a RUNNING legacy daemon: if the legacy unit's MainPID is a
169
+ * live process, it may be the daemon serving the endpoint clients are
170
+ * configured to reach (the wrong-port two-daemon state) — the unattended
171
+ * path only retires enabled-but-NOT-running legacy units; a running one
172
+ * gets a notice pointing at the consented migrate-service.
173
+ * - Requires the CONFIGURED endpoint (the one clients resolve from
174
+ * settings.json, no runtime overrides) to be answering before retiring —
175
+ * a canonical daemon alive on some other port proves nothing about the
176
+ * endpoint clients use.
177
+ * - AUTO-RETIRES only an installer-MARKER-managed legacy unit, and only
178
+ * removes its unit file AFTER `disable --now` reports success. A NONZERO
179
+ * exit leaves everything in place and says so; a TIMED-OUT disable
180
+ * (status null) has an UNKNOWN outcome — the enablement state is
181
+ * re-inspected before anything is printed, and the receipt states what
182
+ * was actually confirmed, never a blanket claim.
183
+ * - A hand-written legacy unit (no marker) is never touched — a one-line
184
+ * actionable notice. An UNREADABLE unit file is reported as unreadable,
185
+ * never misdiagnosed as hand-written.
186
+ * - The whole pass shares ONE cumulative time budget (RECONCILE_DEADLINE_MS)
187
+ * on top of the per-call timeout, so even a degraded-but-completing user
188
+ * bus cannot stack five slow calls into a half-minute boot stall.
189
+ * Every side effect goes through the same injectable seams the migration engine
190
+ * uses, so tests exercise it deterministically and never touch a real service.
191
+ */
192
+ export async function reconcileRedundantLegacyUnit(
193
+ input: ReconcileRedundantLegacyUnitInput,
194
+ ): Promise<ReconcileRedundantLegacyUnitResult> {
195
+ const path = legacyUnitPath(input.homeDir);
196
+ const fileExists = input.legacyUnitFileExists ?? existsSync;
197
+ if (!fileExists(path)) return { action: 'noop', reason: 'no-legacy-unit', lines: [] };
198
+
199
+ const { run, deadlineHit } = makeDeadlineBoundRunner(
200
+ input.actionRunner,
201
+ input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
202
+ input.deadlineMs ?? RECONCILE_DEADLINE_MS,
203
+ );
204
+ const canonicalUnit = `${input.trackedServiceName}.service`;
205
+ const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
206
+ const deadlineNote = (): string[] =>
207
+ deadlineHit()
208
+ ? ['note: the reconcile hit its overall time budget — remaining checks were skipped; it will re-run at the next daemon start.']
209
+ : [];
210
+
211
+ // Guard 1: the canonical unit must report active. (A timed-out, skipped, or
212
+ // failed probe — e.g. a wedged user bus — lands here too and refuses.)
213
+ const probe = run('systemctl', ['--user', 'is-active', canonicalUnit]);
214
+ const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
215
+ if (!canonicalActive) {
216
+ return {
217
+ action: 'noop',
218
+ reason: 'canonical-not-active',
219
+ lines: [
220
+ `legacy-unit reconcile: a ${legacyUnit} unit file exists at ${path} but ${canonicalUnit} is not confirmably active — ` +
221
+ 'left untouched (it may be the only daemon).',
222
+ ...deadlineNote(),
223
+ ],
224
+ };
225
+ }
226
+
227
+ // Guard 2: 'active' alone does not prove the canonical unit is the daemon
228
+ // actually serving (Type=simple reports active from fork onward). Require
229
+ // its MainPID to resolve and be a live process.
230
+ const canonicalPid = parseMainPid(run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
231
+ const alive = input.processAlive ?? defaultProcessAlive;
232
+ if (canonicalPid === undefined || !alive(canonicalPid)) {
233
+ return {
234
+ action: 'noop',
235
+ reason: 'canonical-mainpid-not-alive',
236
+ lines: [
237
+ `legacy-unit reconcile: ${canonicalUnit} reports active but its MainPID could not be confirmed alive — ` +
238
+ `left the ${legacyUnit} unit untouched.`,
239
+ ...deadlineNote(),
240
+ ],
241
+ };
242
+ }
243
+
244
+ // Guard 3: never disable the unit that launched THIS process. `disable
245
+ // --now` on our own supervising unit would SIGTERM this daemon's whole
246
+ // cgroup mid-boot, from inside the blocking systemctl call.
247
+ //
248
+ // The legacy MainPID probe is TRI-STATE, exactly like guards 1-2: a reply
249
+ // whose status is non-zero or null (bus flap, timeout, skipped-by-deadline)
250
+ // does NOT mean "MainPID=0, unit affirmatively stopped" — reading it that
251
+ // way let one transient systemctl failure on exactly this call authorize
252
+ // `disable --now` against a LIVE legacy daemon (and guard 5 cannot catch
253
+ // it: in the wrong-port state the legacy daemon itself answers the
254
+ // configured-endpoint probe). Unknown refuses.
255
+ const ownPid = input.ownPid ?? process.pid;
256
+ const legacyPidReply = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
257
+ const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
258
+ const legacyPid = parseMainPid(legacyPidReply);
259
+ const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
260
+ if (legacyPid === ownPid || ownCgroup.includes(legacyUnit)) {
261
+ return {
262
+ action: 'noop',
263
+ reason: 'self-supervised-by-legacy',
264
+ lines: [
265
+ `legacy-unit reconcile: this daemon appears to be running UNDER ${legacyUnit} itself — refusing to disable ` +
266
+ `the unit supervising the current process. Migrate from the canonical side instead: goodvibes-daemon migrate-service`,
267
+ ],
268
+ };
269
+ }
270
+ if (!legacyPidKnown) {
271
+ return {
272
+ action: 'noop',
273
+ reason: 'legacy-state-unknown',
274
+ lines: [
275
+ `legacy-unit reconcile: could not determine whether ${legacyUnit} has a running daemon (the MainPID query ` +
276
+ 'failed or timed out) — refusing to act on a guess; it will be re-checked at the next daemon start.',
277
+ ...deadlineNote(),
278
+ ],
279
+ };
280
+ }
281
+
282
+ // Guard 4: never stop a RUNNING legacy daemon from the unattended path. A
283
+ // live legacy MainPID means a second daemon is actually serving something —
284
+ // possibly the endpoint clients resolve from settings.json (the wrong-port
285
+ // two-daemon state). Retiring an enabled-but-idle unit needs no --now kill;
286
+ // stopping a serving one needs consent. (A KNOWN reply of MainPID=0 is the
287
+ // affirmative "stopped" that permits proceeding.)
288
+ if (legacyPid !== undefined && alive(legacyPid)) {
289
+ return {
290
+ action: 'noop',
291
+ reason: 'legacy-running',
292
+ lines: [
293
+ `legacy-unit reconcile: ${legacyUnit} has a live main process (pid ${legacyPid}) — a second daemon is ` +
294
+ 'actually running, and it may be the one serving the endpoint your clients are configured to reach. ' +
295
+ 'Refusing to stop it unattended; migrate deliberately with: goodvibes-daemon migrate-service',
296
+ ],
297
+ };
298
+ }
299
+
300
+ // Guard 5: the endpoint clients resolve from settings.json must be answered
301
+ // before anything is retired. The canonical daemon being alive proves only
302
+ // that A daemon runs — not that the configured endpoint is served (its unit
303
+ // may pin different launch args).
304
+ if (input.configuredEndpoint) {
305
+ const endpointProbe = input.endpointProbe ?? defaultPortProbe;
306
+ const served = await endpointProbe(input.configuredEndpoint.host, input.configuredEndpoint.port);
307
+ if (!served) {
308
+ return {
309
+ action: 'noop',
310
+ reason: 'configured-endpoint-unserved',
311
+ lines: [
312
+ `legacy-unit reconcile: nothing is answering on the CONFIGURED endpoint ` +
313
+ `${input.configuredEndpoint.host}:${input.configuredEndpoint.port} — the canonical daemon is alive but not ` +
314
+ `provably serving what clients resolve from settings. Left the ${legacyUnit} unit untouched; check the ` +
315
+ "canonical unit's launch arguments against the controlPlane settings.",
316
+ ],
317
+ };
318
+ }
319
+ }
320
+
321
+ const readFile = input.legacyUnitFileRead ?? ((p: string) => readFileSync(p, 'utf-8'));
322
+ let marked = false;
323
+ let readError: string | undefined;
324
+ try {
325
+ marked = readFile(path).includes(INSTALLER_UNIT_MARKER);
326
+ } catch (error) {
327
+ readError = summarizeError(error);
328
+ }
329
+
330
+ if (readError !== undefined) {
331
+ // Fail closed, and say what actually happened: the file could not be READ.
332
+ // Asserting "hand-written (no installer marker)" here would be a false
333
+ // provenance claim about a file whose contents were never established.
334
+ return {
335
+ action: 'notice',
336
+ reason: 'marker-unreadable',
337
+ lines: [
338
+ `note: ${canonicalUnit} is active and a separate ${legacyUnit} exists at ${path}, but its unit file could not ` +
339
+ `be read (${readError}) — left untouched. Inspect it yourself; if it is redundant, retire it with: ` +
340
+ `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
341
+ ],
342
+ };
343
+ }
344
+
345
+ if (!marked) {
346
+ return {
347
+ action: 'notice',
348
+ reason: 'hand-written',
349
+ lines: [
350
+ `note: ${canonicalUnit} is active but a separate ${legacyUnit} also exists at ${path}. ` +
351
+ 'It is hand-written (no installer marker) so it was left untouched; retire it yourself to stop two daemons ' +
352
+ `competing for the same port: systemctl --user disable --now ${legacyUnit} && rm ${path} && ` +
353
+ 'systemctl --user daemon-reload',
354
+ ],
355
+ };
356
+ }
357
+
358
+ // Installer-marker-managed and redundant: disable first, and only remove the
359
+ // unit file if the disable actually succeeded — otherwise the enablement
360
+ // symlink dangles at a deleted file and this tool can never repair it (the
361
+ // next pass no-ops at the file-exists check).
362
+ const disableResult = run('systemctl', ['--user', 'disable', '--now', legacyUnit]);
363
+ const disableStatus = disableResult.status ?? 1;
364
+ if (disableStatus !== 0) {
365
+ if (disableResult.status === null) {
366
+ // TIMED OUT: the outcome is UNKNOWN, not failed — `disable --now`
367
+ // removes the enablement symlinks synchronously BEFORE waiting on the
368
+ // stop job, so the disable may well have taken effect even though the
369
+ // client was killed. Re-inspect instead of printing a blanket denial.
370
+ const reinspect = run('systemctl', ['--user', 'is-enabled', legacyUnit]);
371
+ const reinspectOut = (reinspect.stdout ?? '').trim();
372
+ const confirmedDisabled = (reinspect.status !== null)
373
+ && (reinspectOut === 'disabled' || reinspectOut === 'not-found');
374
+ if (confirmedDisabled) {
375
+ // The disable took effect; only the client timed out waiting on the
376
+ // stop job. Proceed as success, saying exactly that.
377
+ const removeFileAfterTimeout = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
378
+ let removeErrorAfterTimeout: string | undefined;
379
+ try {
380
+ removeFileAfterTimeout(path);
381
+ } catch (error) {
382
+ removeErrorAfterTimeout = summarizeError(error);
383
+ }
384
+ run('systemctl', ['--user', 'daemon-reload']);
385
+ const lines = [
386
+ `reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
387
+ `installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
388
+ 'Its stop may still be completing.',
389
+ ];
390
+ if (removeErrorAfterTimeout) {
391
+ lines.push(`note: could not remove ${path}: ${removeErrorAfterTimeout} — remove it by hand.`);
392
+ }
393
+ lines.push(...deadlineNote());
394
+ return { action: 'removed', reason: 'retired', lines };
395
+ }
396
+ return {
397
+ action: 'failed',
398
+ reason: 'disable-timeout',
399
+ lines: [
400
+ `legacy-unit reconcile: the disable of ${legacyUnit} timed out and its outcome is UNKNOWN — the enablement ` +
401
+ `state could not be re-confirmed. This tool removed nothing; the unit file at ${path} was left in place ` +
402
+ 'and will be re-checked at the next daemon start.',
403
+ `Verify it yourself: systemctl --user is-enabled ${legacyUnit}`,
404
+ ...deadlineNote(),
405
+ ],
406
+ };
407
+ }
408
+ const detail = (disableResult.stderr ?? disableResult.stdout ?? '').trim() || 'no output';
409
+ return {
410
+ action: 'failed',
411
+ reason: 'disable-failed',
412
+ lines: [
413
+ `legacy-unit reconcile: the disable of the redundant installer-managed ${legacyUnit} reported failure ` +
414
+ `(${detail}) — this tool removed nothing; the unit file at ${path} was left in place.`,
415
+ `Verify its state and retire it yourself: systemctl --user is-enabled ${legacyUnit} ; ` +
416
+ `systemctl --user disable --now ${legacyUnit} && rm ${path} && systemctl --user daemon-reload`,
417
+ ],
418
+ };
419
+ }
420
+
421
+ const removeFile = input.legacyUnitFileRemove ?? ((p: string) => rmSync(p, { force: true }));
422
+ let removeError: string | undefined;
423
+ try {
424
+ removeFile(path);
425
+ } catch (error) {
426
+ removeError = summarizeError(error);
427
+ }
428
+ run('systemctl', ['--user', 'daemon-reload']);
429
+
430
+ const lines = [
431
+ `reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
432
+ `was disabled${removeError ? '' : ` and removed (${path})`}.`,
433
+ ];
434
+ if (removeError) {
435
+ lines.push(`note: could not remove ${path}: ${removeError} — remove it by hand.`);
436
+ }
437
+ lines.push(...deadlineNote());
438
+ return { action: 'removed', reason: 'retired', lines };
439
+ }
@@ -0,0 +1,79 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-governance-services.ts — the daemon-composition tail that constructs
3
+ // and starts the SDK's MemoryGovernor with the TUI fork's REAL cache adapters.
4
+ //
5
+ // Mirrors the SDK's own createRuntimeServices: the CacheRegistry, PauseController
6
+ // and the admission-gate holder are built EARLY in services.ts (so the
7
+ // scheduler gates and the knowledge background jobs can consult the pause
8
+ // controller before the governor exists); this helper is the TAIL step that
9
+ // hands them to wireDaemonMemoryGovernance with the fork's knowledge stores +
10
+ // shared session broker as the real, reclaiming cache adapters, and starts the
11
+ // governor. It is default ON — the governor is a safety feature the owner
12
+ // confirmed starts by default (wireDaemonMemoryGovernance starts it unless a
13
+ // caller opts out), so every daemon composition defends its own footprint.
14
+ //
15
+ // Kept as a one-function extraction (like wireIdlePowerAndLiveTurn) so the
16
+ // composition-parity test can pin the wiring from a single file.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import {
20
+ wireDaemonMemoryGovernance,
21
+ type CacheRegistry,
22
+ type PauseController,
23
+ type MemoryGovernor,
24
+ } from '@pellux/goodvibes-sdk/platform/runtime/memory';
25
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
26
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
27
+
28
+ export interface MemoryGovernanceServicesDeps {
29
+ readonly configManager: ConfigManager;
30
+ readonly runtimeBus: RuntimeEventBus;
31
+ /** Built early in services.ts so scheduler gates can consult it before the governor. */
32
+ readonly cacheRegistry: CacheRegistry;
33
+ readonly pauseController: PauseController;
34
+ /** The deferrable background jobs the governor pauses under pressure. */
35
+ readonly jobIds: readonly string[];
36
+ /** Where the tripwire receipt is written so a supervisor sees the exit reason. */
37
+ readonly receiptPath: string;
38
+ /** The fork's three knowledge stores: real retained-entry counts + a real job-run trim. */
39
+ readonly knowledgeStores: ReadonlyArray<{ retainedEntryCount(): number; pruneJobRuns(keep: number): number }>;
40
+ /** The shared session broker: real retained-record count + a real GC/truncate trim. */
41
+ readonly sessionBroker: { retainedRecordCount(): number; trimRetained(level: 'floor' | 'flush'): void };
42
+ /** Graceful tripwire shutdown flush (async store snapshots), run before a tripwire exit. */
43
+ readonly onTripwireShutdown?: (() => Promise<void> | void) | undefined;
44
+ }
45
+
46
+ export interface MemoryGovernanceServices {
47
+ readonly memoryGovernor: MemoryGovernor;
48
+ }
49
+
50
+ /**
51
+ * Construct + start the MemoryGovernor with the standard KNOWN cache adapters
52
+ * (knowledge-store + session-union), reading the memory.* config domain. The
53
+ * governor starts by default — it is a safety feature.
54
+ */
55
+ export function wireMemoryGovernance(deps: MemoryGovernanceServicesDeps): MemoryGovernanceServices {
56
+ const { configManager } = deps;
57
+ const { memoryGovernor } = wireDaemonMemoryGovernance({
58
+ config: {
59
+ budgetMb: configManager.get('memory.budgetMb'),
60
+ elevatedPct: configManager.get('memory.tier.elevatedPct'),
61
+ highPct: configManager.get('memory.tier.highPct'),
62
+ criticalPct: configManager.get('memory.tier.criticalPct'),
63
+ tripwireRateMbPerSec: configManager.get('memory.tripwire.rateMbPerSec'),
64
+ tripwireSustainSec: configManager.get('memory.tripwire.sustainSec'),
65
+ hardLimitPct: configManager.get('memory.hardLimitPct'),
66
+ },
67
+ runtimeBus: deps.runtimeBus,
68
+ cacheRegistry: deps.cacheRegistry,
69
+ pauseController: deps.pauseController,
70
+ jobIds: deps.jobIds,
71
+ receiptPath: deps.receiptPath,
72
+ // REAL cache adapters: genuine counts + trims that reclaim (knowledge
73
+ // job-run history pruning; session broker GC + bucket truncation).
74
+ knowledgeStores: deps.knowledgeStores,
75
+ sessionBroker: deps.sessionBroker,
76
+ onTripwireShutdown: deps.onTripwireShutdown,
77
+ });
78
+ return { memoryGovernor };
79
+ }
@@ -15,6 +15,7 @@ import { createNotificationRouter, type NotificationRouter } from '@pellux/goodv
15
15
  import type { Notification, RoutingDecision, RuntimeEventBus, RuntimeEventDomain } from '@/runtime/index.ts';
16
16
  import type { ConfigManager } from '../config/index.ts';
17
17
  import { getSharedNotificationFeed, type PanelNotificationFeed } from '../panels/notifications-feed.ts';
18
+ import { memoryPressureLine, memoryPressureLevel, type MemoryPressurePayload } from '../core/memory-status.ts';
18
19
 
19
20
  export interface NotificationDispatcher {
20
21
  /** Route a notification; a panel_only (or burst-collapsed) decision lands in the feed. Returns the decision. */
@@ -85,6 +86,33 @@ export function createNotificationDispatcher(
85
86
  };
86
87
  }
87
88
 
89
+ /**
90
+ * Bridge OPS_MEMORY_PRESSURE specifically into the notification feed as an
91
+ * attention line. The MemoryGovernor emits this on the 'ops' domain when the
92
+ * pressure tier changes or the leak tripwire fires; that domain also carries
93
+ * high-churn audit/metric events, so it is deliberately NOT in
94
+ * NOTIFICATION_BRIDGE_DOMAINS — this targeted bridge lifts only the
95
+ * memory-pressure event into notices (critical at the critical tier / on a
96
+ * tripwire, warning at high), leaving the rest of the ops churn out of the
97
+ * feed. Returns an unsubscribe function.
98
+ */
99
+ export function wireMemoryPressureNotice(
100
+ runtimeBus: RuntimeEventBus,
101
+ dispatcher: Pick<NotificationDispatcher, 'dispatch'>,
102
+ ): () => void {
103
+ return runtimeBus.onDomain('ops', (envelope) => {
104
+ if (envelope.type !== 'OPS_MEMORY_PRESSURE') return;
105
+ const payload = envelope.payload as MemoryPressurePayload;
106
+ dispatcher.dispatch({
107
+ id: envelope.traceId ?? `ops-OPS_MEMORY_PRESSURE-${envelope.ts}`,
108
+ domain: 'ops',
109
+ level: memoryPressureLevel(payload),
110
+ title: memoryPressureLine(payload),
111
+ timestamp: envelope.ts,
112
+ });
113
+ });
114
+ }
115
+
88
116
  /**
89
117
  * Bridge the runtime event bus to the dispatcher: each event in a curated
90
118
  * domain becomes a notification and is routed. Returns an unsubscribe function