ms-ai-architect/scripts/kb-update/scheduler.mjs
Kjell Tore Guttormsen 071601bb9e feat(ms-ai-architect): Sesjon 24 — C1 Tier 2 lokal launchd-scheduler (deteksjon-only, ToS-trygt) [skip-docs]
Tier 1 (SessionStart-hook) kjører deteksjon kun når en Claude-sesjon starter.
Tier 2 installerer en lokal launchd LaunchAgent som fyrer SAMME Claude-frie
entrypoint daglig (kl. 03:00), uavhengig av sesjoner = ekte bakgrunn mellom
sesjoner. ToS-trygt: entrypointet kan strukturelt ikke invokere claude.

Søk-først-verifisert (2026-06): launchd > cron (Apple-anbefalt; cron krever
Full Disk Access); StartCalendarInterval fanger opp etter dvale; launchd har
minimalt PATH + ekspanderer ikke ~; process.execPath = Cellar-sti som dør ved
`brew upgrade node` → plistens PATH inkluderer /usr/local/bin.

Levert (TDD, test FØR kode):
- lib/launchd-plist.mjs (REN, SKRIVER ALDRI): renderPlist (XML-escape,
  StartCalendarInterval, PATH-fix), defaultLabel, resolvePaths.
- scheduler.mjs (TYNN CLI): install|uninstall|status|run-now|print|run.
  Strukturelt Claude-fri (kun process.execPath på run-detection.mjs +
  launchctl; kilde-grep-testet). run gater på os_scheduler_cadence →
  delegerer til run-detection.mjs. uid-0-nekt; idempotent bootout.
- detection-schedule.mjs (additiv, Tier-1 urørt): os_scheduler_cadence
  ('daily' default | 'interval') + daysSinceLastPoll ren helper.
- commands/kb-update.md: Tier-1/Tier-2-doc + caveats (uninstall=pause;
  re-install etter node-upgrade).

Operatør-valg: daglig default, kadens egen innstilling (settes i onboarding/C2).

