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
578 changes: 289 additions & 289 deletions cron/Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion cron/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[package]
name = "iii-cron"
version = "0.21.5"
version = "0.21.6"
edition = "2021"
description = "Cron scheduler worker for iii - schedules functions with cron expressions (cron trigger type)"
license = "Apache-2.0"
Expand All @@ -19,6 +19,7 @@ path = "src/main.rs"

[dependencies]
iii-sdk = "=0.23.0-rc.2"
iii-console-ui = { path = "../crates/console-ui" }
async-trait = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] }
serde = { version = "1", features = ["derive"] }
Expand Down
12 changes: 12 additions & 0 deletions cron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ console (**Configuration -> Workers -> cron**) or seed it once via
scheduler under a serialized apply lock: existing jobs are stopped, re-created
with the new backend, and never run in two scheduler instances at once.

## Console page

While the worker is connected it injects a **cron** page into the console
(`#/ext/cron`): every agent-owned schedule with its cadence, next UTC run and
fire count, the cron bindings other workers registered for themselves, and a
composer that turns "every weekday at 09:00, summarise open PRs" into a
registered schedule. Schedules created there live in a session of their own,
so each routine keeps its own transcript.

## Trigger type

This worker always registers the `cron` trigger type. Bind a function to it
Expand All @@ -85,6 +94,9 @@ with:
All schedules use UTC. Missed fires while the worker is stopped are skipped;
there is no catch-up replay.

Write the day of week as a name (`Mon` ... `Sun`). Numerically the crate counts
Sunday as 1, so `0 0 9 * * 1` fires on Sunday, not Monday.

### Requires removing the legacy built-in cron worker

The legacy built-in cron worker also owns the `cron` trigger type. Two owners
Expand Down
138 changes: 138 additions & 0 deletions cron/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! Build script for the cron worker's injectable console UI.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;

fn main() {
println!(
"cargo:rustc-env=TARGET={}",
std::env::var("TARGET").unwrap()
);

println!("cargo:rerun-if-changed=ui/page.tsx");
println!("cargo:rerun-if-changed=ui/styles.css");
println!("cargo:rerun-if-changed=ui/src");
println!("cargo:rerun-if-changed=ui/build.mjs");
println!("cargo:rerun-if-changed=ui/package.json");
println!("cargo:rerun-if-changed=../pnpm-lock.yaml");
println!("cargo:rerun-if-changed=ui/tsconfig.json");

let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let ui_dir = manifest_dir.join("ui");
let dist_assets = [
ui_dir.join("dist").join("page.js"),
ui_dir.join("dist").join("styles.css"),
];

if dist_assets
.iter()
.all(|asset| asset.exists() && dist_is_fresh(asset, &ui_dir))
{
return;
}

if std::env::var_os("SKIP_UI_BUILD").is_some() {
for asset in &dist_assets {
if !asset.exists() {
panic!(
"SKIP_UI_BUILD set but {} is missing; build the UI first",
asset.display()
);
}
}
return;
}

let pnpm = locate_pnpm();
run(&pnpm, &["install"], &ui_dir);
run(&pnpm, &["build"], &ui_dir);

for asset in &dist_assets {
if !asset.exists() {
panic!("UI build completed but {} is missing", asset.display());
}
}
}

fn run(command: &Path, args: &[&str], cwd: &Path) {
let status = Command::new(command)
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|error| panic!("failed to run {}: {error}", command.display()));
if !status.success() {
panic!("{} exited with {status}", command.display());
}
}

fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool {
let Ok(dist_mtime) = dist_asset.metadata().and_then(|meta| meta.modified()) else {
return false;
};
let watched_files = [
ui_dir.join("page.tsx"),
ui_dir.join("styles.css"),
ui_dir.join("build.mjs"),
ui_dir.join("package.json"),
ui_dir.join("../../pnpm-lock.yaml"),
ui_dir.join("tsconfig.json"),
];
for file in watched_files {
if !file.exists() {
continue;
}
let Ok(modified) = file.metadata().and_then(|meta| meta.modified()) else {
return false;
};
if modified > dist_mtime {
return false;
}
}
subtree_older_than(&ui_dir.join("src"), dist_mtime)
}

fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool {
let Ok(entries) = std::fs::read_dir(root) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else {
return false;
};
if meta.is_dir() {
if !subtree_older_than(&path, ceiling) {
return false;
}
} else {
let Ok(modified) = meta.modified() else {
return false;
};
if modified > ceiling {
return false;
}
}
}
true
}

fn locate_pnpm() -> PathBuf {
if let Ok(explicit) = std::env::var("PNPM") {
return PathBuf::from(explicit);
}
let names = if cfg!(windows) {
["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice()
} else {
["pnpm"].as_slice()
};
for directory in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
for name in names {
let candidate = directory.join(name);
if candidate.is_file() {
return candidate;
}
}
}
panic!("pnpm not found on PATH");
}
5 changes: 5 additions & 0 deletions cron/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ The schedule grammar is the Rust `cron` crate dialect: six or seven fields,
optional. The leading field is always seconds, so `0 */5 * * * *` fires every
5 minutes at second 0, while `*/5 * * * * *` fires every 5 seconds.

Write the day of week as a name: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`,
`Sun`. Numerically this dialect counts Sunday as 1, one ahead of the Unix
convention, so `0 0 9 * * 1` fires on Sunday and a `1` written for Monday
fires a day early. Names avoid the whole question.

Two lock backends govern duplicate firing. `local` is the default and is only
process-local; every worker instance can fire the same job in a multi-instance
deployment. `redis` uses Redis locking and is required for once-only firing
Expand Down
2 changes: 2 additions & 0 deletions cron/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ pub async fn start(iii: Arc<IIIClient>, config: CronConfig) -> anyhow::Result<Bo
.trigger_request_format::<CronTriggerSpec>(),
);

crate::ui::register(&iii);

Ok(BootHandle {
scheduler: scheduler_cell,
config: Arc::new(tokio::sync::RwLock::new(config.normalized())),
Expand Down
52 changes: 14 additions & 38 deletions cron/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,15 @@ use crate::config::CronConfig;
use crate::locks;
use crate::scheduler::Scheduler;

pub const DEFAULT_CONFIG_ID: &str = "cron";

/// The configuration entry this worker owns.
///
/// `III_CONFIG_NAME` when a supervisor set it, else the built-in name. A worker
/// that hardcodes its id turns that id into a global scarce name: two instances
/// share one entry and take turns overwriting it, and each write wakes both.
/// Being told which entry is its own is what lets them differ.
pub fn config_id() -> &'static str {
static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
ID.get_or_init(|| {
std::env::var("III_CONFIG_NAME")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_ID.to_string())
})
.as_str()
}
pub const CONFIG_ID: &str = "cron";
const CONFIG_FN_ID: &str = "cron::on-config-change";
const CONFIG_RETRIES: u32 = 3;
const CONFIG_RETRY_BACKOFF_MS: u64 = 250;
const CONFIG_BUS_TIMEOUT_MS: u64 = 10_000;

pub async fn register_config(iii: &IIIClient, seed: Option<&CronConfig>) -> Result<(), String> {
let mut payload = json!({
"id": config_id(),
"id": CONFIG_ID,
"name": "Cron",
"description": "Cron scheduler settings - lock backend for multi-instance mutual exclusion (local or redis).",
"schema": CronConfig::json_schema(),
Expand All @@ -48,7 +30,7 @@ pub async fn register_config(iii: &IIIClient, seed: Option<&CronConfig>) -> Resu
let seed = seed.cloned().unwrap_or_default().normalized();
payload["initial_value"] = seed.to_json();
}
trigger_configuration_with_retry(
trigger_with_retry(
iii,
"configuration::register",
payload,
Expand All @@ -62,10 +44,7 @@ pub async fn fetch_config(iii: &IIIClient) -> Result<CronConfig, String> {
match try_get_config_value(iii).await? {
Some(value) if !value.is_null() => CronConfig::from_json(&value),
_ => {
tracing::info!(
"no `{config_entry}` configuration value stored; using built-in default",
config_entry = config_id()
);
tracing::info!("no `{CONFIG_ID}` configuration value stored; using built-in default");
Ok(CronConfig::default())
}
}
Expand All @@ -79,10 +58,10 @@ async fn should_seed_initial_value(iii: &IIIClient) -> Result<bool, String> {
}

async fn try_get_config_value(iii: &IIIClient) -> Result<Option<Value>, String> {
match trigger_configuration_with_retry(
match trigger_with_retry(
iii,
"configuration::get",
json!({ "id": config_id() }),
json!({ "id": CONFIG_ID }),
CONFIG_BUS_TIMEOUT_MS,
)
.await
Expand Down Expand Up @@ -113,7 +92,7 @@ pub fn register_config_trigger(iii: &Arc<IIIClient>, parts: BootParts) -> Result
"configuration".to_string(),
CONFIG_FN_ID.to_string(),
json!({
"configuration_id": config_id(),
"configuration_id": CONFIG_ID,
"event_types": ["configuration:updated"],
}),
))?;
Expand Down Expand Up @@ -177,7 +156,7 @@ fn swap_needed(current: &CronConfig, next: &CronConfig) -> bool {
!= next.adapter.as_ref().and_then(|a| a.config.clone())
}

async fn trigger_configuration_with_retry(
async fn trigger_with_retry(
iii: &IIIClient,
function_id: &str,
payload: Value,
Expand All @@ -186,15 +165,12 @@ async fn trigger_configuration_with_retry(
let mut last_err = String::new();
for attempt in 1..=CONFIG_RETRIES {
match iii
.trigger(
TriggerRequest {
function_id: function_id.to_string(),
payload: payload.clone(),
action: None,
timeout_ms: Some(timeout_ms),
}
.namespace("default"),
)
.trigger(TriggerRequest {
function_id: function_id.to_string(),
payload: payload.clone(),
action: None,
timeout_ms: Some(timeout_ms),
})
.await
{
Ok(v) => return Ok(v),
Expand Down
1 change: 1 addition & 0 deletions cron/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ pub mod locks;
pub mod manifest;
pub mod scheduler;
pub mod trigger;
pub mod ui;
46 changes: 46 additions & 0 deletions cron/src/ui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Injectable console UI for the cron worker.

use std::sync::Arc;

use iii_console_ui::ConsoleUi;
use iii_sdk::IIIClient;

pub const PAGE_PATH: &str = "cron/page.js";
pub const STYLES_PATH: &str = "cron/styles.css";

const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js"));
const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css"));

fn console_ui() -> ConsoleUi {
ConsoleUi::new("cron")
.script(PAGE_PATH, PAGE_JS)
.style(STYLES_PATH, STYLES_CSS)
}

pub fn register(iii: &Arc<IIIClient>) {
console_ui().register(iii);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ui_builder_accepts_the_assets() {
let _ = console_ui();
}

#[test]
fn embedded_page_is_nonempty_esm() {
assert!(PAGE_JS.contains("export"), "built page.js looks wrong");
}

#[test]
fn embedded_styles_are_scoped() {
assert!(
STYLES_CSS.contains(r#"[data-iii-ui="cron"]"#)
|| STYLES_CSS.contains("[data-iii-ui=cron]"),
"built styles.css must be scoped under the cron worker"
);
}
}
24 changes: 24 additions & 0 deletions cron/ui/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import esbuild from 'esbuild'

const options = {
entryPoints: ['page.tsx', 'styles.css'],
bundle: true,
format: 'esm',
jsx: 'automatic',
outdir: 'dist',
external: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'@iii-dev/console-ui',
],
logLevel: 'info',
}

if (process.argv.includes('--watch')) {
const context = await esbuild.context(options)
await context.watch()
} else {
await esbuild.build(options)
}
Loading
Loading