Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions apps/website/scripts/agents-from-specs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand All @@ -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' }
}
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions apps/website/scripts/agents-from-specs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, [])
Expand Down
4 changes: 3 additions & 1 deletion apps/website/src/components/AgentSpecPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -128,6 +129,7 @@ const COPY = {
viaRailway: 'сервис в Railway',
viaInngest: 'панель Inngest',
timerDisabled: 'таймер внутри процесса — управляется деплоем сервиса',
launchdDisabled: 'задача launchd на Mac владельца — удалённого запуска нет',
unknownService: 'id сервиса Railway сайту не известен',
skillRun: 'скилл запускает агент по его команде; без контура управления кнопки запуска нет',
command: 'команда',
Expand Down Expand Up @@ -495,7 +497,7 @@ export function AgentSpecPanel({ lang, kind, id, entry, links, embedded, i18n =
<span aria-disabled="true" style={{ ...pill, cursor: 'default', opacity: 0.5 }}>
{t.runNow}
</span>
<span style={{ fontSize: 11, color: C.muted }}>{runNow.reason === 'timer' ? t.timerDisabled : t.unknownService}</span>
<span style={{ fontSize: 11, color: C.muted }}>{runNow.reason === 'timer' ? t.timerDisabled : runNow.reason === 'launchd' ? t.launchdDisabled : t.unknownService}</span>
</>
)
) : (
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/lib/agentSpecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/lib/cronsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions apps/website/src/pages/CronExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const UI = {
kindRailway: 'Railway cron',
kindInngest: 'Inngest',
kindTimer: 'Timer',
kindLaunchd: 'Mac (launchd)',
kindActions: 'GitHub Actions',
everyMinutes: 'minutes',
everyHourly: 'hourly',
Expand Down Expand Up @@ -140,6 +141,7 @@ const UI = {
kindRailway: 'Крон Railway',
kindInngest: 'Inngest',
kindTimer: 'Таймер',
kindLaunchd: 'Mac владельца (launchd)',
kindActions: 'GitHub Actions',
everyMinutes: 'минуты',
everyHourly: 'каждый час',
Expand Down Expand Up @@ -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
}

Expand Down
Loading