diff --git a/README.md b/README.md index 9c013d1..cf024b7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight, agent-agnostic framework for building personal automations. -Every automation in VibeShed is a **Trigger → Action → Result** flow, defined as a folder of plain files (`config.yaml`, `sequence.md`, `scripts/main.py`). Agents read those files to orchestrate work; the `vibeshed` CLI executes them so cron, CI, or any other trigger source can run jobs without an agent in the loop. +VibeShed is for **vibe-coding automations** with an agent in the loop. Every job is a folder of plain files (`config.yaml`, `sequence.md`, `scripts/main.py`) that an agent can read, modify, and call. The `vibeshed` CLI runs those jobs so cron, CI, another agent, or a human can invoke them with the same interface. ## Install @@ -17,17 +17,30 @@ vibeshed init my-automations cd my-automations vibeshed new daily-briefing # edit jobs/daily-briefing/scripts/main.py -vibeshed run daily-briefing +vibeshed run daily-briefing -- --date 2026-04-19 vibeshed logs daily-briefing ``` +Everything after `--` is forwarded to `scripts/main.py`. Parse params with `argparse` (the template scaffolds this for you). + +## What it means to be one with the vibeshed + +Every job follows four rules. The long form lives in [`PRINCIPLES.md`](src/vibeshed/templates/PRINCIPLES.md) — scaffolded into every project at `PRINCIPLES.md`. + +1. **Scripts are simple and composable** — small python scripts that lean on `shared/` for logging, state, notifications, and API clients. +2. **Params pass through the CLI** — `vibeshed run -- --key value` forwards everything after `--` to the script. No magic wrapper layer. +3. **Success or failure, nothing in between** — scripts exit `0` on success, non-zero on failure. `logs//runs.json` records the outcome. +4. **Errors have a timeout** — `config.yaml`'s `timeout_minutes` is enforced by `vibeshed run`; hitting it is a FAILURE with `error: "timeout ..."`. + +Agents working in a VibeShed repo use these principles to guide users through vibe-coding new automations. + ## Commands | Command | Purpose | | --- | --- | | `vibeshed init [path]` | Scaffold a new automations repo. | | `vibeshed new ` | Create a new job from the template. | -| `vibeshed run ` | Execute a job's `scripts/main.py` and record the run. | +| `vibeshed run -- ` | Execute a job's `scripts/main.py` with passthrough args, enforce `timeout_minutes`, record the run. | | `vibeshed list` | List all registered jobs. | | `vibeshed validate` | Lint `registry.yaml` and job folder structure. | | `vibeshed status` | Show framework version and any drift in managed files. | @@ -38,12 +51,12 @@ vibeshed logs daily-briefing ## How updates work -Files like `shared/`, `AGENTS.md`, and `CLAUDE.md` are **framework-managed**. VibeShed records their original SHAs in `.vibeshed/manifest.json` so `vibeshed update` can: +Files like `shared/`, `AGENTS.md`, `CLAUDE.md`, and `PRINCIPLES.md` are **framework-managed**. VibeShed records their original SHAs in `.vibeshed/manifest.json` so `vibeshed update` can: - Cleanly overwrite files you haven't touched. - Preserve files where only you changed them. - Three-way merge files that both you and the framework changed (delegated to `git merge-file`). -- Preserve any content you add **outside** the `` / `` markers in `AGENTS.md`, `CLAUDE.md`, `.gitignore`, and `requirements.txt`. +- Preserve any content you add **outside** the `` / `` markers in `AGENTS.md`, `CLAUDE.md`, `PRINCIPLES.md`, `.gitignore`, and `requirements.txt`. `registry.yaml`, `jobs/`, `state/`, `logs/`, and `.env` are never touched by `update`. They are 100% yours. diff --git a/pyproject.toml b/pyproject.toml index c345065..92b84cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "vibeshed" -version = "0.1.0" -description = "Vibe Coding Framework for Personal Automations — agent-agnostic CLI for Trigger → Action → Result jobs." +version = "0.1.1" +description = "Vibe Coding Framework for Personal Automations — agent-agnostic CLI for vibe-coded jobs with passthrough params, exit-code results, and enforced timeouts." readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.9" diff --git a/src/vibeshed/__init__.py b/src/vibeshed/__init__.py index 3dc1f76..485f44a 100644 --- a/src/vibeshed/__init__.py +++ b/src/vibeshed/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/vibeshed/cli.py b/src/vibeshed/cli.py index cee0478..a82ef74 100644 --- a/src/vibeshed/cli.py +++ b/src/vibeshed/cli.py @@ -26,7 +26,10 @@ app.command("update")(update) app.command("status")(status) app.command("new")(new) -app.command("run")(run) +app.command( + "run", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +)(run) app.command("list")(list_jobs) app.command("validate")(validate) app.command("logs")(logs) diff --git a/src/vibeshed/commands/run.py b/src/vibeshed/commands/run.py index b60f950..e9c0a98 100644 --- a/src/vibeshed/commands/run.py +++ b/src/vibeshed/commands/run.py @@ -12,16 +12,20 @@ from typing import Optional import typer +import yaml from vibeshed.commands._common import find_project_root, load_registry +DEFAULT_TIMEOUT_MINUTES = 5.0 + def run( + ctx: typer.Context, slug: str = typer.Argument(..., help="Slug of the job to run."), trigger: str = typer.Option( "cli", "--trigger", - help="What triggered this run. Recorded in runs.json. Common values: cli, cron, manual, agent.", + help="What invoked this run. Recorded in runs.json. Common values: cli, cron, manual, agent.", ), agent_id: Optional[str] = typer.Option( None, "--agent-id", help="Agent type/platform identifier (e.g. claude-cowork)." @@ -30,7 +34,11 @@ def run( None, "--agent-instance", help="Specific agent session/instance identifier." ), ) -> None: - """Execute jobs//scripts/main.py and record the result.""" + """Execute ``jobs//scripts/main.py`` and record the result. + + Any arguments after ``--`` on the command line are forwarded verbatim to + the script, e.g. ``vibeshed run my-job -- --date 2026-04-19 --user ben``. + """ project_root = find_project_root() registry = load_registry(project_root) if slug not in registry["jobs"]: @@ -51,6 +59,11 @@ def run( ) raise typer.Exit(code=1) + timeout_minutes = _read_timeout_minutes(project_root / "jobs" / slug / "config.yaml") + timeout_seconds = timeout_minutes * 60 + + passthrough_args = list(ctx.args) + started = datetime.now() timestamp_iso = started.replace(microsecond=0).isoformat() log_dir = project_root / "logs" / slug / started.strftime("%Y-%m-%d") @@ -65,21 +78,38 @@ def run( ) typer.secho(f"▶ {slug} ({run_id})", fg=typer.colors.CYAN) + cmd = [sys.executable, str(main_script), *passthrough_args] start = time.monotonic() - with log_file.open("w", encoding="utf-8") as fh: + timed_out = False + try: proc = subprocess.run( - [sys.executable, str(main_script)], + cmd, cwd=project_root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + timeout=timeout_seconds, ) - fh.write(proc.stdout or "") + stdout = proc.stdout or "" + returncode = proc.returncode + except subprocess.TimeoutExpired as te: + timed_out = True + stdout = (te.stdout or "") if isinstance(te.stdout, str) else "" + returncode = -1 duration_ms = int((time.monotonic() - start) * 1000) - status = "SUCCESS" if proc.returncode == 0 else "FAILURE" - error = None if proc.returncode == 0 else _last_lines(proc.stdout or "", 5) + log_file.write_text(stdout, encoding="utf-8") + + if timed_out: + status = "FAILURE" + error = f"timeout after {timeout_minutes} minutes" + elif returncode == 0: + status = "SUCCESS" + error = None + else: + status = "FAILURE" + error = _last_lines(stdout, 5) runs_file = project_root / "logs" / slug / "runs.json" runs = _load_runs(runs_file) @@ -97,12 +127,30 @@ def run( color = typer.colors.GREEN if status == "SUCCESS" else typer.colors.RED typer.secho(f" {status} in {duration_ms}ms — {log_file}", fg=color) - if proc.stdout: - typer.echo(proc.stdout, nl=False) + if stdout: + typer.echo(stdout, nl=False) + if timed_out: + typer.secho(f" (timeout: {timeout_minutes} minutes)", fg=typer.colors.RED, err=True) raise typer.Exit(code=0 if status == "SUCCESS" else 1) +def _read_timeout_minutes(config_path: Path) -> float: + """Read ``timeout_minutes`` from a job's config.yaml. Falls back on 5.0.""" + if not config_path.exists(): + return DEFAULT_TIMEOUT_MINUTES + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + return DEFAULT_TIMEOUT_MINUTES + value = data.get("timeout_minutes", DEFAULT_TIMEOUT_MINUTES) + try: + parsed = float(value) + except (TypeError, ValueError): + return DEFAULT_TIMEOUT_MINUTES + return parsed if parsed > 0 else DEFAULT_TIMEOUT_MINUTES + + def _load_runs(path: Path) -> dict: if path.exists(): return json.loads(path.read_text(encoding="utf-8")) diff --git a/src/vibeshed/templates/AGENTS.md b/src/vibeshed/templates/AGENTS.md index 3ce7d62..4958939 100644 --- a/src/vibeshed/templates/AGENTS.md +++ b/src/vibeshed/templates/AGENTS.md @@ -1,20 +1,22 @@ - + # Agent Guidelines for VibeShed -Conventions any agent should follow when working in a VibeShed repo. +Conventions any agent should follow when working in a VibeShed repo. The canonical rules for vibeshed-style automations live in [`PRINCIPLES.md`](PRINCIPLES.md) — read that first. -## Core Principles +## Core Principles (summary) -1. **Trigger → Action → Result** — every automation defines all three. -2. **Fail safely** — errors are logged and notified, never swallowed. -3. **State is explicit** — use `shared/state.py`, not hidden globals. -4. **Logs are for humans** — write clear, readable messages. +1. **Scripts are simple and composable** — small python scripts, shared helpers in `shared/`. +2. **Params pass through the CLI** — `vibeshed run -- --key value` forwards everything after `--` to the script. +3. **Success or failure, nothing in between** — exit `0` on success, non-zero on failure. `runs.json` is the source of truth. +4. **Errors have a timeout** — `config.yaml`'s `timeout_minutes` is enforced by `vibeshed run`. + +See `PRINCIPLES.md` for full detail and examples. ## Job Structure - Each job lives in `jobs/{slug}/`. -- `sequence.md` contains your action steps and result handling. -- `config.yaml` holds job settings (timeout, custom params). +- `sequence.md` documents **Inputs → Action → Result** (what the job accepts, what it does, what it emits). +- `config.yaml` holds job settings (`timeout_minutes`, any custom knobs). - `scripts/` contains the deterministic scripts. - Never modify files outside the job folder, except `shared/` and `state/`. @@ -29,20 +31,20 @@ vibeshed new Then: 1. Update the entry in `registry.yaml` with the real description, tags, and dependencies. -2. Edit `jobs/{slug}/sequence.md` with the Action steps and Result handling. -3. Implement `jobs/{slug}/scripts/main.py` (or split into multiple step scripts). +2. Edit `jobs/{slug}/sequence.md` with the Inputs, Action, and Result sections. +3. Implement `jobs/{slug}/scripts/main.py` — parse `argparse` args for any params, exit `0`/non-zero. 4. Run `vibeshed validate` to check the structure. -5. Test with `vibeshed run `. +5. Test with `vibeshed run -- `. ## Running Jobs Always use the CLI rather than invoking scripts directly: ```sh -vibeshed run +vibeshed run -- --param1 value --param2 value ``` -This sets `JOB_SLUG`, captures logs to the right folder, and updates `runs.json`. If you must run a single step manually, set `JOB_SLUG=` so logging routes correctly. +Everything after `--` is forwarded verbatim to `scripts/main.py`. The CLI sets `JOB_SLUG`, captures logs, enforces `timeout_minutes`, and records the outcome in `logs//runs.json`. If you must run a single step manually, set `JOB_SLUG=` so logging routes correctly. ## Environment Variables @@ -77,6 +79,7 @@ Extend shared utilities for common patterns; don't duplicate. Keep shared code g - Send error notifications for failures. - Exit non-zero on failure. - Do not leave jobs in partially-completed states. +- Set `timeout_minutes` in `config.yaml` to 2–3× expected runtime so flakes fail loudly. diff --git a/src/vibeshed/templates/CLAUDE.md b/src/vibeshed/templates/CLAUDE.md index 9203e4f..0bf291c 100644 --- a/src/vibeshed/templates/CLAUDE.md +++ b/src/vibeshed/templates/CLAUDE.md @@ -1,14 +1,14 @@ - + # Claude Code Instructions for VibeShed -You help users run existing automations and build new ones following the **Trigger → Action → Result** framework. Prefer the `vibeshed` CLI over raw shell whenever it's available — every CLI command below has been designed to give you a structured, predictable interface. +You help users run existing automations and vibe-code new ones that follow the [vibeshed principles](PRINCIPLES.md): simple composable scripts, CLI passthrough params, success/failure exit codes, and enforced timeouts. Prefer the `vibeshed` CLI over raw shell whenever it's available — every command below has been designed to give you a structured, predictable interface. ## Quick Reference | Intent | Command | | --- | --- | | List all jobs | `vibeshed list` | -| Run a job | `vibeshed run ` | +| Run a job | `vibeshed run -- ` | | Create a new job | `vibeshed new ` | | Validate the repo | `vibeshed validate` | | Check recent logs | `vibeshed logs -n 50` | @@ -23,9 +23,10 @@ You help users run existing automations and build new ones following the **Trigg When the user says "run {slug}": 1. Confirm the job exists with `vibeshed list`. -2. Check `dependencies.env_vars` in `registry.yaml` — ask the user for any missing values. -3. Run with `vibeshed run `. -4. If it fails, show the recent logs with `vibeshed logs `. +2. Read `jobs/{slug}/sequence.md` — the **Inputs** section lists the params the script expects and any required env vars. +3. Check `dependencies.env_vars` in `registry.yaml` — ask the user for any missing values. +4. Run with `vibeshed run -- `. Everything after `--` is forwarded to `scripts/main.py`. +5. If it fails, show the recent logs with `vibeshed logs `. ### Create a New Job @@ -33,23 +34,27 @@ When the user says "create job {name}": 1. Generate a kebab-case slug from the name. 2. Run `vibeshed new ` to scaffold the folder. -3. Ask for: description, what each step does, expected inputs/outputs, what result/notification they want. +3. Ask for: description, expected params (name, type, required?), required env vars, what each step does, what result/notification they want. 4. Update the entry in `registry.yaml` with the real description, tags, and `dependencies`. -5. Fill in `jobs/{slug}/sequence.md` with the Action and Result sections. -6. Implement `jobs/{slug}/scripts/main.py` (deterministic CLI entry point). - - For multi-step jobs, also create one script per step under `scripts/` and reference them in `sequence.md`. -7. Run `vibeshed validate` to check the structure. -8. Test with `vibeshed run `. +5. Fill in `jobs/{slug}/sequence.md` with the Inputs, Action, and Result sections. +6. Implement `jobs/{slug}/scripts/main.py`: + - Parse params with `argparse`. + - Exit `0` on success, non-zero on failure. + - Use `from shared import logging` — never `print()`. + - For multi-step jobs, split into scripts under `scripts/` and reference them in `sequence.md`. +7. Set a realistic `timeout_minutes` in `jobs/{slug}/config.yaml` (2–3× expected runtime). +8. Run `vibeshed validate` to check the structure. +9. Test with `vibeshed run -- `. ### Multi-Step Orchestration If `sequence.md` has multiple step scripts and you need to chain them with conditional logic: -- Run each step directly: `JOB_SLUG= python jobs//scripts/.py` +- Run each step directly: `JOB_SLUG= python jobs//scripts/.py ` - Pipe stdin/stdout between steps as documented in `sequence.md`. - Decide whether to skip steps based on previous outputs. -If the job is a single straight-through pipeline, prefer `vibeshed run ` — it executes `scripts/main.py` and handles logging and run tracking for you. +If the job is a single straight-through pipeline, prefer `vibeshed run -- ` — it executes `scripts/main.py`, enforces the timeout, and records the run. ## Code Style @@ -57,6 +62,7 @@ If the job is a single straight-through pipeline, prefer `vibeshed run ` - Document functions with docstrings. - Handle errors gracefully with try/except at the job level. - Use `from shared import logging` — never the stdlib `logging` directly, never `print()`. +- Parse params with `argparse` so `vibeshed run -- --foo bar` works. ## sequence.md Skeleton @@ -65,14 +71,19 @@ If the job is a single straight-through pipeline, prefer `vibeshed run ` [Brief description.] -## Trigger -[Cron / on-demand / event / hybrid.] +## Inputs + +- Params (passed after `--` on `vibeshed run`): + - `--date` (required) — ISO date, e.g. `2026-04-19`. + - `--user` (optional) — target user slug. +- Env vars: + - `SOME_API_KEY` — required. ## Action ### Step 1: [Description] Run: `scripts/{script_name}.py` -- Input: [stdin / args / env / files] +- Input: [params / env / stdin / files] - Output: [JSON / text / files] ## Result @@ -84,18 +95,19 @@ Run: `scripts/{script_name}.py` - [Retry, alert, cleanup.] ## Notes -[Edge cases, special considerations.] +[Edge cases, special considerations, timeout reasoning.] ``` ## Testing a New Job Before declaring a job complete: -1. `vibeshed run ` — verify it succeeds. +1. `vibeshed run -- ` — verify it succeeds. 2. `vibeshed logs ` — verify the log file was created. 3. Inspect `logs//runs.json` — confirm a SUCCESS entry exists. 4. Check that state was updated (if applicable). 5. Confirm notifications were sent (if configured). +6. Verify timeout behavior: set a tight `timeout_minutes`, force a slow run, and confirm FAILURE is recorded with `error` mentioning `timeout`. diff --git a/src/vibeshed/templates/PRINCIPLES.md b/src/vibeshed/templates/PRINCIPLES.md new file mode 100644 index 0000000..2ae01cc --- /dev/null +++ b/src/vibeshed/templates/PRINCIPLES.md @@ -0,0 +1,42 @@ + +# VibeShed Principles + +What it means to **be one with the vibeshed**. These are the rules every job follows so agents and humans can mix and match them without surprises. + +## 1. Scripts are simple and composable + +Every job is a small python script (or a handful of them) that does one useful thing and leans on `shared/` for the boring parts — logging, state, notifications, API clients. If two jobs need the same helper, the helper belongs in `shared/`, not copy-pasted. + +```python +from shared import logging, state, notifications +``` + +## 2. Params pass through the CLI + +Anything after `--` on the `vibeshed run` command is forwarded verbatim to `scripts/main.py`. Jobs parse their own args with `argparse` (or whatever they prefer) — there is no magic wrapper layer. + +```sh +vibeshed run daily-briefing -- --date 2026-04-19 --user ben +``` + +Document the params a job expects in `sequence.md`'s **Inputs** section so agents can invoke it correctly. + +## 3. Success or failure, nothing in between + +Scripts exit `0` on success and non-zero on failure. `vibeshed run` records the outcome in `logs//runs.json` along with duration, log path, and the last lines of output on failure. No "partial" states — if a step fails, the whole run fails. + +## 4. Errors have a timeout + +Every job has a `timeout_minutes` in its `config.yaml`. `vibeshed run` kills the process and records a FAILURE with `error: "timeout ..."` if it runs long. Pick a timeout that's roughly 2–3× your expected runtime so flakes fail loudly instead of hanging silently. + +```yaml +# jobs//config.yaml +timeout_minutes: 5 +``` + +--- + +Agents guiding a user through vibe-coding a new automation should enforce these four principles. If a proposed job doesn't fit them, that's usually a sign the job should be split, or a helper should move into `shared/`. + + + diff --git a/src/vibeshed/templates/job_template/scripts/main.py b/src/vibeshed/templates/job_template/scripts/main.py index 4fbc3e1..7a8adff 100644 --- a/src/vibeshed/templates/job_template/scripts/main.py +++ b/src/vibeshed/templates/job_template/scripts/main.py @@ -1,20 +1,30 @@ #!/usr/bin/env python3 """Entry point for {{JOB_SLUG}}. -This script is invoked by ``vibeshed run {{JOB_SLUG}}``. It also runs directly -with ``JOB_SLUG={{JOB_SLUG}} python jobs/{{JOB_SLUG}}/scripts/main.py``. +Invoked by ``vibeshed run {{JOB_SLUG}} -- ``; everything after ``--`` +is forwarded here as ``sys.argv``. Also runs directly with +``JOB_SLUG={{JOB_SLUG}} python jobs/{{JOB_SLUG}}/scripts/main.py ``. """ from __future__ import annotations +import argparse import sys from shared import logging -def main() -> int: +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="{{JOB_SLUG}}") + # TODO: declare the params this job expects. Mirror them in sequence.md's Inputs section. + # parser.add_argument("--example", required=True, help="Describe me.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: logger = logging.get_logger(__name__) - logger.info("Starting {{JOB_SLUG}}") + args = parse_args(sys.argv[1:] if argv is None else argv) + logger.info("Starting {{JOB_SLUG}} with args=%s", vars(args)) try: # TODO: implement the job's action here. diff --git a/src/vibeshed/templates/job_template/sequence.md b/src/vibeshed/templates/job_template/sequence.md index f3e5e77..2bfc838 100644 --- a/src/vibeshed/templates/job_template/sequence.md +++ b/src/vibeshed/templates/job_template/sequence.md @@ -2,15 +2,18 @@ [Briefly describe what this job does.] -## Trigger +## Inputs -[Cron / on-demand / event / hybrid. Be specific — e.g. "every weekday at 8:00 AM".] +- Params (passed after `--` on `vibeshed run {{JOB_SLUG}}`): + - `--example` (required) — [describe each param: type, purpose, example value]. +- Env vars: + - `EXAMPLE_API_KEY` — [describe; delete this block if none]. ## Action ### Step 1: [Description] Run: `scripts/main.py` -- Input: [None / stdin / args / env vars] +- Input: [params / stdin / env / files] - Output: [What it produces] [Add more steps as needed. Each step references one script.] @@ -25,4 +28,4 @@ Run: `scripts/main.py` ## Notes -[Edge cases, special considerations, gotchas.] +[Edge cases, special considerations, why the chosen `timeout_minutes` is right.] diff --git a/src/vibeshed/templates_loader.py b/src/vibeshed/templates_loader.py index d5ed7cb..f710851 100644 --- a/src/vibeshed/templates_loader.py +++ b/src/vibeshed/templates_loader.py @@ -16,6 +16,7 @@ MANAGED_FILES: Dict[str, str] = { "AGENTS.md": "marker", "CLAUDE.md": "marker", + "PRINCIPLES.md": "marker", ".gitignore": "marker", "requirements.txt": "marker", "shared/__init__.py": "full", diff --git a/tests/test_new_and_run.py b/tests/test_new_and_run.py index e8f229c..d19a1a7 100644 --- a/tests/test_new_and_run.py +++ b/tests/test_new_and_run.py @@ -72,3 +72,46 @@ def test_run_unknown_slug(initialized_project: Path, runner: CliRunner) -> None: result = runner.invoke(app, ["run", "nope"]) assert result.exit_code != 0 assert "not registered" in result.output.lower() + + +def test_run_forwards_passthrough_args( + initialized_project: Path, runner: CliRunner +) -> None: + runner.invoke(app, ["new", "echo-args"]) + main_script = initialized_project / "jobs" / "echo-args" / "scripts" / "main.py" + main_script.write_text( + "import sys\nprint('argv:' + '|'.join(sys.argv[1:]))\n", + encoding="utf-8", + ) + + result = runner.invoke( + app, ["run", "echo-args", "--", "--name", "ben", "--flag"] + ) + assert result.exit_code == 0, result.output + + log_files = list((initialized_project / "logs" / "echo-args").rglob("run_*.log")) + assert len(log_files) == 1 + assert "argv:--name|ben|--flag" in log_files[0].read_text() + + +def test_run_enforces_config_timeout( + initialized_project: Path, runner: CliRunner +) -> None: + runner.invoke(app, ["new", "slow-job"]) + config_path = initialized_project / "jobs" / "slow-job" / "config.yaml" + config_path.write_text("timeout_minutes: 0.02\n", encoding="utf-8") # 1.2s + main_script = initialized_project / "jobs" / "slow-job" / "scripts" / "main.py" + main_script.write_text( + "import time\nprint('starting')\ntime.sleep(10)\n", + encoding="utf-8", + ) + + result = runner.invoke(app, ["run", "slow-job"]) + assert result.exit_code != 0 + + runs = json.loads( + (initialized_project / "logs" / "slow-job" / "runs.json").read_text() + ) + entry = next(iter(runs["runs"].values())) + assert entry["status"] == "FAILURE" + assert "timeout" in (entry["error"] or "").lower()