feat(ms-ai-architect): add cross-OS scheduling templates (launchd/systemd/Windows) [skip-docs]
This commit is contained in:
parent
aefe9ef5b4
commit
03c77b6452
6 changed files with 295 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
launchd job for weekly ms-ai-architect KB-update.
|
||||||
|
Placeholders ({{NODE_BIN}}, {{PLUGIN_ROOT}}, {{LOG_FILE}}, {{SCHEDULE_HOUR}},
|
||||||
|
{{SCHEDULE_MINUTE}}, {{SCHEDULE_DAY_OF_WEEK}}) are filled in by
|
||||||
|
scripts/install-kb-cron.mjs at install-time.
|
||||||
|
|
||||||
|
RunAtLoad is intentionally false so loading the job at boot does not
|
||||||
|
immediately spawn a Claude Code session. Weekday=3 is Wednesday in
|
||||||
|
launchd's StartCalendarInterval semantics.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.fromaitochitta.ms-ai-architect.kb-update</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{{NODE_BIN}}</string>
|
||||||
|
<string>{{PLUGIN_ROOT}}/scripts/kb-update/weekly-kb-cron.mjs</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>{{PLUGIN_ROOT}}</string>
|
||||||
|
|
||||||
|
<key>StartCalendarInterval</key>
|
||||||
|
<dict>
|
||||||
|
<key>Weekday</key>
|
||||||
|
<integer>{{SCHEDULE_DAY_OF_WEEK}}</integer>
|
||||||
|
<key>Hour</key>
|
||||||
|
<integer>{{SCHEDULE_HOUR}}</integer>
|
||||||
|
<key>Minute</key>
|
||||||
|
<integer>{{SCHEDULE_MINUTE}}</integer>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{{LOG_FILE}}</string>
|
||||||
|
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{{LOG_FILE}}</string>
|
||||||
|
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Background</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -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)"
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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, /<key>Label<\/key>/);
|
||||||
|
assert.match(content, /<key>StartCalendarInterval<\/key>/);
|
||||||
|
assert.match(content, /<key>ProgramArguments<\/key>/);
|
||||||
|
assert.match(content, /<key>StandardOutPath<\/key>/);
|
||||||
|
assert.match(content, /<key>StandardErrorPath<\/key>/);
|
||||||
|
assert.match(content, /<key>EnvironmentVariables<\/key>/);
|
||||||
|
assert.match(content, /<key>RunAtLoad<\/key>\s*<false\/>/);
|
||||||
|
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_]+\}\}/);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue