From 0cf7248a7b250f13b6a450a16857fea2a5557621 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 4 Oct 2026 18:37:33 +0700 Subject: [PATCH] crons: support HOST launchd (the owner's Mac) in the generator and the page agents-from-specs accepts HOST launchd with exactly one of INTERVAL_MS (StartInterval) or SCHEDULE (StartCalendarInterval / crontab), does not warn a launchd card for being absent from the repo-scanning code manifest, and gives it a disabled run-now target. CronExplorer and AgentSpecPanel label it. Refs gHashTag/t27#6014 Co-Authored-By: Claude Opus 5.5 --- apps/website/scripts/agents-from-specs.mjs | 17 ++++++++++++----- apps/website/scripts/agents-from-specs.test.mjs | 11 +++++++++++ apps/website/src/components/AgentSpecPanel.tsx | 4 +++- apps/website/src/lib/agentSpecs.ts | 4 ++-- apps/website/src/lib/cronsLoader.ts | 2 +- apps/website/src/pages/CronExplorer.tsx | 3 +++ 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/website/scripts/agents-from-specs.mjs b/apps/website/scripts/agents-from-specs.mjs index 4a9448207c..1561b40ee3 100644 --- a/apps/website/scripts/agents-from-specs.mjs +++ b/apps/website/scripts/agents-from-specs.mjs @@ -534,7 +534,7 @@ export function localized(en, id, field, locales) { } return out } -export const HOSTS = ['github-actions', 'inngest', 'railway-cron', 'timer'] +export const HOSTS = ['github-actions', 'inngest', 'railway-cron', 'timer', 'launchd'] export const CONTROLS = ['github-actions-dispatch', 'railway-dashboard', 'inngest-dashboard', 'code-only'] export const ON_FAILURE = ['issue', 'log', 'unknown'] @@ -560,6 +560,8 @@ export function runNowTarget(fields, railway) { } case 'inngest': return { kind: 'link', via: 'inngest', url: INNGEST_DASHBOARD_URL } + case 'launchd': + return { kind: 'disabled', reason: 'launchd' } default: return { kind: 'disabled', reason: 'timer' } } @@ -703,19 +705,24 @@ export function buildSpecCatalogs({ skillSpecs, cronSpecs, agentSpecs = [], func const isTimer = f.HOST === 'timer' if (isTimer && !('INTERVAL_MS' in f)) problems.push(`${file}: a timer needs INTERVAL_MS`) if (isTimer && 'SCHEDULE' in f) problems.push(`${file}: a timer has INTERVAL_MS, not SCHEDULE`) - if (!isTimer && !('SCHEDULE' in f)) problems.push(`${file}: a scheduled job needs SCHEDULE`) - if (!isTimer && 'INTERVAL_MS' in f) problems.push(`${file}: only a timer has INTERVAL_MS`) + // launchd (the owner's Mac): StartInterval -> INTERVAL_MS, StartCalendarInterval/crontab -> SCHEDULE; exactly one. + const isLaunchd = f.HOST === 'launchd' + if (isLaunchd && ('SCHEDULE' in f) === ('INTERVAL_MS' in f)) problems.push(`${file}: a launchd job has exactly one of SCHEDULE and INTERVAL_MS`) + if (!isTimer && !isLaunchd && !('SCHEDULE' in f)) problems.push(`${file}: a scheduled job needs SCHEDULE`) + if (!isTimer && !isLaunchd && 'INTERVAL_MS' in f) problems.push(`${file}: only a timer has INTERVAL_MS (or a launchd job)`) if (f.SCHEDULE === '' && !f.SCHEDULE_NOTE) problems.push(`${file}: an empty SCHEDULE needs SCHEDULE_NOTE`) const code = codeCrons.get(f.ID) const runs = Array.isArray(f.RUNS) ? f.RUNS : [] const runsResolved = runs.map((id) => ({ id, ok: seenSkill.has(id) })) const unresolved = runsResolved.filter((r) => !r.ok) + // The code manifest scans repositories, not the owner's Mac: a launchd card is spec-only by construction. + const macOnly = f.HOST === 'launchd' const messages = [ - ...(code ? [] : [`no job ${f.ID} in public/crons/manifest.json`]), + ...(code || macOnly ? [] : [`no job ${f.ID} in public/crons/manifest.json`]), ...unresolved.map((r) => `RUNS names ${r.id}, which has no skill spec`), ] let health = 'ok' - if (!code) health = 'warn' + if (!code && !macOnly) health = 'warn' if (unresolved.length) health = 'fail' // The code catalog knows the schedule it read from the file; when the spec // disagrees, that is a fact the reader must see, not a build failure. diff --git a/apps/website/scripts/agents-from-specs.test.mjs b/apps/website/scripts/agents-from-specs.test.mjs index 5429aa57f3..c4b9ebd61f 100644 --- a/apps/website/scripts/agents-from-specs.test.mjs +++ b/apps/website/scripts/agents-from-specs.test.mjs @@ -140,6 +140,17 @@ test('a timer carries INTERVAL_MS, a schedule carries SCHEDULE, never both', () assert.ok(r.problems.some((p) => p.includes('x1.t27: only a timer has INTERVAL_MS')), r.problems.join('\n')) }) +test('a launchd job (the owner\'s Mac) carries exactly one of INTERVAL_MS and SCHEDULE, and is not warned for being absent from the code manifest', () => { + const ok = build([], files('specs/crons', + cronSrc('launchd/mac/a', { module: 'cron_x0', host: 'launchd' }), + cronSrc('launchd/mac/b', { module: 'cron_x1', host: 'launchd' }).replace(/pub const SCHEDULE[^\n]*\n/, 'pub const INTERVAL_MS : u32 = 600000;\n'), + )) + assert.deepEqual(ok.problems, []) + assert.ok(ok.crons.crons.every((c) => c.health === 'ok'), JSON.stringify(ok.crons.crons.map((c) => c.messages))) + const both = build([], files('specs/crons', cronSrc('launchd/mac/a', { module: 'cron_x0', host: 'launchd', extra: 'pub const INTERVAL_MS : u32 = 1;' }))) + assert.ok(both.problems.some((p) => p.includes('exactly one of SCHEDULE and INTERVAL_MS')), both.problems.join('\n')) +}) + test('a spec that disagrees with the schedule the code catalog read is shown, as warn', () => { const r = build([], files('specs/crons', cronSrc('github-actions/trinity/x', { module: 'cron_x0', schedule: '"5 5 * * *"' }))) assert.deepEqual(r.problems, []) diff --git a/apps/website/src/components/AgentSpecPanel.tsx b/apps/website/src/components/AgentSpecPanel.tsx index 1285579b6a..554d0bea26 100644 --- a/apps/website/src/components/AgentSpecPanel.tsx +++ b/apps/website/src/components/AgentSpecPanel.tsx @@ -65,6 +65,7 @@ const COPY = { viaRailway: 'Railway service', viaInngest: 'Inngest dashboard', timerDisabled: 'timer inside the process — controlled by the service’s deployment', + launchdDisabled: 'launchd job on the owner’s Mac — no remote handle', unknownService: 'the Railway service id is not known to the site', skillRun: 'a skill is launched by an agent with its command; there is no run button without a control plane', command: 'command', @@ -128,6 +129,7 @@ const COPY = { viaRailway: 'сервис в Railway', viaInngest: 'панель Inngest', timerDisabled: 'таймер внутри процесса — управляется деплоем сервиса', + launchdDisabled: 'задача launchd на Mac владельца — удалённого запуска нет', unknownService: 'id сервиса Railway сайту не известен', skillRun: 'скилл запускает агент по его команде; без контура управления кнопки запуска нет', command: 'команда', @@ -495,7 +497,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n = {t.runNow} - {runNow.reason === 'timer' ? t.timerDisabled : t.unknownService} + {runNow.reason === 'timer' ? t.timerDisabled : runNow.reason === 'launchd' ? t.launchdDisabled : t.unknownService} ) ) : ( diff --git a/apps/website/src/lib/agentSpecs.ts b/apps/website/src/lib/agentSpecs.ts index 6614ae0af5..ba600242e1 100644 --- a/apps/website/src/lib/agentSpecs.ts +++ b/apps/website/src/lib/agentSpecs.ts @@ -14,7 +14,7 @@ export type Witness = 'spec+code' | 'spec-only' | 'code-only' /** An agent's witness: at least one experience episode names its letter, or none does. */ export type AgentWitness = 'spec+experience' | 'spec-only' export type Health = 'ok' | 'warn' | 'fail' -export type CronHostKind = 'github-actions' | 'inngest' | 'railway-cron' | 'timer' +export type CronHostKind = 'github-actions' | 'inngest' | 'railway-cron' | 'timer' | 'launchd' export type CronControl = 'github-actions-dispatch' | 'railway-dashboard' | 'inngest-dashboard' | 'code-only' export interface SkillSpecFields { @@ -643,7 +643,7 @@ export function vendoredSpecUrl(specPath: string): string { export type RunNowTarget = | { kind: 'link'; url: string; via: 'github-actions' | 'railway' | 'inngest' } - | { kind: 'disabled'; reason: 'timer' | 'unknown-service' } + | { kind: 'disabled'; reason: 'timer' | 'launchd' | 'unknown-service' } // --------------------------------------------------------------------------- // The optional live control plane. See docs/agent-control-api.md. diff --git a/apps/website/src/lib/cronsLoader.ts b/apps/website/src/lib/cronsLoader.ts index b431b30249..81e995fbab 100644 --- a/apps/website/src/lib/cronsLoader.ts +++ b/apps/website/src/lib/cronsLoader.ts @@ -2,7 +2,7 @@ // scripts/sync-crons.mjs, which reads the three repositories on disk and the // hand-kept Railway service list. The page reads only this committed file. -export type CronKind = 'railway-cron' | 'inngest' | 'timer' | 'github-actions' +export type CronKind = 'railway-cron' | 'inngest' | 'timer' | 'github-actions' | 'launchd' export type Health = 'ok' | 'warn' | 'fail' export interface CronSchedule { diff --git a/apps/website/src/pages/CronExplorer.tsx b/apps/website/src/pages/CronExplorer.tsx index e1b946df7b..cf934ff85d 100644 --- a/apps/website/src/pages/CronExplorer.tsx +++ b/apps/website/src/pages/CronExplorer.tsx @@ -73,6 +73,7 @@ const UI = { kindRailway: 'Railway cron', kindInngest: 'Inngest', kindTimer: 'Timer', + kindLaunchd: 'Mac (launchd)', kindActions: 'GitHub Actions', everyMinutes: 'minutes', everyHourly: 'hourly', @@ -140,6 +141,7 @@ const UI = { kindRailway: 'Крон Railway', kindInngest: 'Inngest', kindTimer: 'Таймер', + kindLaunchd: 'Mac владельца (launchd)', kindActions: 'GitHub Actions', everyMinutes: 'минуты', everyHourly: 'каждый час', @@ -192,6 +194,7 @@ function kindLabel(kind: string, ui: Ui): string { if (kind === 'railway-cron') return ui.kindRailway if (kind === 'inngest') return ui.kindInngest if (kind === 'timer') return ui.kindTimer + if (kind === 'launchd') return ui.kindLaunchd return ui.kindActions }