Skip to content

Changelog

Versions follow the wheel on PyPI. Each entry lists what changed and, where a downgrade needs care, how to do it.

Changelog

Unreleased

1.13.0 (2026-09-13)

  • A flow disabled by disable_after now resumes after a restart. The resume at the end of the window was a timer held only in the server's memory, and start never read the paused_until it had stored beside it, so a server restarted inside the window left the flow's schedules paused until someone resumed them by hand. Start now derives the window from paused_until: one still open is armed again and ends on time, and one that ended while the server was down resumes as start completes, recording flow.enabled either way. Fires inside the window are still never caught up; fires after it, missed only because the server was down, follow the schedule's catchup policy.
  • Skip upcoming runs, and reschedule from the Flows page. Pausing was the only way to stop a scheduled run, and it dropped every upcoming run until someone remembered to resume. A fire can now be skipped on its own: from the Flows row menu (Skip next run, Skip runs…), the flow page's Skip next… and Upcoming tab, or POST /api/schedules/{id}/skips with fires or next. A skip is stored against the schedule and the fire time, so it survives a restart, a pause and resume, and an edit that keeps the time; catch-up never recreates a skipped fire, and the look-ahead keeps three runs that will start past the skipped ones. At its time the run ends Skipped with reason user without starting, and the runs of flows declared after= it, keyed fan-in included, are created Skipped with reason upstream; every other Skipped run still triggers its downstream as before. Undo, or DELETE /api/schedules/{id}/skips/{fire}, takes a skip back until its time. GET /api/flows/{id}/upcoming marks each run skipped, with skipped_by and skipped_at, and, with projected=N, also lists fires past the look-ahead; each schedule reports skipped, and its next_fire is the next fire that will run. Reschedule… edits a cron schedule as a daily, weekly or monthly time in a timezone, or as raw cron, with its catch-up options and the coming week before and after the change; skips an edit no longer produces are dropped and recorded as schedule.skips_dropped. run.skipped now carries reason.
  • Breaking: a schedule edit lasts until the next restart unless it asks to persist. PATCH /api/schedules/{id} used to mark the row persist, which detached a code-declared schedule from its declaration for good and said so only afterwards, in small grey text. It now leaves persist as it was unless the body sets it, so an edit from the flow page, the Reschedule dialog, or MCP's edit_schedule holds until the server restarts and the declaration applies again. Send persist: true for the old behaviour.
  • CI runs the documentation tests on Windows, so every suite now runs on Linux, macOS and Windows. The Python suite has run there since 1.5.0, once two things were fixed: a test liveness probe that killed the process it was asking about, and a stop path that hard-killed the server before it could shut down (the 1.5.0 graceful-stop entry). The documentation step was the last one conditional on the platform. No product behaviour changed.

1.12.0 (2026-09-10)

  • A rule that could never fire is now refused instead of stored. @app.rule(on="run.failure") registered cleanly, stored cleanly, and never fired — no error, no warning, no log line — because nothing on either path checked an event name. The names themselves lived in four places that drifted apart: literals across the server crate, a smaller set on the offline path, a hand-written reference table that claimed to mirror them, and a free-text box in the rule form. There is now one catalogue, in crates/core/src/events.rs, that the emit sites are compiled against, so renaming an event breaks the build. The engine owns the prefixes run., task_run., flow., schedule., resource., rule. and expectation.: a name under one of them that nothing emits is rejected at import by @app.rule, with 422 by the rules API, and by emit_event, each with the nearest real name — run.failure answers "did you mean run.failed?". Every name outside those prefixes is a custom event and is not checked, so on="orders.table_empty" still needs no registration. cereyan.events and cereyan.states expose the catalogue as str constants that are the wire names, so events.run.failed and "run.failed" are interchangeable and nothing downstream can tell them apart; events.run.any is "run.*". GET /api/vocabulary serves the same list, the rule form offers it as suggestions while still taking a typed custom name, and docs/reference/events.md is generated from it.

  • A rule naming a state type now matches that type's sub-states. states= was documented as "state types the run must be in" and matched the sub-state name, so states=["Scheduled"] matched no run that was Late, AwaitingRetry or AwaitingResource, and expressing "any scheduled run" meant listing all four. A value now matches the run's state type or its sub-state name: ["Scheduled"] covers the sub-states, ["Late"] still narrows to one. This widens what an existing rule matches — it can only match more, never less, since no type and sub-state name collide — so a rule written to exploit the old narrow reading fires more often. The guards (once, cooldown_seconds, max_per_minute) bound the effect.

  • @app.rule rejects an unrecognised keyword instead of dropping it, so cooldownseconds=60 no longer leaves a rule running on defaults nobody asked for. once="never", valid since rules shipped and documented nowhere, is on Events and rules and the rules guide. The Rules page marks a rule that has never fired, which is the one failure that name checking cannot catch: a rule spelled plausibly that matches nothing. CodeRule.extra_actions, which nothing set and no decorator accepted, is gone.

  • The schedule drift test no longer gates a release on a wall-clock ceiling. test_schedule_fires_on_time_with_low_drift asserted that a run starts within 250 ms of its scheduled time; a Windows runner measured 294 ms and failed the 1.11.0 build, which is the flake the repository already has a rule against — the correctness gate excludes wall-clock ceilings, because a bound calibrated on a developer machine says nothing about correctness on a shared runner. The 250 ms was also a loosened stand-in for a target that already has a home: benches/e2e.py measures schedule_drift_ms against the documented 50 ms, with a per-platform baseline and regression detection. The test is now test_schedule_fires_at_its_scheduled_time and asserts what does not depend on the hardware — the run is materialised at the anchor, it completes, and it does not fire early. No product behaviour changed.

1.11.0 (2026-09-09)

  • Documentation: @flow(group=...) shipped in 1.10.0 with a paragraph on App and projects and nothing a reader could copy. It now has a section of its own there with a snippet, and Flows and parameters lists it among what a flow declares, so the option is findable from the page a reader reaches for when choosing one. The README's screenshot captions describe the collapsible groups the recaptured Flows and Runs shots already show.

1.10.0 (2026-09-09)

  • Flows can declare a group, and the UI collapses by it. @flow(group="nightly") names the group a flow is listed under; without it a flow is grouped under its project, as before. Groups are a flat axis rather than a level inside the project, so flows in different projects declaring the same group form one group and a group named after a project merges with the flows defaulting to it — which is how a flow joins a group it does not live beside. A run carries its flow's group, read through the flow rather than stored on the run, so renaming a group moves the run history with it instead of cleaving it in two. Both the Flows and the Runs page now draw their groups as collapsible sections whose header is the same columns rolled up: the soonest next fire, the group's recent runs, its states as a bar with counts, the tag union, and the row count. Flows the running server no longer has registered are counted separately as stale, because a group whose flows all last succeeded but have since deregistered would otherwise read as healthy behind a folded header. A group of more than five rows starts collapsed, a lone group is always open, a search opens every group it matches, and the default is fixed when a group is first seen so a live update never shuts one under the cursor.

  • An engine still reporting a run it has just finished keeps retrying for up to ten minutes when its server disappears, so a restarted server records the outcome instead of seeing a crash. That is deliberate, and it is now on Engines and the home directory, where the thirty to forty second figure for an idle engine had been the only number given. Three CI failures came from tests that did not know it: wait_run returns when the server records the terminal state, while the engine is still flushing its last events and log lines. Two shutdown tests killed the server inside that window and measured the ten minute path; the retention test rewrote the timestamps of log rows the engine was still adding to, so a line landing afterwards carried a fresh one and was rightly never swept. ServerProcess.wait_idle gives all three the wait they were missing.

1.9.2 (2026-09-09)

  • A clock-armed proactive rule no longer cries wolf on its first tick. unless with at fires when an expected event did not arrive in the look-back window, and an early tick's window reaches back past the rule's own creation — where the store is empty because nothing had happened yet, not because anything was missed. A rule with within=3 fired 0.77 seconds after the server started, and one saved from the UI could page someone about the hour before it existed. A tick now evaluates only once the rule has been watching for a whole window; the schedule keeps running meanwhile, so the first honest lapse is delayed by at most within. This was recorded in WINDOWS.md as an unexplained Windows-only flake; it is neither. Windows lost the coin flip more often because a slower start delays the first event, and CI caught it on Linux at 1.9.1. The xfail marker that blamed Windows is gone, and test_clock_armed_rule_waits_until_it_has_watched_a_whole_window pins the behaviour.

1.9.1 (2026-09-09)

  • The engine give-up test no longer gates a release on a wall-clock ceiling. test_idle_engine_gives_up_when_the_server_is_killed asserted that a SIGKILLed server's engine exits within 60 seconds, against a bound of 40; on a loaded macOS runner the same commit that passed on main failed on the tag, which is the flake the repository already has a rule against. It now asserts only that the engine gives up, and the 40 second bound moved to a performance test that just bench runs on calibrated hardware, where a number like that means something. No product behaviour changed.

1.9.0 (2026-09-09)

  • Documentation: features that shipped without a page. The MCP guide still described fifteen tools and named none of the four schedule tools added in 1.8.0. cereyan.runtime, the public view of the run in progress (run, task_run, flow), was exported from the package and mentioned nowhere; it now has a section on the Python API page and a pointer from Runs and states. The events reference listed five of the fifteen kinds the SSE stream carries and the OpenAPI description behind GET /api/stream listed four; both now list all of them with their payloads. The tour still said the run page's Details tab shows an exit code, which was removed in 1.4.0, and did not mention the task run page.
  • Documentation: the three Windows behaviours tracked in WINDOWS.md are now stated where a reader meets them rather than only in a file that ships with the repository. A flow's timeout_seconds does nothing on the offline path there, so Retry, time out and survive crashes says so; a cancel cannot interrupt a flow blocked in a call, so Engines and the home directory says so; a clock-armed proactive rule can lapse while its events are still arriving, so Detect when something did not happen says so. Install carries the three as a table and Design and limitations names them.

1.8.0 (2026-09-09)

  • An agent can manage schedules. pause_schedule and resume_schedule both took a schedule_id that no MCP tool returned, so reaching them meant already knowing the id: a schedule that had never fired was invisible while the tool to pause it sat in the list. list_schedules closes that, filtered by flow or project, reporting each schedule's spec, whether it is active, when it next fires, and whether it came from code, the interface or an agent. create_schedule, edit_schedule and delete_schedule complete the lifecycle; create and edit return the next few fire times so a spec can be checked before it fires unattended, and schedules an agent creates record source: "mcp". Two guards: editing a schedule declared in a flow's code returns a note that the declaration no longer governs it, and deleting one is refused, because startup recreates it from the declaration and a success would be untrue. Flows still run on demand by default; a flow needs no schedule.

1.7.0 (2026-09-09)

  • Cereyan is production ready. The Python API, the HTTP API, the CLI, the MCP surface and the database schema are settled; the under-development warning is gone from the README and the documentation, and the PyPI classifier moves from Beta to Production/Stable. The README also said the package was not yet published, which stopped being true at 1.4.0.

  • Benchmarks for the write path a served task run actually travels. transition on both tables and apply_report over the event sequence an engine child sends had no benchmark at all, so a change to the store showed up only once it was large enough to move an end-to-end number that also contains subprocess spawn, HTTP and the client's flush interval. cargo bench -p cereyan-store --bench hot_path now measures both directly, at three batch widths so per-batch cost that grows faster than the batch is visible as a shape. A second bench sizes the stream payload built for every transition. just bench gains task_run_cost_us, the orchestration cost of one completed task run, with a 200 µs target — the unit a fan-out workload is actually counted in.

  • The performance baseline is refreshed. It was last written at 1.3.0 and three of its numbers had drifted, schedule_drift_ms past the 20 percent gate. No code explains it: every file on the ingestion and dispatch paths — writer.rs, read.rs, dispatch.rs, api/engine.rs, index.rs, scheduler.rs, timer.rs, and the core state rules — is byte-identical to that commit, and Cargo.lock differs only in the version string. The repository pins no Rust toolchain, so a baseline does not survive a compiler upgrade; the numbers here were recorded on rustc 1.92.0. Every target is still met with room to spare.

1.6.1 (2026-09-09)

  • Internal refactoring.

1.6.0 (2026-09-09)

  • @flow and @task reject async def. An async body was never awaited: the call returned a coroutine, the body never ran, and the run was recorded Completed — a pipeline that fetched nothing and reported success, its only trace a RuntimeWarning on stderr after the fact. Both decorators now raise TypeError at decoration, naming the function and showing the synchronous wrapper. async def with yield is rejected too: inspect.iscoroutinefunction is false for an async generator function, which failed the same way. Async bodies remain unsupported; async def route handlers are unaffected. Anything this breaks was already reporting success without running.
  • A guide for fetching from an HTTP API: building the client once so a warm engine reuses its connection pool, map over a ThreadRunner for concurrency, and where the reuse stops (isolated=True, ProcessRunner, offline runs). That an engine imports its module once, and so shares module-level state across the runs it serves, is now stated on Engines and the home directory and covered by a test, rather than being an undocumented accident of the implementation.

1.5.0 (2026-09-09)

  • The runtime home is readable only by the account that created it. It was created with whatever the umask gave — 0755 on a typical machine — so db.sqlite, with every run, log, event and variable, was readable by any other account, and secret.key was too: it was narrowed to 0600 after being written, leaving a window in which the key that decrypts every secret was world-readable, and that narrowing never ran on Windows at all. Protecting the directory covers everything in it, closes the window, and needs nothing platform-specific for a home under your user profile. A home from an earlier version is narrowed when opened, with a message saying so.
  • An engine whose server disappeared could take up to ninety seconds to exit rather than the thirty its documentation promised. It notices only between requests, so the wait was bounded by the client's long-poll timeout — ninety seconds — and not by the thirty second idle threshold that appeared to govern it. The timeout is now forty seconds, ten more than the server ever holds a request, so the worst case is forty rather than ninety. The regression test used to wait exactly ninety seconds and so raced the very timeout that defeated the bound; it now waits sixty against a forty second bound.
  • Windows: stopping the server no longer kills the engine executing a run. Engine children were spawned in their own process group on Unix — so a Ctrl-C or a stop aimed at the server never reached them — but there was no Windows equivalent, so a console control event swept up every engine, including the one mid-run that a restarted server is supposed to adopt. A run in flight when the server stopped was lost rather than resumed. Engines now get their own group on both platforms.
  • server.json records a URL clients can actually use. It carried the address the listener bound, so starting the server on every interface wrote http://0.0.0.0:<port> — a bind address, not a destination. Linux and macOS route that to loopback, so it worked by accident; Windows refuses it, and nothing that reads the discovery file could find the server there. url now names loopback when the bind address is unspecified, and host still records what was bound.
  • Windows: cereyan serve can be stopped gracefully. It handled SIGTERM, which Windows never delivers, so anything stopping the server other than an interactive Ctrl-C — a service manager, a script, a supervisor — ended the process before it could shut down: the discovery file was left behind, pending writes were not flushed, the WAL was not checkpointed, and engine processes were orphaned. A console control event arrives as SIGBREAK on Windows and is now handled the same way SIGTERM is on Unix. Unix behaviour is unchanged. Found by running the Python test suite on Windows for the first time.

1.4.0 (2026-09-08)

  • The Intel macOS wheel is cross-built from the arm64 runner. GitHub retired the macos-13 label, so that build queued forever and, because the smoke test and the PyPI upload wait for every wheel, no release could complete at all.
  • cargo bench runs again. Criterion's flags were being handed to the auto libtest harness of every lib target, which rejects them, so the benchmarks stopped at the first target reached — through just bench as much as in CI. Each crate's lib now sets bench = false.
  • Wall-clock ceilings moved out of the test suite that gates a release. Two Rust timing tests are #[ignore] and the Python performance marker is deselected there; both run in just bench and in CI's benchmark job, which is where the per-platform baselines live. A ceiling calibrated on a developer machine failing on a shared CI runner said nothing about correctness. benches/e2e.py --no-ceilings reports an absolute miss instead of failing, while a regression against a baseline still fails.
  • Windows: cereyan-server now compiles. The Unix socket listener's serve path was never excluded on platforms without Unix sockets, so the crate failed to build and the Windows wheel and test job have been broken since the socket landed in 1.1. Windows had no working wheel for 1.1, 1.2, or 1.3 despite being listed as a supported platform.
  • Engines now end with the server. A graceful stop answers every waiting engine with an instruction to exit and signals any that were idle between requests, so cereyan serve leaves no engine child behind; an engine executing a run is deliberately left alone, so a restarted server still adopts its run. An engine that loses its server without being told to stop — a kill, a crash — exits by itself after thirty seconds of failing to reach it. This reverses a documented guarantee: the server previously exited without terminating any engine process, so anything that relied on the warm pool outliving a graceful stop or restart now sees a cold pool instead.
  • Removed exit_code from runs. The field was in the Run model, the OpenAPI and MCP surfaces, and the run page, but nothing ever wrote it, so it was always null; the store's set_run_exit write path had no callers. Clients reading run.exit_code should drop it. The SQLite column stays, unread, because migrations are append-only.
  • Packaging: the wheel and sdist now carry LICENSE and NOTICE, project URLs, keywords, an author, and a fuller classifier set, so the PyPI page renders its links, licence, and images. Tagging a release now publishes to PyPI from CI through trusted publishing, which the release checklist already promised.

  • UI redesign: a top bar replaces the sidebar and breadcrumb bar, with the eight sections as tabs, a project switcher that scopes every list, a ⌘K palette that jumps to sections, flows, runs, and artifacts, and a warm neutral palette in light and dark with Geist bundled. Controls are shadcn components. The dashboard leads with a Needs attention list (paused, failed, crashed, and late runs with inline actions), Running now with task progress, and the histogram with an axis; the runs page has popover filters, a task-state bar per run, and a floating selection bar; the run page is a workbench with a tasks rail that filters the logs to one task run and shows retry countdowns; the flows page groups by project with the schedule in words, a run-history sparkline, and a Dependencies panel of after= chains and fan-in groups.

  • API: run list items and GET /api/runs/{id} carry task_counts by task state; recent_runs entries on flows gain the run's duration; AwaitingRetry details record retries.

  • Documentation authoring guide: docs/AGENTS.md now exists, covering the layout, the generated pages and their generators, the page kinds and section budgets, the vocabulary, the style rules, the tested-block markers and fixtures, the redirect rule, and the commands. just lint fails when it is missing or stops naming a generated page, which is what let the documentation-restructure entry below promise a file that was never written.

  • The MCP reference is generated: scripts/gen_mcp_reference.py renders docs/reference/mcp.md from tests/mcp_snapshot.json, a snapshot of the server's own handshake, tools, resource templates, prompts, and response keys, checked in just lint like the CLI and HTTP references. The page now carries the protocol version, the instructions the server gives the model, each argument's type, default, and range, the keys every tool returns (including next_cursor and verdict), the fields of the lists they hold, and the prompt text.
  • Documentation: cereyan mcp --token never worked; --token is global and goes before the subcommand. The agent guide says so, and calls the two resources templates, which is what they are.

  • Overlap soak (just soak, benches/soak_overlap.py): fifteen scheduled flows across enqueue, skip, and cancel_new run for an hour against a real server and are checked against nine overlap invariants; --quick for twelve minutes, --keep to browse the UI afterwards, manual workflow_dispatch job in CI.

  • Documentation restructured into Get started, Concepts, Guides, Reference, Examples, and Design on a Material for MkDocs site with llms.txt output; every Python block in the docs and every file under examples/ is executed by the test suite; the CLI and HTTP API references are generated from the parser and the OpenAPI snapshot; a docstring check covers the public API; docs/AGENTS.md and an OpenSpec rule keep future changes documented. Old page URLs redirect.
  • Docstrings for every public class, function, and method, and help text for every CLI option (cereyan <command> --help). No behaviour changes.
  • Seven literate examples under examples/: quickstart pipeline, daily ETL, fan-in, approval, alerting, webhook route, agent diagnosis.

1.3.0 (2026-09-06)

  • Fan-in dependencies: @flow(after=["a", "b"], batch_key="day") runs the downstream once per key value after every upstream completed it; flow.fan_in events, upstream lists in the flow summary and graph.

1.2.0 (2026-09-06)

  • Built-in MCP server: POST /mcp (Streamable HTTP, JSON replies) and cereyan mcp (stdio proxy) with fifteen curated tools, two resources, and a diagnose_run prompt; runs started by agents record created_by = mcp:<client>.
  • Human-in-the-loop: wait_for_input(prompt, schema=None) pauses a run, POST /api/runs/{id}/resume answers it, the run page shows the question with a form; the engine and resources are released while a run waits. run.paused and run.resumed events.
  • Paused is now entered only from Running.

1.1.0 (2026-09-06)

  • Proactive rules: unless with within (event-armed) or at cron with tz (clock-armed) fires when an expected event does not happen; lapses are expectation.lapsed events, expectations survive restarts, GET /api/rules/{id}/expectations, and an "Unless" section in the rule form.
  • API token: --token, CEREYAN_TOKEN, app.serve(token=), or [server] token protects every API route except health; the UI prompts for it, clients and engines send it, and server.json records auth.
  • Unix socket listener (--socket, CEREYAN_SOCKET, [server] socket) trusted by file permission, recorded in server.json, and used by the Python client.
  • Async custom route handlers on one shared event loop.
  • Artifacts page and GET /api/artifacts with filters, keyset pagination, and per-key history.
  • Negative priority lowers engine niceness on Unix.
  • Downgrading to 1.0.x: delete rules that use unless first; 1.0 does not load them.

1.0.0 (2026-09-06)

First release. One wheel, no runtime dependencies, Python 3.11 or newer.

  • Flows and tasks with parameters from type hints, offline execution into a local SQLite store, and the cereyan run and cereyan runs ls commands.
  • cereyan serve: HTTP API with an OpenAPI document, server-sent events, a warm pool of engine processes that survive server restarts, custom routes, and the embedded React UI.
  • One runtime home per machine (--home, CEREYAN_HOME, ~/.cereyan); flows identified by project and name; cross-project handoff from scripts to a running server.
  • Schedules (cron, interval, RRule) with timezones and catch-up policies; retries, timeouts, hooks, and crash chains; Targets with atomic writes; input and source caching; backfills; resources, priority, overlap policies, and disable windows; concurrent tasks with futures, map, thread and process runners; single-upstream flow dependencies; timeline graph.
  • Events, rules with seven actions and templating, artifacts, variables with encrypted secrets, settings and retention, benchmarks against the performance targets, docs, and the release wheel matrix.
  • Performance: engine reports are chunked and accepted up to 64 MB, task-run events are appended in one batch per report, and opening a large database no longer reads the whole file when the previous shutdown was clean.