Verifisert: launchd-plist 13/13 · detection-schedule 21/21 · kb-update 173 ·
kb-eval 100 · validate 239/0 · test-hooks 6/6 · plutil -lint OK · live
install→status→idempotent→uninstall på maskinen (gui/501, rent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:59:18 +02:00

188 lines
7 KiB
JavaScript

#!/usr/bin/env node
// scheduler.mjs — Spor C / C1 Tier 2: install/manage the local launchd
// LaunchAgent that runs the Claude-FREE detection pipeline on a daily schedule,
// independent of Claude sessions = real background BETWEEN sessions.
//
// STRUCTURAL ToS GUARANTEE: this script only ever spawns the running node
// binary (process.execPath) on the allow-listed run-detection.mjs, and the
// `launchctl` CLI for agent management. It can NEVER invoke `claude`. The
// scheduled entrypoint (`run`) gates on cadence, then delegates to
// run-detection.mjs, which is itself structurally Claude-free. Apply (the only
// Claude step) stays manual + in-session + gated. See detection-schedule.mjs
// for the ToS rationale (Consumer Terms §3.7 targets Anthropic's services, not
// local scripts).
//
// node scripts/kb-update/scheduler.mjs install # write plist + load agent
// node scripts/kb-update/scheduler.mjs uninstall # unload + remove plist
// node scripts/kb-update/scheduler.mjs status # is it loaded?
// node scripts/kb-update/scheduler.mjs run-now # trigger one run now
// node scripts/kb-update/scheduler.mjs print # show the plist (alias --dry-run)
// node scripts/kb-update/scheduler.mjs run # (the launchd entrypoint)
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { renderPlist, resolvePaths, defaultLabel } from './lib/launchd-plist.mjs';
import {
loadScheduleConfig,
daysSinceLastPoll,
} from './lib/detection-schedule.mjs';
import { atomicWriteSync } from './lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const RUN_DETECTION = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs');
const CHANGE_REPORT = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'data', 'change-report.json');
// Daily fire time. Not configurable yet (onboarding/C2 may expose it).
const FIRE_HOUR = 3;
const FIRE_MINUTE = 0;
/** Build renderPlist inputs from the resolved paths + the running node. */
function planAgent() {
const nodePath = process.execPath;
const paths = resolvePaths({ pluginRoot: PLUGIN_ROOT, homeDir: homedir(), nodePath });
const plist = renderPlist({
label: paths.label,
nodePath,
scriptPath: paths.scriptPath,
args: ['run'],
hour: FIRE_HOUR,
minute: FIRE_MINUTE,
logPath: paths.logPath,
workingDir: paths.workingDir,
});
return { ...paths, nodePath, plist };
}
/** gui/<uid> domain + gui/<uid>/<label> service target. */
function targets() {
const uid = process.getuid();
return { domain: `gui/${uid}`, service: `gui/${uid}/${defaultLabel()}` };
}
/**
* Run launchctl. ignoreError swallows a non-zero exit (the idempotency
* primitive — a pre-install bootout of a not-yet-loaded service is expected to
* fail); silent hides its output so that expected failure isn't alarming.
*/
function launchctl(args, { ignoreError = false, silent = false } = {}) {
try {
execFileSync('launchctl', args, { stdio: silent ? 'ignore' : 'inherit' });
return true;
} catch (err) {
if (ignoreError) return false;
throw err;
}
}
// --- Subcommands -----------------------------------------------------------
function printPlist() {
// Pure preview: render and print. Writes nothing, runs no launchctl.
process.stdout.write(planAgent().plist);
}
function install() {
if (typeof process.getuid === 'function' && process.getuid() === 0) {
console.error('Refusing to install as root — run as your logged-in user (no sudo).');
console.error('A LaunchAgent in the gui/<uid> domain must target your own session.');
process.exit(1);
}
const { agentPlistPath, plist, scriptPath, logPath } = planAgent();
const { domain, service } = targets();
mkdirSync(dirname(agentPlistPath), { recursive: true });
atomicWriteSync(agentPlistPath, plist);
// Idempotent: unload any previous instance (expected to fail+silent on first install).
launchctl(['bootout', service], { ignoreError: true, silent: true });
launchctl(['bootstrap', domain, agentPlistPath]);
console.log(`Installed LaunchAgent: ${agentPlistPath}`);
console.log(` fires daily at ${String(FIRE_HOUR).padStart(2, '0')}:${String(FIRE_MINUTE).padStart(2, '0')}${scriptPath} run`);
console.log(` log: ${logPath}`);
console.log('');
console.log('Verify now: node scripts/kb-update/scheduler.mjs run-now');
console.log('Pause it: node scripts/kb-update/scheduler.mjs uninstall (the enabled: flag is Tier-1/hook only)');
console.log('After a major `brew upgrade node`, re-run install (the node path is pinned at install time).');
}
function uninstall() {
const { agentPlistPath } = planAgent();
const { service } = targets();
launchctl(['bootout', service], { ignoreError: true, silent: true });
if (existsSync(agentPlistPath)) {
rmSync(agentPlistPath);
console.log(`Removed LaunchAgent: ${agentPlistPath}`);
} else {
console.log('No LaunchAgent plist found — nothing to remove.');
}
}
function status() {
const { agentPlistPath } = planAgent();
const { service } = targets();
console.log(`plist: ${agentPlistPath} ${existsSync(agentPlistPath) ? '(present)' : '(absent)'}`);
// Non-fatal: prints the loaded job, or an error line if not loaded.
launchctl(['print', service], { ignoreError: true });
}
function runNow() {
const { service } = targets();
launchctl(['kickstart', '-k', service]);
console.log(`Kicked off ${service} — see the log for output.`);
}
/** The launchd entrypoint. Gate on cadence, then delegate to run-detection.mjs. */
function run() {
const config = loadScheduleConfig(PLUGIN_ROOT);
if (config.os_scheduler_cadence === 'interval') {
let report = null;
if (existsSync(CHANGE_REPORT)) {
try {
report = JSON.parse(readFileSync(CHANGE_REPORT, 'utf8'));
} catch {
report = null; // unreadable => treat as infinitely stale => run
}
}
const days = daysSinceLastPoll(report, Date.now());
if (days < config.interval_days) {
console.log(`fresh (${Math.floor(days)}d < ${config.interval_days}d) — skipping poll`);
return;
}
}
// daily cadence, or interval+stale: delegate to the Claude-free entrypoint
// using the running node binary (no PATH dependency for this hop).
execFileSync(process.execPath, [RUN_DETECTION], { stdio: 'inherit' });
}
// --- Dispatch --------------------------------------------------------------
const cmd = process.argv[2];
switch (cmd) {
case 'print':
case '--dry-run':
printPlist();
break;
case 'install':
install();
break;
case 'uninstall':
uninstall();
break;
case 'status':
status();
break;
case 'run-now':
runNow();
break;
case 'run':
run();
break;
default:
console.error('Usage: scheduler.mjs <install|uninstall|status|run-now|print|run>');
process.exit(2);
}