Skip to content

v0.33.0

Choose a tag to compare

@mortenoh mortenoh released this 03 Nov 14:49
· 4 commits to main since this release
v0.33.0
c7083f3

Follows dirigent 0.33.0; nothing in the pack changed.

Requires dirigent 0.33.0.

The dirigent 0.33.0 notes follow.

Released 2026-11-03. Every package in the workspace moves to 0.33.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.

Before upgrading. The baseline migration is edited in place, so dg db upgrade does not
carry this release's schema: recreate the state (dg dev --wipe-state, or a fresh database), or
make the changes by hand. The new table is audit_entries. connections gains awaiting_secrets
(a boolean, false by default); notifications gains rule_code, and its alert_rule_id foreign
key becomes nullable with ON DELETE SET NULL; sources gains failed, sources.failures is
renamed poll_failures, and input_outcome gains failed; runs gains the index
ix_runs_created_at. Four changes break a document or a caller, each marked Breaking below: a
list or object inside text renders as JSON; a block's limits are refused at plan; a source's
counters are renamed and recounted; cancelling a settled run is refused. A dg of another version
run inside a project that pins dirigent now refuses to run: use uv run dg, or
uv tool upgrade dirigent-cli for the global one.

dg dev and dg server draw one startup banner at a terminal. The ready record carries the
version, the addresses (UI, API, docs, /mcp), on dg dev the sign-in and the state path, the
dev log file, and every pack loaded with its version and how many blocks, connection kinds and
source kinds it contributes; at a terminal the console formatter draws it as one banner under the
mark. In a pipe, with --json or DIRIGENT_LOG_FORMAT, the records are written as they are and
nothing else. dg server now writes starting and ready too.

Ctrl-C stops dg dev within a bound, a second Ctrl-C at once. dg dev handles SIGINT and
SIGTERM itself: the first drains the worker and stops the API, closing connections still open once
the worker has drained, and gives up after 10 seconds, naming what it cancelled; a second exits at
once. Program runners such as jq's start outside the terminal's process group, so a Ctrl-C no
longer reaches them or prints their traceback, and dg dev stops them on its way out. It hung
before whenever a stream was open, a --watch or an MCP session.

dg guards a project against another dg, and asks for a kind's credential. Inside a project
whose pyproject.toml pins dirigent-cli, a dg the pin does not allow refuses with
cli.version_mismatch, naming both versions; DG_ALLOW_VERSION_MISMATCH=1 runs it anyway. A
connection kind declares its credential_forms and whether one is credential_required, and
dg connection create asks which form to use and for each of its fields at a terminal; outside one,
a required credential left out is refused with the exact --set fields of each form. dg init's
README is a guide from the scaffold to a running instance, a connection, a first pipeline and an
agent over MCP, for each template. dg profile list and dg profile show say which profiles exist,
which is selected and where each token comes from, never the token; DG_PROFILE is documented.

An alert names its failing step and why. The log channel writes one structured line per alert
-- event_kind, run_id, pipeline, status, failing_step (and item), a one-line error
and its error_code, trigger, duration_ms, url -- where it wrote the text body into one
field. A default body opens with the failing step and its error and writes params as JSON, and a
custom body template reads failing_step, failing_item, error and error_code.

The run graph zooms on a plain wheel, and the account row is a menu. On the run's screen, whose
graph fills the room above the terminal, a plain wheel zooms about the pointer; the pipeline's page
keeps a plain wheel for the page and Ctrl or Cmd with the wheel for the graph. The rail's account
row is a round avatar, the name and a chevron, and its menu carries Settings, Keyboard shortcuts,
Appearance (Light, Dark, System) and Sign out; the top strip's mode flip is gone.

