dirigent-core 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. dirigent_core/__init__.py +26 -0
  2. dirigent_core/alembic/env.py +71 -0
  3. dirigent_core/alembic/script.py.mako +26 -0
  4. dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
  5. dirigent_core/alerting.py +805 -0
  6. dirigent_core/artifacts.py +73 -0
  7. dirigent_core/auth.py +529 -0
  8. dirigent_core/blockdocs.py +223 -0
  9. dirigent_core/config.py +421 -0
  10. dirigent_core/configdocs.py +134 -0
  11. dirigent_core/database.py +177 -0
  12. dirigent_core/directory.py +339 -0
  13. dirigent_core/documents.py +878 -0
  14. dirigent_core/documentschema.py +92 -0
  15. dirigent_core/engine/__init__.py +120 -0
  16. dirigent_core/engine/claim.py +156 -0
  17. dirigent_core/engine/context.py +420 -0
  18. dirigent_core/engine/definition.py +543 -0
  19. dirigent_core/engine/executor.py +1029 -0
  20. dirigent_core/engine/failure.py +71 -0
  21. dirigent_core/engine/recovery.py +137 -0
  22. dirigent_core/engine/references.py +239 -0
  23. dirigent_core/engine/runs.py +865 -0
  24. dirigent_core/engine/services.py +74 -0
  25. dirigent_core/engine/state.py +434 -0
  26. dirigent_core/ids.py +39 -0
  27. dirigent_core/logging.py +310 -0
  28. dirigent_core/migrations.py +98 -0
  29. dirigent_core/models.py +626 -0
  30. dirigent_core/pipelines.py +494 -0
  31. dirigent_core/plugins.py +255 -0
  32. dirigent_core/protocol.py +276 -0
  33. dirigent_core/py.typed +0 -0
  34. dirigent_core/ratelimit.py +42 -0
  35. dirigent_core/registry.py +32 -0
  36. dirigent_core/retention.py +293 -0
  37. dirigent_core/scheduler.py +491 -0
  38. dirigent_core/schemas.py +147 -0
  39. dirigent_core/secrets.py +164 -0
  40. dirigent_core/storage.py +349 -0
  41. dirigent_core/telemetry.py +402 -0
  42. dirigent_core/trigger_documents.py +193 -0
  43. dirigent_core/triggers/__init__.py +117 -0
  44. dirigent_core/triggers/backfill.py +131 -0
  45. dirigent_core/triggers/materialize.py +218 -0
  46. dirigent_core/triggers/schedules.py +531 -0
  47. dirigent_core/triggers/webhooks.py +586 -0
  48. dirigent_core/types.py +59 -0
  49. dirigent_core/worker.py +350 -0
  50. dirigent_core-0.9.0.dist-info/METADATA +29 -0
  51. dirigent_core-0.9.0.dist-info/RECORD +53 -0
  52. dirigent_core-0.9.0.dist-info/WHEEL +4 -0
  53. dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,223 @@
