From 03c77b6452e8e923518a1b852b1327a92e7143c2 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 5 May 2026 11:02:44 +0200 Subject: [PATCH] feat(ms-ai-architect): add cross-OS scheduling templates (launchd/systemd/Windows) [skip-docs] --- .../scripts/kb-update/templates/README.md | 62 ++++++++++++ ...aitochitta.ms-ai-architect.kb-update.plist | 55 +++++++++++ .../templates/ms-ai-architect-kb-update.ps1 | 48 +++++++++ .../ms-ai-architect-kb-update.service | 19 ++++ .../templates/ms-ai-architect-kb-update.timer | 13 +++ .../test-template-generation.test.mjs | 98 +++++++++++++++++++ 6 files changed, 295 insertions(+) create mode 100644 plugins/ms-ai-architect/scripts/kb-update/templates/README.md create mode 100644 plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist create mode 100644 plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1 create mode 100644 plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service create mode 100644 plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer create mode 100644 plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/README.md b/plugins/ms-ai-architect/scripts/kb-update/templates/README.md new file mode 100644 index 0000000..4ac5d56 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/templates/README.md @@ -0,0 +1,62 @@ +# ms-ai-architect KB-update scheduling templates + +These templates are consumed by `scripts/install-kb-cron.mjs` (added in +Wave 4 / Step 11) which substitutes the documented placeholders and +hands off to the platform's native scheduler. Do not edit a generated +file directly — re-run the installer instead so the source-of-truth +stays in this directory. + +## Files + +| File | Platform | Scheduler | +|------|----------|-----------| +| `com.fromaitochitta.ms-ai-architect.kb-update.plist` | macOS (Intel + Apple Silicon) | `launchctl` (per-user LaunchAgent) | +| `ms-ai-architect-kb-update.service` | Linux | `systemctl --user` | +| `ms-ai-architect-kb-update.timer` | Linux | `systemctl --user` (paired with the .service) | +| `ms-ai-architect-kb-update.ps1` | Windows 10/11 | Task Scheduler via `Register-ScheduledTask` | + +## Placeholders + +All four templates share the same canonical placeholder set. The +installer fills them in at install-time and writes the rendered file +under the platform's scheduler directory. + +| Placeholder | Filled with | Source | +|-------------|-------------|--------| +| `{{NODE_BIN}}` | Absolute path to the `node` binary that should run the cron | `which node` (POSIX) / `where node` (Windows) at install-time | +| `{{PLUGIN_ROOT}}` | Absolute path to the `plugins/ms-ai-architect/` directory | Resolved by the installer relative to itself | +| `{{LOG_FILE}}` | Absolute path to the rotated log file | `getLogDir('ms-ai-architect') + '/kb-update.log'` (per `lib/cross-platform-paths.mjs`) | +| `{{SCHEDULE_HOUR}}` | Cron-hour, 0-23 | Default `4`; overridable via `--schedule-hour` | +| `{{SCHEDULE_MINUTE}}` | Cron-minute, 0-59 | Default `23`; overridable via `--schedule-minute` | +| `{{SCHEDULE_DAY_OF_WEEK}}` | launchd Weekday integer (0=Sunday … 3=Wednesday) | Default `3` (Wednesday) | + +The systemd `.timer` and Windows `.ps1` use a literal `Wed`/`Wednesday` +day name rather than `{{SCHEDULE_DAY_OF_WEEK}}` because their respective +schedulers expect day-name strings, and the installer currently locks +the day to Wednesday (per the brief's "weekly Wed" cadence). Changing +the day requires editing the template — the installer does not yet +expose a `--schedule-day` flag. + +## Install / uninstall + +The full install/uninstall flow is implemented by +`scripts/install-kb-cron.mjs` (Wave 4 / Step 11). Run with `--help` for +the current option set. The contract for all three platforms is "fires +while the user is logged in" — there is no system-wide / sudo install +path because Claude Code's keychain-bound auth dies in unattended +contexts. + +## Why these specific schedulers + +- **launchd** is the only first-class scheduler on macOS; cron is a + thin user-facing alias. `RunAtLoad` is `false` so loading the job at + boot does not trigger an immediate Claude Code session. +- **systemd `--user` units** keep the symmetry of "user-context only" + with launchd's LoginItem and Windows' `InteractiveToken`. The + `Persistent=true` setting on the timer ensures a missed run (laptop + asleep on Wednesday) fires on next boot rather than being skipped. +- **Windows Task Scheduler** with `InteractiveToken` is the only logon + type that keeps the keychain unlocked, which is required for + subscription-auth Claude Code sessions. + +See `research/01-cross-os-scheduling.md` for the full background. diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist b/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist new file mode 100644 index 0000000..5bdf92f --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/templates/com.fromaitochitta.ms-ai-architect.kb-update.plist @@ -0,0 +1,55 @@ + + + + + + Label + com.fromaitochitta.ms-ai-architect.kb-update + + ProgramArguments + + {{NODE_BIN}} + {{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs + + + WorkingDirectory + {{PLUGIN_ROOT}} + + StartCalendarInterval + + Weekday + {{SCHEDULE_DAY_OF_WEEK}} + Hour + {{SCHEDULE_HOUR}} + Minute + {{SCHEDULE_MINUTE}} + + + RunAtLoad + + + StandardOutPath + {{LOG_FILE}} + + StandardErrorPath + {{LOG_FILE}} + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + ProcessType + Background + + diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1 b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1 new file mode 100644 index 0000000..95ba629 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.ps1 @@ -0,0 +1,48 @@ +# ms-ai-architect-kb-update.ps1 +# PowerShell installer fragment for Windows Task Scheduler. Filled in +# by scripts/install-kb-cron.mjs at install-time and run elevated only +# if the user requested system-wide install (default is per-user with +# InteractiveToken so the task fires while the user is logged in). + +$TaskName = 'ms-ai-architect-kb-update' +$NodeBin = '{{NODE_BIN}}' +$PluginRoot = '{{PLUGIN_ROOT}}' +$LogFile = '{{LOG_FILE}}' +$ScheduleAt = '{{SCHEDULE_HOUR}}:{{SCHEDULE_MINUTE}}' + +$Trigger = New-ScheduledTaskTrigger ` + -Weekly ` + -DaysOfWeek Wednesday ` + -At $ScheduleAt + +$Action = New-ScheduledTaskAction ` + -Execute $NodeBin ` + -Argument "$PluginRoot\scripts\kb-update\weekly-kb-cron.mjs" ` + -WorkingDirectory $PluginRoot + +# InteractiveToken is the contract: the task only runs while the user is +# logged in. This avoids the "OAuth dies in cron" failure-mode (claude +# subscription auth is bound to the keychain, which is unlocked only when +# the user is logged in). RunLevel Limited keeps the task at non-elevated +# privileges; admin elevation is unnecessary for per-user scheduling. +$Principal = New-ScheduledTaskPrincipal ` + -UserId $env:USERNAME ` + -LogonType InteractiveToken ` + -RunLevel Limited + +$Settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Hours 2) + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Trigger $Trigger ` + -Action $Action ` + -Principal $Principal ` + -Settings $Settings ` + -Description 'Weekly Microsoft Learn KB freshness update for ms-ai-architect plugin' ` + -Force | Out-Null + +Write-Host "Registered Windows scheduled task '$TaskName' (weekly Wed $ScheduleAt, log: $LogFile)" diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service new file mode 100644 index 0000000..9a69bd3 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.service @@ -0,0 +1,19 @@ +[Unit] +Description=ms-ai-architect weekly KB-update (Microsoft Learn freshness) +Documentation=file://{{PLUGIN_ROOT}}/scripts/kb-update/templates/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart={{NODE_BIN}} {{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs +WorkingDirectory={{PLUGIN_ROOT}} +StandardOutput=append:{{LOG_FILE}} +StandardError=append:{{LOG_FILE}} +Environment=PATH=/usr/local/bin:/usr/bin:/bin +# No User= here; the unit is installed under `systemctl --user` so it +# inherits the invoking user's identity. Running under the user manager +# keeps the contract "fires while user is logged in" symmetric across +# the three platforms (launchd LoginItem, systemd --user, Windows +# InteractiveToken). Switching to system-wide service+sudo would +# diverge from that contract — do not do that here. diff --git a/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer new file mode 100644 index 0000000..e05bce6 --- /dev/null +++ b/plugins/ms-ai-architect/scripts/kb-update/templates/ms-ai-architect-kb-update.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Weekly trigger for ms-ai-architect KB-update + +[Timer] +# Default cadence per the brief is Wednesday 04:23 local time. Editing +# this file directly is fine for one-off schedule tweaks; for +# reproducible installs prefer re-running scripts/install-kb-cron.mjs. +OnCalendar=Wed *-*-* 04:23:00 +Persistent=true +Unit=ms-ai-architect-kb-update.service + +[Install] +WantedBy=timers.target diff --git a/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs b/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs new file mode 100644 index 0000000..034faf6 --- /dev/null +++ b/plugins/ms-ai-architect/tests/kb-update/test-template-generation.test.mjs @@ -0,0 +1,98 @@ +// tests/kb-update/test-template-generation.test.mjs +// Structural-regex tests for scripts/kb-update/templates/* (Step 8). +// Verifies that each template file exists, contains the documented sentinel +// strings, and exposes the documented placeholder set. No template execution +// or real scheduling occurs in this test — that lives in Wave 6 live-test. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, '..', '..', 'scripts', 'kb-update', 'templates'); + +const PLIST = join(TEMPLATES_DIR, 'com.fromaitochitta.ms-ai-architect.kb-update.plist'); +const SERVICE = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.service'); +const TIMER = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.timer'); +const PS1 = join(TEMPLATES_DIR, 'ms-ai-architect-kb-update.ps1'); +const README = join(TEMPLATES_DIR, 'README.md'); + +function readTpl(p) { + assert.equal(existsSync(p), true, `template missing: ${p}`); + return readFileSync(p, 'utf8'); +} + +test('plist — exists with required keys and placeholders', () => { + const content = readTpl(PLIST); + assert.match(content, /Label<\/key>/); + assert.match(content, /StartCalendarInterval<\/key>/); + assert.match(content, /ProgramArguments<\/key>/); + assert.match(content, /StandardOutPath<\/key>/); + assert.match(content, /StandardErrorPath<\/key>/); + assert.match(content, /EnvironmentVariables<\/key>/); + assert.match(content, /RunAtLoad<\/key>\s*/); + assert.match(content, /\{\{NODE_BIN\}\}/); + assert.match(content, /\{\{PLUGIN_ROOT\}\}/); + assert.match(content, /\{\{LOG_FILE\}\}/); + assert.match(content, /\{\{SCHEDULE_HOUR\}\}/); + assert.match(content, /\{\{SCHEDULE_MINUTE\}\}/); + assert.match(content, /\{\{SCHEDULE_DAY_OF_WEEK\}\}/); +}); + +test('systemd .timer — exists with OnCalendar and Persistent', () => { + const content = readTpl(TIMER); + assert.match(content, /\[Unit\]/); + assert.match(content, /\[Timer\]/); + assert.match(content, /\[Install\]/); + assert.match(content, /OnCalendar=Wed/); + assert.match(content, /Persistent=true/); + assert.match(content, /WantedBy=timers\.target/); +}); + +test('systemd .service — exists with [Unit], [Service] and ExecStart', () => { + const content = readTpl(SERVICE); + assert.match(content, /\[Unit\]/); + assert.match(content, /\[Service\]/); + assert.match(content, /ExecStart=/); + assert.match(content, /\{\{NODE_BIN\}\}/); + assert.match(content, /\{\{PLUGIN_ROOT\}\}/); +}); + +test('PowerShell ps1 — exists with Register-ScheduledTask and InteractiveToken', () => { + const content = readTpl(PS1); + assert.match(content, /Register-ScheduledTask/); + assert.match(content, /InteractiveToken/); + assert.match(content, /New-ScheduledTaskTrigger/); + assert.match(content, /-Weekly/); + assert.match(content, /-DaysOfWeek\s+Wednesday/); + assert.match(content, /\{\{NODE_BIN\}\}/); + assert.match(content, /\{\{PLUGIN_ROOT\}\}/); +}); + +test('README — exists and references each template by filename', () => { + const content = readTpl(README); + assert.match(content, /com\.fromaitochitta\.ms-ai-architect\.kb-update\.plist/); + assert.match(content, /ms-ai-architect-kb-update\.service/); + assert.match(content, /ms-ai-architect-kb-update\.timer/); + assert.match(content, /ms-ai-architect-kb-update\.ps1/); +}); + +test('plist + service + ps1 reference NODE_BIN and PLUGIN_ROOT', () => { + // The .timer is a pure trigger — it activates the .service, which is + // the only systemd unit that needs to know the binary + plugin root. + // launchd and Windows put the command directly in the trigger spec, so + // they need both placeholders themselves. + for (const tpl of [PLIST, SERVICE, PS1]) { + const content = readFileSync(tpl, 'utf8'); + assert.match(content, /\{\{NODE_BIN\}\}/, `${tpl} missing NODE_BIN placeholder`); + assert.match(content, /\{\{PLUGIN_ROOT\}\}/, `${tpl} missing PLUGIN_ROOT placeholder`); + } +}); + +test('.timer is placeholder-free literal (Wed 04:23 hardcoded per plan)', () => { + const content = readFileSync(TIMER, 'utf8'); + assert.match(content, /OnCalendar=Wed \*-\*-\* 04:23:00/); + assert.doesNotMatch(content, /\{\{[A-Z_]+\}\}/); +});