An agent or an operator sets up a connection, and an admin gives it its secrets. The MCP server's create_connection (offered to an operator and an admin, never a viewer) creates a connection with its settings alone. It sends awaiting_secrets: true on POST /connections, and the API then refuses any field the kind declares secret by name and never by value (server.secrets_withheld), still refuses every other setting the kind's model refuses, and stores the settings alone. An operator may now POST /connections too, without a secret: one carrying a secret value is refused by field name (server.secrets_need_admin, 403), a connection that cannot work without a secret (its model refuses the settings alone, or its kind's credential_required names a form none of which is complete) is stored awaiting it, and any other, such as http without auth, is stored complete and usable. Supplying a secret, updating, deleting and checking a connection stay an admin's. Every read carries awaiting_secrets; a step, a source, an alert and $check refuse such a connection (secret.connection_awaiting_secrets) until an update whose config the model accepts whole completes it. The tool answers the secret fields to supply and the command that does it: the new dg connection set-secret CODE [FIELD]..., which asks for the kind's credential form as dg connection create does and prompts for each secret without echoing it, or the connection's dialog, where the Connections screen draws it as awaiting secrets. dirigent-client's connections.create takes awaiting_secrets.

An agent over MCP reaches what it had to ask a person for. get_run_report reads the markdown report document a run rendered, and whether it rendered one, as dg runs report RUN --markdown does. get_run_profile is dg runs profile: the critical path and its queued, running and waiting time. list_schedule_firings is dg schedule firings, and list_alert_deliveries is dg alerts queue, narrowed to a run or a status. Alert rules are written with create_alert_rule, update_alert_rule, pause_alert_rule, resume_alert_rule and delete_alert_rule, which an operator is offered, and named schemas with create_schema, update_schema and delete_schema, which only an admin is, as the API allows. dirigent-client's alerts.notifications takes status, and the run-timing model dg runs profile computes moved from dirigent_cli.timing to dirigent_client.timing.

edit_pipeline changes a pipeline without sending it whole. It takes the current version's text exactly as export_pipeline answers it, makes each {old, new} replacement in turn, each old occurring exactly once, and plans the result, applying it unless plan_only. An edit that misses, or matches more than once, refuses every edit, names each one, and stores nothing.

Every MCP answer is bounded. An answer is held under 48 KB of JSON: a listing too large keeps the rows that fit, drops its next and says which limit asks for exactly those, and anything else is cut to the start of its JSON with a note saying how to ask for less. Listings answer 20 rows by default, not 50. list_examples lists each example's code, name, shelf, tags and blocks, with summary=false for the whole row; get_example, export_pipeline and get_run_report answer a long text a stretch at a time from offset, with next_offset, and export_pipeline now answers an object carrying the text rather than the bare text. run_attempts shows each output as output_bytes and an output_preview unless include_output, and get_attempt_output reads one, inline or from storage, or with path the part a JSON pointer names.

Alert rules stay out of pipeline documents. A rule's code is the instance's rather than a pipeline's, a global rule watches every pipeline, and a rule is removed by a person rather than by a document that stopped declaring it.

Runs grouped by pipeline. The Runs screen has a View switch between the list and By pipeline: one row per pipeline that ran in the last hour, 24 hours or 7 days, with the newest run's state, a count per state it has runs in, its 30 most recent runs as dots oldest first, each a link to its run, when it last ran, and the median duration of the settled ones. A row opens the list of that pipeline's runs over the same window. The view and window are in the address, /runs?group=pipeline&window=24h; the pipeline, tag and status filters carry over, the status asking for each pipeline's newest run. The same answer is GET /runs/$summary?since=24h (RunSummary, with latest=<state> and tag), dg.runs.summary(), dg runs summary (runs.summary records), and the runs_by_pipeline MCP tool.

A refused config says what is wrong, in our words. A step whose resolved config its block refuses fails with run.config_invalid: one short sentence, `delay` must be at most 30s, with every problem in error_params.problems[] (code, message, location, params), and the run's screen lists them by field. A literal config is refused the same way at apply, at steps.<step>.config.<field>. Every validation message reads in our own words under the same validation.* codes (must be at most 30s, is required, must be a valid integer), with a duration or size limit written as a document writes one.

How a run was started is said once. The runs list, the pipeline page's Recent runs, the run's header and its Trigger section name a person or a token by the label alone (agent (token mcp-trial), not api token · agent (token mcp-trial)), a schedule, backfill or source as its kind and code, and a rerun as rerun of <run> by <who>. The run dialog says runs as admin.

A report template reads the run's parameters. {{ params.period }} reads the parameters the
run resolved to, defaults filled in, beside run.params, in a report: template and an alert's
subject and body alike.

A list or an object inside text is JSON, not a Python repr. Breaking. A ${...} inside a
longer string interpolates a list or an object as its compact JSON ([{"value":"42"}], never
[{'value': '42'}]), a boolean as true or false and null as nothing; a reference standing
alone keeps its type as before. In a report, an alert and report.render, {{ value }} prints a
list or an object as compact JSON, a boolean as true/false and null as nothing, where it
printed Python's True and None; | tojson(indent=2) indents. An alert's default body writes
params as JSON too.

A block's limits are refused at plan, not at the run. Breaking. A bound on a Duration or a
Size field -- playground.generate's delay of at most 30s, its payload of at most 1mb, a
timeout that must be more than 0s, docker.run's memory of at least 6mb -- is published in
the block's schema as x-dirigent-bounds and enforced by every check holding the catalog, dg validate offline and MCP's plan_document included, where JSON Schema had no keyword for it and
it was dropped. A literal config is also validated against the block's own model at apply, so a
field or model validator refuses there; an error on a value that is still a ${...} is left to
the run. playground.generate and playground.arrive refuse an unknown locale, a provider Faker
does not have and a size argument past its cap at apply. docs/blocks.md says each bound.

Source kinds are in the document schema. source.kind is a choice between the installed kinds
-- endpoint, storage, sensor, and kafka and rabbitmq with dirigent-block-queues, or any
a pack contributes -- and each kind's fields are held to that kind, so dg pipeline schema, the
instance's document schema and an editor complete and check them. MCP's list_blocks answers
with sources beside blocks, each with its fields.

An example reads a failed step's error in a reply. examples/sources/reply-with-the-error.yaml
answers 201 when a check passes and 422 with ${steps.check.error.code} and .message from a
one_failed reply when it refuses.

A source's counts say what they count, and every call is journaled. Breaking. accepted
counts runs started whose caller was not answered with the run's failure; the new failed counts
runs whose held caller's answer was the run's failure (422 or 502), and their journal row ends
failed rather than replied; refused counts every call refused before a run, a call with no
token or a bad signature, one past the source's rate, and a stalled or oversized body before
admission included. Those were not journaled at all; they now are, without a header or a body,
60 a minute per source at most, so a flood from nobody cannot fill the journal. failures on a
source is renamed poll_failures, since it counts failed polls. The Triggers panel shows n failed beside accepted and refused.

Cancelling a settled run is refused. Breaking. POST /runs/{id}/$cancel on a run that already
settled answers 409 run.already_settled with the run's status in the params, rather than the
run as though the cancel had done something. dg.runs.cancel() raises it, dg runs cancel writes
it as an error record and exits 1, and MCP's cancel_run fails with the sentence and
[run.already_settled]. Every MCP tool error now ends with its code in brackets.

A step's text can be the run's report. report: {from: <step>} makes the text a step handed
on -- a report.render step's, or any block output with a text string -- the run's report
document, with no template; a section with both is refused, and plan refuses a from naming no
step, a source or a reply, or a block whose output has no text. Plan and dg validate warn
about a report.render step in a document with no report:, and about a name a report or
report.render template reads that its context does not have, such as step.sumary or a
params.x the schema does not list; rendering stays lenient. A run with no report document
whose report.render step rendered text offers that text on its Report tab. report.render's
summary says it does not set the run's report.

Who changed what is recorded. Every administrative change -- a pipeline applied, activated, deactivated or deleted, a triggers document, a schedule, a source paused or its token rotated, an alert rule, a connection, a schema, an account, an API token created or revoked -- is a row in the new audit_entries table, written in the change's own transaction: who acted (the account, and the token it acted through, so an agent's change reads agent (token mcp-trial)), the action, the subject's kind and code, and for an update the names of the fields it changed, never a value. Admins read it with GET /audit, dirigent-client's audit.list, dg audit, the MCP list_audit tool, and the Audit screen under Admin. It is kept for retention_audit, 365d unless set, and dg prune --audit prunes it by hand. The same change is one INFO line on the process log, which now includes a token's revocation and a connection's create, update and delete. See the audit trail.

Deleting an alert rule keeps what it sent. A delivery's rule was a cascade, so deleting a rule deleted every notification it had raised, while its docstrings said they were kept. Now notifications.alert_rule_id is set to null when the rule goes, and the new notifications.rule_code, written when a delivery is queued, keeps naming the rule in GET /notifications, dg alerts queue and the MCP list_alert_deliveries.

dg dev says what happened. It logs at INFO by default: the audited changes, each run started and settled with its outcome and duration_ms, and the engine's other INFO events, without the per-request log, which -v adds with the step-level detail. A log_level in the project's settings is respected. It also writes its log as NDJSON to .dirigent/state/dev.log, whatever the terminal, rotated at dev_log_max_size (10MB) with dev_log_backups (3) kept; the starting record names the file. dg server still logs to stdout only.

An attempt id leads to its run. Every engine and worker line about an attempt carries run_id, pipeline and step, and item for a fan-out element. GET /attempts/{id}, dirigent-client's attempts.get, dg runs attempt ID, the MCP get_attempt tool and the UI's search take an attempt id alone and answer with the run, pipeline and step it belongs to. A run a person cancelled or paused stops its attempts with an INFO line saying so, and lease lost at WARNING is kept for a lease the sweeper took.