Skip to content

Latest commit

 

History

History
195 lines (152 loc) · 7.78 KB

File metadata and controls

195 lines (152 loc) · 7.78 KB

OS-Integration Contract

This document describes how the scheduler talks to an operating system's timer facility, and how to add support for a new OS.

macOS (launchd) is the primary, fully-supported and tested backend. Linux (systemd/cron) and Windows (Task Scheduler) are supported through the same contract and kept functional, but see less active testing.


The big picture

tool call ─▶ src/tools.ts ─▶ src/backends/registry.ts ─▶ a SchedulerBackend
                   │                                            │
                   │                                            ▼
                   │                          installs an OS timer that runs…
                   ▼                                            │
             src/core/*  (job storage, run-spec,               ▼
              invocation snapshot, supervisor)    the supervisor (bun|node JS)
                                                                │
                                                                ▼
                                                     opencode run … (the job)

Two ideas make this extensible:

  1. The SchedulerBackend contract (src/backends/contract.ts) is the only thing a new OS must implement. A backend translates a Job (with its cron schedule) into an OS timer and installs/removes it.

  2. The supervisor (src/supervisor/main.ts, compiled to a JS payload) is a runtime-agnostic wrapper that every backend schedules instead of running opencode directly. It provides the reliability guarantees (no-overlap lock, timeout, retry, run history) uniformly, so a backend author never re-implements them. It runs under bun or node (whichever is resolved at install time), using only APIs shared by both.


The contract

interface SchedulerBackend {
  readonly id: SchedulerBackendId
  isSupported(): boolean
  isAvailable(): boolean
  describeReliability(job: Job): BackendReliability
  install(job: Job): void
  uninstall(job: Job): void
  listInstalledArtifacts(): string[]
}

Rules (enforced by convention; see the doc comment in contract.ts):

  1. Stateless. All durable state — job JSON, locks, run history, logs — lives on disk under src/core/paths.ts and is owned by the core. A backend owns only its OS timer artifacts.
  2. Delegate execution. Schedule buildSupervisorCommand(job), never opencode directly. A backend that physically cannot (e.g. Windows Task Scheduler today) must declare mode: "direct" in describeReliability() so callers can warn the user.
  3. install is idempotent + replacing. Remove any existing/legacy entry first, then create the current one. Re-installing never duplicates timers.
  4. uninstall is tolerant. Removing a job with no entry is a no-op.
  5. Availability ≠ support. isSupported() = "right backend for this OS?"; isAvailable() = "can I use it right now?" (command present, daemon up).

Adding a new OS

Say you want to add support for a hypothetical fooclock.

1. Implement the contract

Create src/backends/fooclock.ts:

import type { Job } from "../core/types"
import type { BackendReliability, SchedulerBackend } from "./contract"
import { parseCron } from "../core/cron"
import { buildSupervisorCommand, ensureSupervisorScript } from "../core/supervisor"
import { ensureDir, jobScopeId, scopeLogsDir } from "../core/paths"

export function cronToFooclock(cron: string): string {
  const { minute, hour /* … */ } = parseCron(cron) // reuse the shared parser
  // …translate to fooclock's schedule syntax…
  return "…"
}

export const fooclockBackend: SchedulerBackend = {
  id: "fooclock" as any, // add to SchedulerBackendId in contract.ts

  isSupported() {
    return process.platform === "foo"
  },
  isAvailable() {
    return this.isSupported() /* && probe for the fooclock daemon */
  },
  describeReliability(): BackendReliability {
    return {
      mode: "supervised", // because we schedule the supervisor
      catchesUpMissedRuns: true,
      note: "The job runs at the scheduled time; missed runs are replayed.",
    }
  },
  install(job) {
    ensureDir(scopeLogsDir(jobScopeId(job)))
    ensureSupervisorScript()
    const { command, args } = buildSupervisorCommand(job) // ← always this
    // …write/replace the fooclock timer that runs [command, ...args]…
  },
  uninstall(job) {
    // …remove the fooclock timer for this job (tolerant of "not found")…
  },
  listInstalledArtifacts() {
    return [] // absolute file paths, if your timers are files
  },
}

2. Register it

In src/backends/registry.ts:

  • add fooclockBackend to the BACKENDS array,
  • add a branch to resolveBackend() for when it should win,
  • if it should participate in per-platform uninstall, add it to uninstallJob().

Add "fooclock" to SchedulerBackendId in contract.ts.

3. Test it

Add test/fooclock.test.ts covering cronToFooclock (the pure mapper) with the same table of expressions the other backends test: 0 9 * * *, */15 * * * *, 0 */6 * * *, 30 8 * * 1, and the day-of-month + day-of-week OR case.

That's it — tools.ts, storage, the supervisor, cleanup, and every tool work unchanged, because they only ever talk to the contract and the registry.


cron semantics to respect

When both day-of-month and day-of-week are restricted (neither is *), cron treats them as an OR: the job runs when either matches. Backends must preserve this. launchd emits two calendar sets; systemd emits two OnCalendar= groups. Reuse parseCron() and mirror that handling.

Ranges (1-5) are intentionally unsupported by the shared parser — expand to comma lists (1,2,3,4,5).


The supervisor contract

buildSupervisorCommand(job) returns { command, args } = [<bun|node>, supervisor.js, <jobPath>] (runtime resolved by findSupervisorRuntime: bun preferred, node fallback, OPENCODE_SCHEDULER_RUNTIME override). The supervisor:

  • reads the job JSON from <jobPath> (so the OS timer only needs the path),
  • takes a per-job lock and skips if a prior run is still alive (no overlap),
  • forces OPENCODE_PERMISSION question=deny (non-interactive),
  • enforces timeoutSeconds (SIGTERM → SIGKILL, exit 124),
  • applies the retry policy (retries, retryOn, retryBackoffSeconds),
  • writes lastRun* job metadata atomically and appends a JSONL run record.

It re-derives its paths from HOME + scopeId + slug, so the on-disk layout in core/paths.ts and the supervisor must stay in sync.

OPENCODE_SCHEDULER_SOURCE distinguishes scheduled (default) from manual (set by run_job). Both paths go through the supervisor, so manual and scheduled runs behave identically.

How the supervisor is built

The supervisor is authored in TypeScript at src/supervisor/main.ts (so it is type-checked and unit-tested with the rest of the plugin), then bundled by scripts/build-supervisor.ts into a dependency-free JavaScript string embedded at src/supervisor/payload.generated.ts. core/supervisor.ts writes that payload to ~/.config/opencode/scheduler/supervisor.js on install.

Because it must run under both bun and node, main.ts may use only APIs common to both (node:fs, node:child_process, node:process, node:path) — no bundler-only globals, no npm dependencies. The build targets node, which bun also runs. Regenerate the payload with bun run build:supervisor (it runs automatically before build, test, and typecheck).

A working opencode does not guarantee a runtime is on PATH (opencode ships as a compiled binary), so findSupervisorRuntime probes PATH, then next to the resolved opencode binary, then common install locations — bun first, then node. If neither is found it throws at install time with an actionable message.