diff --git a/docs/TASKS.md b/docs/TASKS.md index b8c4fb0..e9dda35 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -54,3 +54,7 @@ Active workstream is **W1 — Federation v1**. Workers should: ## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha - Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md. + +## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed + +- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md. diff --git a/docs/scratchpads/f3-m3-update-reseed.md b/docs/scratchpads/f3-m3-update-reseed.md new file mode 100644 index 0000000..afa9dfa --- /dev/null +++ b/docs/scratchpads/f3-m3-update-reseed.md @@ -0,0 +1,29 @@ +# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13) + +- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed` + +## Gap (found in 0.0.39 production validation) + +`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's +bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays +DORMANT until a re-seed — operators get the new CLI on a stale framework. + +## Implementation + +- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in + `MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`, + `readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent). +- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the + framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or + print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`. + +## Flow + +`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the +native harness for every operator. + +## Verification + +- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard). +- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean. +- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation). diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index ab955d9..187bbbd 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -26,6 +26,10 @@ import { checkForAllUpdates, formatAllPackagesTable, getInstallAllCommand, + runFrameworkReseed, + readRosterAgentNames, + buildRelaunchCommands, + FRAMEWORK_RESEED_PACKAGE, } from './runtime/update-checker.js'; import { runWizard } from './wizard.js'; import { ClackPrompter } from './prompter/clack-prompter.js'; @@ -404,7 +408,12 @@ program .command('update') .description('Check for and install Mosaic CLI updates') .option('--check', 'Check only, do not install') - .action(async (opts: { check?: boolean }) => { + .option( + '--no-reseed', + 'Skip re-seeding framework files into ~/.config/mosaic after the CLI update', + ) + .option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect') + .action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => { // checkForAllUpdates imported statically above const { execSync } = await import('node:child_process'); @@ -442,6 +451,51 @@ program console.error('\nUpdate failed. Try manually: bash tools/install.sh'); process.exit(1); } + + // F3-m3 / R13: the CLI is updated, but the framework files in + // ~/.config/mosaic/ are still the previous version. Re-seed them from the + // freshly-installed package so shipped launcher/runtime changes ACTIVATE. + // Only when the framework-bearing package itself updated. + const mosaicUpdated = outdated.some( + (r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE, + ); + if (mosaicUpdated && opts.reseed !== false) { + console.log( + '\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…', + ); + const reseed = runFrameworkReseed(); + if (reseed.ok) { + console.log('✔ Framework re-seeded.'); + const agents = readRosterAgentNames(); + if (agents.length > 0) { + if (opts.relaunch) { + console.log( + `\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`, + ); + for (const restart of buildRelaunchCommands(agents)) { + try { + execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 }); + } catch { + console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`); + } + } + console.log('✔ Agents relaunched.'); + } else { + console.log( + `\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` + + 'Restart them to activate the update:\n mosaic update --relaunch ' + + '(or: mosaic fleet restart )', + ); + } + } + } else { + console.error( + `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + + ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + + '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', + ); + } + } }); // ─── wizard ───────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/runtime/update-checker.reseed.spec.ts b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts new file mode 100644 index 0000000..932cdd4 --- /dev/null +++ b/packages/mosaic/src/runtime/update-checker.reseed.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildReseedCommand, + buildRelaunchCommands, + readRosterAgentNames, + runFrameworkReseed, +} from './update-checker.js'; + +/** + * F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches + * durable agents so shipped launcher/runtime changes activate. These cover the + * pure builders + the missing-installer guard (the exec path is integration). + */ + +describe('buildReseedCommand', () => { + it('invokes the package install.sh in data-safe sync-only keep mode', () => { + const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic'); + expect(out.installer).toBe('/pkg/framework/install.sh'); + expect(out.command).toBe('bash /pkg/framework/install.sh'); + expect(out.env).toEqual({ + MOSAIC_SYNC_ONLY: '1', + MOSAIC_INSTALL_MODE: 'keep', + MOSAIC_HOME: '/home/u/.config/mosaic', + }); + }); +}); + +describe('buildRelaunchCommands', () => { + it('builds a systemctl --user restart per agent unit', () => { + expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([ + ['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'], + ['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'], + ]); + }); + + it('is empty for an empty roster', () => { + expect(buildRelaunchCommands([])).toEqual([]); + }); +}); + +describe('readRosterAgentNames', () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mosaic-roster-')); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('returns [] when no roster exists', () => { + expect(readRosterAgentNames(home)).toEqual([]); + }); + + it('extracts agent names from roster.yaml', () => { + mkdirSync(join(home, 'fleet'), { recursive: true }); + writeFileSync( + join(home, 'fleet', 'roster.yaml'), + [ + 'version: 1', + 'agents:', + ' - name: orchestrator', + ' runtime: pi', + ' - name: coder0', + ' runtime: claude', + ' - name: "reviewer-1"', + ' runtime: codex', + ].join('\n') + '\n', + ); + expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']); + }); +}); + +describe('runFrameworkReseed', () => { + it('reports not-ok (not throw) when the installer is absent', () => { + const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-')); + const res = runFrameworkReseed(missing, join(missing, 'home')); + expect(res.ok).toBe(false); + expect(res.reason).toContain('installer not found'); + rmSync(missing, { recursive: true, force: true }); + }); +}); diff --git a/packages/mosaic/src/runtime/update-checker.ts b/packages/mosaic/src/runtime/update-checker.ts index 855a067..d56836e 100644 --- a/packages/mosaic/src/runtime/update-checker.ts +++ b/packages/mosaic/src/runtime/update-checker.ts @@ -16,7 +16,8 @@ import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -453,6 +454,98 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string { return `npm i -g ${pkgs.join(' ')}`; } +// ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ───────────── +// +// `mosaic update` installs the new npm CLI but, on its own, leaves the framework +// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g. +// the agent-name export + native heartbeat) never ACTIVATE until a re-seed. +// These helpers run the package's own install.sh in sync-only mode (the P4 +// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/ +// *.local/credentials preserved) and, opt-in, relaunch durable agents. + +/** Resolve the framework/ directory bundled in the installed package. */ +export function resolveBundledFrameworkRoot(): string { + // dist/runtime/update-checker.js → ../../framework (package files: dist + framework) + return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework'); +} + +export const FRAMEWORK_RESEED_PACKAGE = PKG; + +/** + * Build the framework re-seed invocation: the package's install.sh in + * sync-only mode (file phase only — no environment-touching post-install), + * keep mode (never overwrite user files). Returned as data so it is unit + * testable; `runFrameworkReseed` executes it. + */ +export function buildReseedCommand( + frameworkRoot: string, + mosaicHome: string, +): { installer: string; command: string; env: Record } { + const installer = join(frameworkRoot, 'install.sh'); + return { + installer, + command: `bash ${installer}`, + env: { + MOSAIC_SYNC_ONLY: '1', + MOSAIC_INSTALL_MODE: 'keep', + MOSAIC_HOME: mosaicHome, + }, + }; +} + +/** + * Re-seed the framework from the freshly-installed package. Returns a result + * describing what happened (so callers can message + decide on relaunch). + * Best-effort: a missing installer or a non-zero exit is reported, not thrown. + */ +export function runFrameworkReseed( + frameworkRoot = resolveBundledFrameworkRoot(), + mosaicHome = join(homedir(), '.config', 'mosaic'), +): { ok: boolean; reason?: string } { + const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome); + if (!existsSync(installer)) { + return { ok: false, reason: `installer not found: ${installer}` }; + } + try { + execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 }); + return { ok: true }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Best-effort parse of the fleet roster for agent names (used to relaunch + * durable agents after a re-seed). Returns [] when no roster exists. + */ +export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] { + const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); + if (!existsSync(rosterPath)) return []; + let text: string; + try { + text = readFileSync(rosterPath, 'utf-8'); + } catch { + return []; + } + // Roster agents are listed as `- name: ` entries under `agents:`. + const names: string[] = []; + for (const line of text.split('\n')) { + const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/); + if (m && m[1]) names.push(m[1]); + } + return names; +} + +/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */ +export function buildRelaunchCommands(agentNames: string[]): string[][] { + return agentNames.map((name) => [ + 'systemctl', + '--user', + 'restart', + `mosaic-agent@${name}.service`, + ]); +} + /** * Format a table showing all packages with their current/latest versions. */