1
+ """Rendering the block catalog as the documentation page for it.
2
+
3
+ The rendered page is checked in, and a test regenerates it and fails when the file and the
4
+ catalog disagree.
5
+ """
6
+
7
+ import json
8
+ from typing import Final, cast
9
+
10
+ from dirigent_client.schemas import BlockEntry, BlockKind, Catalog, SurfaceEntry
11
+ from dirigent_common import JsonMap
12
+ from dirigent_core.configdocs import first_paragraph, one_line
13
+
14
+ #: Relative to the repository root.
15
+ PAGE_PATH: Final = "docs/blocks.md"
16
+
17
+ BANNER: Final = "<!-- Generated from the installed block catalog by dirigent_core.blockdocs. Do not edit. -->"
18
+
19
+ PREAMBLE: Final = """# Block reference
20
+
21
+ Every block an instance has, with the config it takes and the output it produces. This page is
22
+ generated from the live catalog -- the same one `GET /blocks` serves, the same one the UI
23
+ builds forms from -- so it cannot drift from the code: a field added to a block either shows
24
+ up here or fails the build.
25
+
26
+ What a block is, and the contract behind these tables, is in
27
+ [the design document](design.md#2-the-five-plugin-surfaces). The short version:
28
+
29
+ - An **operator** does work. It either finishes and returns its output, or hands back a
30
+ handle for the engine to probe, which is how a step waits for an hour without holding a
31
+ worker for an hour.
32
+ - A **sensor** waits for the world. Each poke is one short, read-only observation, and "not
33
+ yet" is the expected answer rather than a failure. `poll`, `deadline`, and `on_timeout` are
34
+ step-level engine semantics, uniform across every sensor and never buried in a block's own
35
+ config.
36
+
37
+ **Group** is the shelf a block declares for itself, and what every catalog is arranged by: the
38
+ four transform verbs are one `transform` group, and `shell.run`, `docker.run` and
39
+ `pipeline.run` are `execute`. A block that declares none takes the first half of its id.
40
+
41
+ Two properties are worth reading before a block is used:
42
+
43
+ - **Idempotent** says whether re-running the block after an unclear failure is safe. The
44
+ engine spends the step's retry budget either way, so this is what to read before writing
45
+ `max_attempts` above 1.
46
+ - **Local execution** says the block runs code on the worker. The engine refuses such a block
47
+ unless the instance names it in `DIRIGENT_ENABLED_UNSAFE_BLOCKS`, because "can edit
48
+ pipelines" must never quietly mean "can run code on workers".
49
+ """
50
+
51
+ SURFACES_PREAMBLE: Final = """## Other surfaces
52
+
53
+ Blocks are two of the five surfaces a plugin contributes to. The other three are listed here
54
+ by id, since a document references them by code and never by id of any other kind.
55
+ """
56
+
57
+ NO_DESCRIPTION: Final = "--"
58
+
59
+
60
+ def render(catalog: Catalog) -> str:
61
+ """Render the whole catalog as the markdown page, deterministically."""
62
+ parts = [BANNER, "", PREAMBLE]
63
+ operators = [entry for entry in catalog.blocks if entry.kind is BlockKind.OPERATOR]
64
+ sensors = [entry for entry in catalog.blocks if entry.kind is BlockKind.SENSOR]
65
+ parts.append(_index(catalog.blocks))
66
+ parts.append("## Operators\n")
67
+ parts.extend(_block(entry) for entry in operators)
68
+ parts.append("## Sensors\n")
69
+ parts.extend(_block(entry) for entry in sensors)
70
+ parts.append(SURFACES_PREAMBLE)
71
+ parts.append(_surfaces("Storage schemes", "scheme", catalog.storage_schemes))
72
+ parts.append(_surfaces("Notifiers", "id", catalog.notifiers))
73
+ parts.append(_surfaces("Connection kinds", "id", catalog.connection_kinds))
74
+ return "\n".join(parts).rstrip() + "\n"
75
+
76
+
77
+ def _index(blocks: list[BlockEntry]) -> str:
78
+ """Render the table of every block."""
79
+ rows = [
80
+ f"| [`{entry.id}`](#{_anchor(entry.id)}) | {entry.kind.value} | {entry.group} "
81
+ f"| {entry.summary} | `{entry.plugin}` |"
82
+ for entry in blocks
83
+ ]
84
+ header = ["| Block | Kind | Group | Summary | Contributed by |", "| --- | --- | --- | --- | --- |"]
85
+ return "\n".join([*header, *rows, ""])
86
+
87
+
88
+ def _block(entry: BlockEntry) -> str:
89
+ """Render one block: what it is, how it behaves, and the two schemas it publishes."""
90
+ lines = [f"### `{entry.id}`", "", entry.summary, "", _properties(entry), ""]
91
+ lines.extend(["**Config**", "", _schema_table(entry.config_schema), ""])
92
+ lines.extend(["**Output**", "", _schema_table(entry.output_schema), ""])
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _properties(entry: BlockEntry) -> str:
97
+ """Render the retry-relevant and scheduling-relevant facts as one line each."""
98
+ facts = [f"Contributed by `{entry.plugin}`."]
99
+ facts.append("Idempotent." if entry.idempotent else "Not idempotent.")
100
+ if entry.local_execution:
101
+ facts.append("**Runs code on the worker**, so the instance must allowlist its id.")
102
+ if entry.default_poll_seconds is not None:
103
+ facts.append(f"Polls every {_duration(entry.default_poll_seconds)} unless the step says otherwise.")
104
+ if entry.default_deadline_seconds is not None:
105
+ facts.append(f"Gives up after {_duration(entry.default_deadline_seconds)} unless the step says otherwise.")
106
+ return " ".join(facts)
107
+
108
+
109
+ def _duration(seconds: float) -> str:
110
+ """Render a duration the way a document would write it."""
111
+ for unit, size in (("h", 3600), ("m", 60)):
112
+ if seconds >= size and seconds % size == 0:
113
+ return f"{int(seconds // size)}{unit}"
114
+ return f"{seconds:g}s"
115
+
116
+
117
+ def _schema_table(schema: JsonMap) -> str:
118
+ """Render one JSON Schema object as a table of its fields."""
119
+ properties = _mapping(schema.get("properties"))
120
+ if not properties:
121
+ return "_No fields._"
122
+ required = {name for name in _sequence(schema.get("required")) if isinstance(name, str)}
123
+ definitions = _mapping(schema.get("$defs"))
124
+ rows: list[str] = []
125
+ for name, raw in properties.items():
126
+ field = _mapping(raw)
127
+ rows.append(
128
+ f"| `{name}` | `{_type_of(field, definitions)}` | {'yes' if name in required else ''} "
129
+ f"| {_default_of(field, name in required)} | {_description_of(field)} |"
130
+ )
131
+ return "\n".join(["| Field | Type | Required | Default | Description |", "| --- | --- | --- | --- | --- |", *rows])
132
+
133
+
134
+ def _type_of(field: JsonMap, definitions: JsonMap) -> str:
135
+ """Render a field's type readably, following the shapes pydantic actually emits.
136
+
137
+ A union reads as "a or b" rather than "a | b": the result lands in a markdown table cell,
138
+ where a pipe is a column separator whatever it is wrapped in.
139
+ """
140
+ resolved = _resolve(field, definitions)
141
+ if "const" in resolved:
142
+ return json.dumps(resolved["const"])
143
+ enum = _sequence(resolved.get("enum"))
144
+ if enum:
145
+ return " or ".join(json.dumps(value) for value in enum)
146
+ for keyword in ("anyOf", "oneOf", "allOf"):
147
+ branches = _sequence(resolved.get(keyword))
148
+ if branches:
149
+ alternatives = [_type_of(_mapping(branch), definitions) for branch in branches]
150
+ return " or ".join(dict.fromkeys(alternatives))
151
+ declared = resolved.get("type")
152
+ if declared == "array":
153
+ return f"{_type_of(_mapping(resolved.get('items')), definitions)}[]"
154
+ if declared == "object":
155
+ values = _mapping(resolved.get("additionalProperties"))
156
+ if not values:
157
+ return "object"
158
+ of = _type_of(values, definitions)
159
+ return "object" if of == "any" else f"object of {of}"
160
+ if isinstance(declared, str):
161
+ formatted = resolved.get("format")
162
+ return f"{declared} ({formatted})" if isinstance(formatted, str) else declared
163
+ return "any"
164
+
165
+
166
+ def _resolve(field: JsonMap, definitions: JsonMap) -> JsonMap:
167
+ """Follow a ``$ref`` into the schema's own ``$defs``, which is the only ref pydantic emits."""
168
+ reference = field.get("$ref")
169
+ if not isinstance(reference, str):
170
+ return field
171
+ name = reference.rsplit("/", 1)[-1]
172
+ return _mapping(definitions.get(name)) or field
173
+
174
+
175
+ def _default_of(field: JsonMap, required: bool) -> str:
176
+ """Render a field's default, distinguishing "none" from "there is no default"."""
177
+ if required or "default" not in field:
178
+ return ""
179
+ return f"`{json.dumps(field['default'])}`"
180
+
181
+
182
+ def _description_of(field: JsonMap) -> str:
183
+ """Render what a field is on one line, since a table cell is one line.
184
+
185
+ The first paragraph only, for the reason ``described`` gives: a config field inherited by
186
+ three blocks renders three times, and a paragraph of rationale renders three times with it.
187
+ """
188
+ text = field.get("description")
189
+ if not isinstance(text, str) or not text.strip():
190
+ return NO_DESCRIPTION
191
+ return one_line(first_paragraph(text)).replace("|", "\\|")
192
+
193
+
194
+ def _surfaces(title: str, label: str, entries: list[SurfaceEntry]) -> str:
195
+ """Render one non-block surface's entries as a short table."""
196
+ if not entries:
197
+ return f"### {title}\n\n_None installed._\n"
198
+ rows = [f"| `{entry.id}` | `{entry.plugin}` |" for entry in entries]
199
+ return "\n".join(
200
+ [
201
+ f"### {title}",
202
+ "",
203
+ f"| {title[:-1] if label == 'id' else label.title()} | Contributed by |",
204
+ "| --- | --- |",
205
+ *rows,
206
+ "",
207
+ ]
208
+ )
209
+
210
+
211
+ def _anchor(block_id: str) -> str:
212
+ """Render the anchor mkdocs gives a heading whose text is a backticked block id."""
213
+ return block_id.replace(".", "")
214
+
215
+
216
+ def _mapping(value: object) -> JsonMap:
217
+ """Read a schema node that should be an object, or nothing."""
218
+ return cast("JsonMap", value) if isinstance(value, dict) else {}
219
+
220
+
221
+ def _sequence(value: object) -> list[object]:
222
+ """Read a schema node that should be a list, or nothing."""
223
+ return cast("list[object]", value) if isinstance(value, list) else []
@@ -0,0 +1,421 @@
1
+ """Layered configuration: defaults, a YAML file, then DIRIGENT_-prefixed environment variables."""
2
+
3
+ import json
4
+ import os
5
+ from datetime import timedelta
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Annotated, Literal
9
+
10
+ from pydantic import Field, SecretStr, field_validator, model_validator
11
+ from pydantic_settings import (
12
+ BaseSettings,
13
+ NoDecode,
14
+ PydanticBaseSettingsSource,
15
+ SettingsConfigDict,
16
+ YamlConfigSettingsSource,
17
+ )
18
+
19
+ from dirigent_common import Duration, EntityName, Size
20
+
21
+ CONFIG_FILE_ENV = "DIRIGENT_CONFIG_FILE"
22
+
23
+ #: Connections a worker process needs beyond its in-flight block calls: one for the lease
24
+ #: heartbeat, one for the sweeper, one for the alert loop, and one spare so that none of the
25
+ #: three ever queues behind a block call. The heartbeat is the one that must never wait.
26
+ POOL_HEADROOM = 4
27
+
28
+ #: Where a local instance keeps its database and its artifacts, relative to the working
29
+ #: directory. Everything under it is disposable state, so `dg init` gitignores it.
30
+ STATE_DIR = ".dirigent/state"
31
+
32
+ DEFAULT_CONFIG_PATHS = (
33
+ Path("dirigent.yaml"),
34
+ Path(".dirigent/dirigent.yaml"),
35
+ Path.home() / ".config" / "dirigent" / "dirigent.yaml",
36
+ )
37
+
38
+
39
+ def parse_string_list(value: object) -> object:
40
+ """Read a list setting written as either a comma-separated string or a JSON array."""
41
+ if not isinstance(value, str):
42
+ return value
43
+ text = value.strip()
44
+ if not text:
45
+ return []
46
+ if text.startswith("["):
47
+ try:
48
+ return json.loads(text)
49
+ except ValueError:
50
+ pass
51
+ return [item.strip() for item in text.split(",") if item.strip()]
52
+
53
+
54
+ def parse_string_mapping(value: object) -> object:
55
+ """Read a mapping setting written as either comma-separated ``key=value`` pairs or JSON."""
56
+ if not isinstance(value, str):
57
+ return value
58
+ text = value.strip()
59
+ if not text:
60
+ return {}
61
+ if text.startswith("{"):
62
+ try:
63
+ return json.loads(text)
64
+ except ValueError:
65
+ pass
66
+ pairs = [item.strip() for item in text.split(",") if item.strip()]
67
+ return {key.strip(): held.strip() for key, _, held in (pair.partition("=") for pair in pairs)}
68
+
69
+
70
+ def candidate_config_paths() -> list[Path]:
71
+ """List the YAML files that contribute to the effective configuration, most specific last."""
72
+ named = os.environ.get(CONFIG_FILE_ENV)
73
+ if named:
74
+ return [Path(named)]
75
+ return list(reversed(DEFAULT_CONFIG_PATHS))
76
+
77
+
78
+ class Settings(BaseSettings):
79
+ """The effective instance configuration, shared by the server, the workers, and the CLI."""
80
+
81
+ model_config = SettingsConfigDict(
82
+ env_prefix="DIRIGENT_",
83
+ env_nested_delimiter="__",
84
+ extra="ignore",
85
+ frozen=True,
86
+ # Each field's docstring becomes its description, which is what the generated
87
+ # reference and the scaffolded example file are written from.
88
+ use_attribute_docstrings=True,
89
+ )
90
+
91
+ environment: Literal["local", "staging", "prod"] = "local"
92
+ """Which deployment this instance believes it is, reported on telemetry."""
93
+
94
+ database_url: str = f"sqlite+aiosqlite:///./{STATE_DIR}/dirigent.db"
95
+ """Where the instance's own state lives, as a SQLAlchemy async URL.
96
+
97
+ A URL starting with ``sqlite`` puts the instance in single-process mode: one process may
98
+ hold it, which is what ``dg dev`` runs. PostgreSQL is ``postgresql+asyncpg://...``, and
99
+ is what more than one worker requires. The default is relative to the working directory,
100
+ and its directory is created on start."""
101
+
102
+ database_echo: bool = False
103
+ """Echo every SQL statement to the process log. Debugging only; it is very loud."""
104
+
105
+ database_pool_size: int = Field(default=12, ge=1)
106
+ """How many database connections one process keeps open. Ignored by SQLite.
107
+
108
+ It moves with ``worker_concurrency``: every executing attempt wants a session, and so do
109
+ the lease heartbeat, the sweeper and the alert loop. An instance refuses to start where
110
+ the pool cannot cover them, because the loop that loses is the heartbeat, and an expired
111
+ lease means the sweeper reclaims a live attempt and non-idempotent work runs twice."""
112
+
113
+ database_max_overflow: int = Field(default=4, ge=0)
114
+ """Connections the pool may open beyond ``database_pool_size`` under a burst."""
115
+
116
+ database_pool_timeout: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
117
+ """How long a checkout waits for a free connection before failing loudly."""
118
+
119
+ database_pool_recycle: Duration | None = timedelta(minutes=30)
120
+ """Recycle a pooled connection older than this; unset disables recycling."""
121
+
122
+ host: str = "127.0.0.1"
123
+ """The address the API server binds. A container needs ``0.0.0.0`` to be reachable."""
124
+
125
+ port: int = Field(default=3333, ge=1, le=65535)
126
+ """The port the API server binds."""
127
+
128
+ api_prefix: str = "/api/v1"
129
+ """Where the versioned REST API is mounted. ``/hooks/{token}`` is not under it.
130
+
131
+ A client has to be told the same thing, through a profile's ``api_prefix`` or the SDK's
132
+ argument of that name: an instance that moves its API and tells nobody is one its own
133
+ CLI cannot reach.
134
+ """
135
+
136
+ ui_enabled: bool = True
137
+ """Whether the server serves the bundled web UI beside the API.
138
+
139
+ Off, the process is the API and nothing else: no ``/``, no ``/assets``, no
140
+ ``/config.json``. On with no bundle built, ``/`` answers a refusal naming how to build
141
+ one, and every API route is unaffected either way.
142
+ """
143
+
144
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
145
+ """How loud the process log is. This is the process log; a run's own telemetry is the
146
+ ``log_entries`` table, and a CLI ``-v`` or ``--debug`` flag wins over this."""
147
+
148
+ log_format: Literal["console", "json"] = "console"
149
+ """How a command spells a log line: ``console`` for a person, ``json`` for a collector.
150
+
151
+ The four process commands write NDJSON whatever this says.
152
+ """
153
+
154
+ artifact_root: str = f"file://./{STATE_DIR}/artifacts"
155
+ """Default storage backend URI prefix for run scratch space and artifacts."""
156
+
157
+ work_root: str = f"./{STATE_DIR}/work"
158
+ """Directory on this worker's own filesystem where a run's local working files go.
159
+
160
+ A path, not a URI, and never shared: a checkout, a build context, a compose file and a
161
+ bind mount are things a tool opens through the filesystem, so they cannot live in the
162
+ bucket the artifact root addresses. What a step leaves here is reachable only by the
163
+ worker that wrote it."""
164
+
165
+ storage_connections: Annotated[dict[str, str], NoDecode] = Field(default_factory=dict[str, str])
166
+ """Which connection configures each storage scheme, as ``scheme -> connection code``.
167
+
168
+ A scheme with no entry keeps whatever its package contributed it with, which for ``s3://``
169
+ is the ambient AWS credential chain."""
170
+
171
+ inline_artifact_max: Size = Field(default=16384, ge=0)
172
+ """Outputs at or below this serialized size are stored inline instead of as a file."""
173
+
174
+ inline_capture: Size = Field(default=8192, ge=0)
175
+ """How much of a captured stream a block may inline in its output.
176
+
177
+ A chatty job is worth raising this for; the whole stream is in storage either way."""
178
+
179
+ secret_key: SecretStr | None = None
180
+ """Envelope key for connection secrets; required before any secret is stored."""
181
+
182
+ worker_concurrency: int = Field(default=8, ge=1)
183
+ """How many step attempts one worker process executes at once.
184
+
185
+ Attempts, not runs and not pipelines: eight steps of one run, one step each from eight
186
+ runs, or any mix. A waiting attempt holds no slot -- a sensor between pokes, or an
187
+ operator whose remote job is still running, is a parked row with a due time, and its
188
+ probe occupies a slot only while it runs -- so one worker at 8 can have hundreds of runs
189
+ in flight. This bounds simultaneously executing work and nothing else.
190
+
191
+ It is one of three separate limits, which are easy to confuse: this bounds one worker, a
192
+ pipeline's own ``concurrency`` policy bounds overlapping runs of that pipeline, and
193
+ running more workers multiplies this one. Nothing bounds an instance's total runs, by
194
+ design. Raise it together with ``database_pool_size``: every executing attempt wants a
195
+ session, and a pool smaller than the concurrency starves the lease heartbeat."""
196
+
197
+ worker_tags: Annotated[list[str], NoDecode] = Field(default_factory=list[str])
198
+ """Capability tags a worker advertises to the claim query."""
199
+
200
+ worker_name: EntityName | None = None
201
+ """Registry name of this worker; defaults to hostname plus process id."""
202
+
203
+ lease: Duration = Field(default=timedelta(seconds=60), ge=timedelta(seconds=5))
204
+ """How long a claimed attempt's lease is valid before the sweeper may reclaim it."""
205
+
206
+ heartbeat: Duration = Field(default=timedelta(seconds=15), ge=timedelta(seconds=1))
207
+ """How often a worker refreshes the leases it holds and its registry row."""
208
+
209
+ claim_idle: Duration = Field(default=timedelta(milliseconds=500), gt=timedelta(0))
210
+ """How long a worker waits before asking for work again when the queue is empty."""
211
+
212
+ log_flush_interval: Duration = Field(default=timedelta(seconds=1), gt=timedelta(0))
213
+ """How often a running attempt's buffered log entries are written to the run.
214
+
215
+ A step's lines reach the run screen and ``dg run --watch`` within this rather than at the
216
+ moment the step ends, so a ten-minute command is visible working. A step that logs faster
217
+ than the interval flushes sooner, when its buffer fills."""
218
+
219
+ log_entries_per_attempt: int = Field(default=1000, ge=1)
220
+ """How many entries one attempt may log before the rest are dropped with one warning."""
221
+
222
+ log_flush_batch: int = Field(default=100, ge=1)
223
+ """How many buffered entries write themselves without waiting for the flush interval.
224
+
225
+ A wider batch sends fewer, larger transactions and makes a chatty step's lines wait
226
+ longer to become visible."""
227
+
228
+ sweep_interval: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
229
+ """How often a worker runs the crash-recovery sweeper over expired leases."""
230
+
231
+ stuck_run: Duration = Field(default=timedelta(hours=1), gt=timedelta(0))
232
+ """A running run with no attempt progress for this long is flagged as stuck."""
233
+
234
+ stale_worker: Duration = Field(default=timedelta(minutes=15), gt=timedelta(0))
235
+ """A worker whose registry row is older than this has that row reaped by the sweeper."""
236
+
237
+ docker_reap_interval: Duration = timedelta(minutes=5)
238
+ """How often a docker-capable worker looks for compose stacks whose run has ended.
239
+
240
+ Zero turns the reaper off, which is what a worker with no daemon wants."""
241
+
242
+ docker_reap_grace: Duration = timedelta(minutes=10)
243
+ """How old a compose project must be before the reaper will consider it at all.
244
+
245
+ A project's age is its newest container's, so a stack still being built up is young."""
246
+
247
+ retention_runs: Duration | None = None
248
+ """How long a settled run is kept, with its items, attempts, artifacts and alerts."""
249
+
250
+ retention_logs: Duration | None = None
251
+ """How long a log entry is kept, for runs too young to be pruned themselves."""
252
+
253
+ retention_deliveries: Duration | None = None
254
+ """How long a webhook delivery is kept."""
255
+
256
+ retention_firings: Duration | None = None
257
+ """How long a schedule firing is kept."""
258
+
259
+ retention_notifications: Duration | None = None
260
+ """How long an alert that was sent is kept, for runs too young to be pruned themselves."""
261
+
262
+ retention_scratch: bool = True
263
+ """Whether pruning a run also deletes its artifacts from storage."""
264
+
265
+ retention_interval: Duration = timedelta(hours=1)
266
+ """How often the scheduler sweeps whatever the retention ages have outlived."""
267
+
268
+ retention_batch: int = Field(default=500, ge=1)
269
+ """How many rows of one family a single sweep deletes before committing."""
270
+
271
+ lost_job_max_gone: int = Field(default=3, ge=1)
272
+ """Consecutive GONE probes before the lost-job policy fails a waiting attempt."""
273
+
274
+ enabled_unsafe_blocks: Annotated[list[str], NoDecode] = Field(default_factory=list[str])
275
+ """Block ids permitted to execute code on a worker; empty means none."""
276
+
277
+ scheduler_enabled: bool = True
278
+ """Whether `dg server` embeds the scheduler rather than leaving it to its own process."""
279
+
280
+ scheduler_tick: Duration = Field(default=timedelta(seconds=5), gt=timedelta(0))
281
+ """How often the leader asks the database which schedules are due."""
282
+
283
+ scheduler_misfire_grace: Duration = timedelta(minutes=5)
284
+ """How late a firing may be before it counts as a misfire: fire once, advance, no catchup storm."""
285
+
286
+ scheduler_lock_key: int = Field(default=0x64_69_72_67)
287
+ """The advisory-lock key leadership is taken on; only PostgreSQL has one to take."""
288
+
289
+ apply_dir: Path | None = None
290
+ """A directory of pipeline documents the server applies at boot; unset applies nothing."""
291
+
292
+ apply_prune: bool = False
293
+ """Whether the boot apply also deactivates directory-provenance pipelines absent from it."""
294
+
295
+ apply_lock_key: int = Field(default=0x64_69_72_61)
296
+ """The advisory-lock key the boot apply is serialised on, distinct from the scheduler's."""
297
+
298
+ notification_max_attempts: int = Field(default=5, ge=1)
299
+ """How many times a notification delivery is retried before it is recorded as failed."""
300
+
301
+ notification_backoff: Duration = Field(default=timedelta(seconds=30), gt=timedelta(0))
302
+ """The delay after a notification's first failed delivery; later ones double from it."""
303
+
304
+ notification_lease: Duration = Field(default=timedelta(seconds=60), ge=timedelta(seconds=5))
305
+ """How long a claimed notification's lease is valid before another worker may take it."""
306
+
307
+ webhook_max_payload: Size = Field(default=1_048_576, ge=1)
308
+ """The largest body the webhook intake will read; reading stops one byte past it."""
309
+
310
+ webhook_intake_rate_per_minute: int = Field(default=120, ge=1)
311
+ """How fast one *offered* token may deliver, before anything is looked up.
312
+
313
+ A real webhook's own ``rate_limit_per_minute`` applies on top of this."""
314
+
315
+ login_rate_per_minute: int = Field(default=10, ge=1)
316
+ """How many login attempts one address, and one username, get a minute.
317
+
318
+ The login route is unauthenticated and verifying a password costs Argon2id at 64 MiB, so
319
+ without this limit a flood of requests exhausts the instance's memory and CPU. It is
320
+ counted per address *and* per username, covering both a flood and credential stuffing."""
321
+
322
+ alert_base_url: str | None = None
323
+ """The externally reachable base URL, so an alert can link back to the run it is about."""
324
+
325
+ @field_validator("worker_tags", "enabled_unsafe_blocks", mode="before")
326
+ @classmethod
327
+ def _read_list(cls, value: object) -> object:
328
+ """Accept a comma-separated string for a list setting."""
329
+ return parse_string_list(value)
330
+
331
+ @field_validator("storage_connections", mode="before")
332
+ @classmethod
333
+ def _read_mapping(cls, value: object) -> object:
334
+ """Accept a comma-separated ``key=value`` string for a mapping setting."""
335
+ return parse_string_mapping(value)
336
+
337
+ @field_validator("api_prefix")
338
+ @classmethod
339
+ def _normalise_prefix(cls, value: str) -> str:
340
+ """Keep the API prefix rooted and free of a trailing slash."""
341
+ prefix = "/" + value.strip("/")
342
+ return prefix.rstrip("/") or "/"
343
+
344
+ @model_validator(mode="after")
345
+ def _pool_covers_concurrency(self) -> "Settings":
346
+ """Refuse a pool too small for the concurrency it has to carry.
347
+
348
+ A worker running ``worker_concurrency`` block calls at once needs a connection per
349
+ outcome transaction, and the heartbeat, the sweeper and the alert loop each need one
350
+ too. When the pool cannot cover them the heartbeat is the loop that loses, so leases
351
+ expire under load, the sweeper reclaims live attempts, and non-idempotent work runs
352
+ twice. The failure surfaces as a duplicate rather than as pool exhaustion.
353
+ """
354
+ if self.is_sqlite:
355
+ return self
356
+ ceiling = self.database_pool_size + self.database_max_overflow
357
+ needed = self.worker_concurrency + POOL_HEADROOM
358
+ if ceiling < needed:
359
+ raise ValueError(
360
+ f"database_pool_size ({self.database_pool_size}) plus database_max_overflow "
361
+ f"({self.database_max_overflow}) is {ceiling} connections, and a worker at "
362
+ f"worker_concurrency={self.worker_concurrency} needs at least {needed} "
363
+ f"(one per in-flight call, plus {POOL_HEADROOM} for the heartbeat, the sweeper "
364
+ "and the alert loop). Raise the pool or lower the concurrency."
365
+ )
366
+ return self
367
+
368
+ @property
369
+ def is_sqlite(self) -> bool:
370
+ """Report whether this instance runs on SQLite, where multi-process modes are refused."""
371
+ return self.database_url.startswith("sqlite")
372
+
373
+ @property
374
+ def sqlite_path(self) -> Path | None:
375
+ """The file a SQLite URL addresses, or None on PostgreSQL or an in-memory database."""
376
+ if not self.is_sqlite:
377
+ return None
378
+ target = self.database_url.partition("///")[2].split("?", 1)[0]
379
+ if not target or target.startswith(":memory:"):
380
+ return None
381
+ return Path(target)
382
+
383
+ @property
384
+ def sync_database_url(self) -> str:
385
+ """Render the database URL with a synchronous driver, which Alembic uses offline."""
386
+ return self.database_url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg")
387
+
388
+ @classmethod
389
+ def settings_customise_sources(
390
+ cls,
391
+ settings_cls: type[BaseSettings],
392
+ init_settings: PydanticBaseSettingsSource,
393
+ env_settings: PydanticBaseSettingsSource,
394
+ dotenv_settings: PydanticBaseSettingsSource,
395
+ file_secret_settings: PydanticBaseSettingsSource,
396
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
397
+ """Order the layers: explicit arguments, then environment, then the YAML file."""
398
+ yaml_settings = YamlConfigSettingsSource(settings_cls, yaml_file=candidate_config_paths())
399
+ return (init_settings, env_settings, dotenv_settings, yaml_settings)
400
+
401
+
402
+ @lru_cache(maxsize=1)
403
+ def get_settings() -> Settings:
404
+ """Return the process-wide settings, resolved once."""
405
+ return Settings()
406
+
407
+
408
+ def reset_settings_cache() -> None:
409
+ """Forget the cached settings, so a test or a CLI flag can re-resolve them."""
410
+ get_settings.cache_clear()
411
+
412
+
413
+ def redacted_url(settings: Settings) -> str:
414
+ """Render the database URL without its password, for anything a person reads."""
415
+ url = settings.database_url
416
+ if "@" not in url or "://" not in url:
417
+ return url
418
+ scheme, rest = url.split("://", 1)
419
+ credentials, host = rest.split("@", 1)
420
+ user = credentials.split(":", 1)[0]
421
+ return f"{scheme}://{user}:***@{host}"