dirigent-cli 0.18.0__tar.gz → 0.18.1__tar.gz

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 (37) hide show
  1. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/PKG-INFO +9 -9
  2. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/pyproject.toml +9 -9
  3. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/commands.py +45 -6
  5. dirigent_cli-0.18.1/src/dirigent_cli/explain.py +237 -0
  6. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/main.py +48 -1
  7. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/messages.py +2 -0
  8. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/output.py +50 -1
  9. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/schemas.py +51 -6
  10. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/summaries.py +76 -0
  11. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/triggers.py +25 -11
  12. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/LICENSE +0 -0
  13. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/README.md +0 -0
  14. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/__init__.py +0 -0
  15. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/aliases.py +0 -0
  16. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/context.py +0 -0
  17. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/formatters.py +0 -0
  18. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/graph.py +0 -0
  19. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/health.py +0 -0
  20. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/init_form.py +0 -0
  21. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/local.py +0 -0
  22. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/params.py +0 -0
  23. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/profiles.py +0 -0
  24. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/project.py +0 -0
  25. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/py.typed +0 -0
  26. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/reaper.py +0 -0
  27. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/scaffold.py +0 -0
  28. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/seeding.py +0 -0
  29. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/sources.py +0 -0
  30. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/starters.py +0 -0
  31. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/stream.py +0 -0
  32. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  33. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  34. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  35. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  36. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  37. {dirigent_cli-0.18.0 → dirigent_cli-0.18.1}/src/dirigent_cli/timing.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.18.0
3
+ Version: 0.18.1
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Programming Language :: Python :: 3.13
9
- Requires-Dist: dirigent-block-execute==0.18.0
10
- Requires-Dist: dirigent-blocks==0.18.0
11
- Requires-Dist: dirigent-client==0.18.0
12
- Requires-Dist: dirigent-common==0.18.0
13
- Requires-Dist: dirigent-core==0.18.0
14
- Requires-Dist: dirigent-examples==0.18.0
15
- Requires-Dist: dirigent-plugin==0.18.0
16
- Requires-Dist: dirigent-server==0.18.0
9
+ Requires-Dist: dirigent-block-execute==0.18.1
10
+ Requires-Dist: dirigent-blocks==0.18.1
11
+ Requires-Dist: dirigent-client==0.18.1
12
+ Requires-Dist: dirigent-common==0.18.1
13
+ Requires-Dist: dirigent-core==0.18.1
14
+ Requires-Dist: dirigent-examples==0.18.1
15
+ Requires-Dist: dirigent-plugin==0.18.1
16
+ Requires-Dist: dirigent-server==0.18.1
17
17
  Requires-Dist: httpx2>=2.12.0
18
18
  Requires-Dist: python-dotenv>=1.1.0
19
19
  Requires-Dist: pyyaml>=6.0.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.18.0"
3
+ version = "0.18.1"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.18.0",
15
- "dirigent-blocks==0.18.0",
16
- "dirigent-client==0.18.0",
17
- "dirigent-common==0.18.0",
18
- "dirigent-core==0.18.0",
19
- "dirigent-examples==0.18.0",
20
- "dirigent-plugin==0.18.0",
21
- "dirigent-server==0.18.0",
14
+ "dirigent-block-execute==0.18.1",
15
+ "dirigent-blocks==0.18.1",
16
+ "dirigent-client==0.18.1",
17
+ "dirigent-common==0.18.1",
18
+ "dirigent-core==0.18.1",
19
+ "dirigent-examples==0.18.1",
20
+ "dirigent-plugin==0.18.1",
21
+ "dirigent-server==0.18.1",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.18.0"
3
+ version = "0.18.1"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,14 +11,14 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "dirigent-block-execute==0.18.0",
15
- "dirigent-blocks==0.18.0",
16
- "dirigent-client==0.18.0",
17
- "dirigent-common==0.18.0",
18
- "dirigent-core==0.18.0",
19
- "dirigent-examples==0.18.0",
20
- "dirigent-plugin==0.18.0",
21
- "dirigent-server==0.18.0",
14
+ "dirigent-block-execute==0.18.1",
15
+ "dirigent-blocks==0.18.1",
16
+ "dirigent-client==0.18.1",
17
+ "dirigent-common==0.18.1",
18
+ "dirigent-core==0.18.1",
19
+ "dirigent-examples==0.18.1",
20
+ "dirigent-plugin==0.18.1",
21
+ "dirigent-server==0.18.1",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -15,6 +15,7 @@ from rich.markup import escape
15
15
 
16
16
  from dirigent_cli import schemas, starters
17
17
  from dirigent_cli.context import CliState, Session, client_for, state_of
18
+ from dirigent_cli.explain import explain as document_shape
18
19
  from dirigent_cli.graph import GraphStep, render_graph, steps_of_document
19
20
  from dirigent_cli.local import (
20
21
  ConnectionSpec,
@@ -61,6 +62,7 @@ from dirigent_cli.messages import (
61
62
  NOT_A_RUN_STATUS,
62
63
  NOT_A_STARTER,
63
64
  NOT_A_WINDOW,
65
+ NOT_AN_IMPORTANCE,
64
66
  NOT_AUTHENTICATED,
65
67
  OR_A_PROFILE_TOKEN,
66
68
  OR_DG_AUTH_LOGIN,
@@ -104,6 +106,7 @@ from dirigent_cli.output import (
104
106
  prioritised,
105
107
  refuse,
106
108
  render_bool,
109
+ render_check,
107
110
  stream_line,
108
111
  styled,
109
112
  table,
@@ -140,6 +143,7 @@ from dirigent_client import (
140
143
  DocumentKind,
141
144
  ExampleDetail,
142
145
  ExampleOut,
146
+ Importance,
143
147
  ItemOut,
144
148
  LogEntryOut,
145
149
  LogLevel,
@@ -467,11 +471,18 @@ def validate_command(
467
471
  server: Annotated[
468
472
  bool, typer.Option("--server", help="Also check it against a server's catalog and requirements.")
469
473
  ] = False,
474
+ explain: Annotated[
475
+ bool, typer.Option("--explain", help="Also say what the work will cost: how wide, how many tries, how long.")
476
+ ] = False,
470
477
  ) -> None:
471
478
  """Check a document offline, or against a server's catalog too with --server.
472
479
 
473
480
  Offline, a triggers document is checked at the schema level: its clocks and its codes.
474
481
  `--server` adds the pipeline it names and the parameters its schedules pin.
482
+
483
+ `--explain` adds the shape of the work ahead for every pipeline document that is valid:
484
+ the fan-out widths, the retry budgets and the waits. With `--server` too, a sensor that
485
+ declares neither cadence nor deadline takes the block's own.
475
486
  """
476
487
  state = state_of(ctx)
477
488
  documents = _documents_to_apply(reference, None)
@@ -524,16 +535,26 @@ def validate_command(
524
535
  )
525
536
  failures += 1
526
537
  continue
538
+ checked = "document and catalog" if catalog is not None else "document, offline"
527
539
  emit_fact(
528
540
  "validation",
529
541
  message="valid",
530
542
  code=definition.code,
531
543
  document=label,
532
- checked="document and catalog" if catalog is not None else "document, offline",
544
+ checked=checked,
533
545
  steps=[step._asdict() for step in graph_steps(definition)]
534
546
  if isinstance(definition, PipelineDefinition)
535
547
  else None,
536
548
  )
549
+ if explain and isinstance(definition, PipelineDefinition):
550
+ emit_fact(
551
+ "validation.shape",
552
+ message="shape",
553
+ code=definition.code,
554
+ document=label,
555
+ checked=checked,
556
+ **document_shape(definition, catalog).model_dump(mode="json"),
557
+ )
537
558
  emit_fact(
538
559
  "validated",
539
560
  level="error" if failures else "info",
@@ -831,6 +852,7 @@ def pipeline_show(
831
852
  "name": row.name or "-",
832
853
  "description": row.description or "-",
833
854
  "tags": " ".join(row.tags) or "-",
855
+ "importance": row.importance.value,
834
856
  "active": "yes" if row.active else "no",
835
857
  "current version": row.current_version,
836
858
  "runs in flight": row.active_runs,
@@ -1049,6 +1071,20 @@ def parse_priority(value: str | None) -> RunPriority | None:
1049
1071
  )
1050
1072
 
1051
1073
 
1074
+ def parse_importance(value: str | None) -> Importance | None:
1075
+ """Read the importance a rule was given, naming the vocabulary when it is not one."""
1076
+ if value is None:
1077
+ return None
1078
+ try:
1079
+ return Importance(value.lower())
1080
+ except ValueError:
1081
+ fail(
1082
+ NOT_AN_IMPORTANCE,
1083
+ value=repr(value),
1084
+ allowed=", ".join(importance.value for importance in Importance),
1085
+ )
1086
+
1087
+
1052
1088
  def run_command(
1053
1089
  ctx: typer.Context,
1054
1090
  target: Annotated[str, typer.Argument(help="A pipeline code, or a document file, URL, or -.")],
@@ -2285,7 +2321,7 @@ def connection_list(
2285
2321
  row.name or "-",
2286
2322
  row.kind,
2287
2323
  moment(row.last_check_at),
2288
- render_bool(row.last_check_healthy),
2324
+ render_check(row.last_check_at, row.last_check_healthy),
2289
2325
  row.description or "-",
2290
2326
  ]
2291
2327
  for row in rows
@@ -2389,18 +2425,21 @@ def connection_show(
2389
2425
 
2390
2426
  @connection_app.command("check")
2391
2427
  def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2392
- """Ask a connection's own kind whether its external system answers."""
2428
+ """Ask a connection's own kind whether its external system answers.
2429
+
2430
+ A check that ran and could not decide is not a failure, so only a refused one exits 1.
2431
+ """
2393
2432
  with client_for(state_of(ctx)) as dg:
2394
2433
  report = dg.call(dg.connections.check(code))
2395
2434
  emit_fact(
2396
2435
  "connection.checked",
2397
- message="healthy" if report.healthy else "unhealthy",
2436
+ message="not verified" if report.healthy is None else "healthy" if report.healthy else "unhealthy",
2398
2437
  code=code,
2399
2438
  healthy=report.healthy,
2400
2439
  detail=report.detail,
2401
2440
  version=report.version,
2402
2441
  )
2403
- if not report.healthy:
2442
+ if report.healthy is False:
2404
2443
  raise typer.Exit(code=1)
2405
2444
 
2406
2445
 
@@ -2541,7 +2580,7 @@ def system_info(
2541
2580
  row.name or "-",
2542
2581
  row.kind,
2543
2582
  moment(row.last_check_at),
2544
- render_bool(row.last_check_healthy),
2583
+ render_check(row.last_check_at, row.last_check_healthy),
2545
2584
  row.last_check_detail or "-",
2546
2585
  ]
2547
2586
  for row in info.connections
@@ -0,0 +1,237 @@
1
+ """What a document will cost before it runs: how wide, how many tries, how long it may wait.
2
+
3
+ Everything here is read off the document, and off the catalog where a server hands one over.
4
+ Nothing executes, so the numbers are the ones the engine fixes when it creates a run: a
5
+ fan-out's cardinality, the attempts a retry policy allows, and the waits a step may spend
6
+ under a deadline. What only the run can decide -- a parameter supplied at the command line,
7
+ a window's bounds -- is reported as unknown rather than guessed at, and a total that counted
8
+ one of those says so.
9
+
10
+ The cardinality rules are :func:`dirigent_core.engine.runs.resolve_fan_out`'s: a literal list
11
+ is its own length, ``${steps.<name>.items}`` adopts another fan-out's grid, a reference that
12
+ resolves to a list is that list, and anything else is only the run's to answer.
13
+ """
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from datetime import timedelta
17
+ from typing import Final
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from dirigent_cli.schemas import ShapeWarning, StepShape
22
+ from dirigent_client.schemas import BlockEntry, BlockKind, Catalog
23
+ from dirigent_common import Duration, JsonMap, base_format_checker, format_duration
24
+ from dirigent_core.engine.definition import ParameterError, PipelineDefinition, RetryPolicy, StepDefinition
25
+ from dirigent_core.engine.failure import backoff_delay
26
+ from dirigent_core.engine.references import ReferenceScope, UnknownReference, resolve
27
+
28
+ #: What a cardinality says when the document alone cannot fix it.
29
+ UNKNOWN: Final = "unknown"
30
+
31
+ #: How a cardinality names the fan-out whose grid a step maps over instead of its own.
32
+ ADOPTS: Final = "adopts {step}"
33
+
34
+
35
+ class DocumentShape(BaseModel):
36
+ """What a whole document will cost: the steps, the totals over them, and the warnings."""
37
+
38
+ model_config = ConfigDict(frozen=True)
39
+
40
+ attempts_max: int = 0
41
+ """Every attempt the document allows: each step's tries times the items it maps over."""
42
+
43
+ attempts_at_least: bool = False
44
+ """Whether that total is a floor, a cardinality only the run can fix having counted once."""
45
+
46
+ deadline_longest: Duration | None = None
47
+ """The longest chain of deadlines through the DAG, which is the wait the document allows."""
48
+
49
+ steps: list[StepShape] = Field(default_factory=list[StepShape])
50
+ warnings: list[ShapeWarning] = Field(default_factory=list[ShapeWarning])
51
+
52
+
53
+ def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> DocumentShape:
54
+ """Read a document as the work it will become, in the order the steps will run.
55
+
56
+ ``catalog`` is a server's, where one was asked for: a sensor that declares no cadence and
57
+ no deadline of its own takes the block's, and the row says which of the two came from
58
+ there. With no catalog the document's own values are all there is.
59
+ """
60
+ params = _param_defaults(definition)
61
+ order = definition.topological_order()
62
+ shapes: dict[str, StepShape] = {}
63
+ for name in order:
64
+ shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog)
65
+ rows = [shapes[name] for name in order]
66
+ return DocumentShape(
67
+ attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
68
+ attempts_at_least=any(row.elements is None for row in rows),
69
+ deadline_longest=_deadline_longest(definition, shapes, order),
70
+ steps=rows,
71
+ warnings=_warnings(rows, catalog),
72
+ )
73
+
74
+
75
+ def _param_defaults(definition: PipelineDefinition) -> JsonMap:
76
+ """Fill the parameters a run would be given with the defaults the document declares.
77
+
78
+ A parameter with no default is one only the run supplies, and every cardinality that
79
+ reads one is unknown here rather than wrong.
80
+ """
81
+ try:
82
+ return definition.validate_params({}, base_format_checker())
83
+ except ParameterError:
84
+ return {}
85
+
86
+
87
+ def _step_shape(
88
+ name: str,
89
+ step: StepDefinition,
90
+ params: JsonMap,
91
+ done: Mapping[str, StepShape],
92
+ catalog: Catalog | None,
93
+ ) -> StepShape:
94
+ """Read one step as the work it will become, against the steps already read."""
95
+ cardinality, elements = _cardinality(step, params, done)
96
+ poll, deadline, from_block = _waits(step, _entry(step, catalog))
97
+ return StepShape(
98
+ name=name,
99
+ block=step.block,
100
+ depends_on=list(step.depends_on),
101
+ cardinality=cardinality,
102
+ elements=elements,
103
+ reference=step.for_each if isinstance(step.for_each, str) else None,
104
+ items=step.items.value,
105
+ max_attempts=step.retry.max_attempts,
106
+ retry_wait=_retry_wait(step.retry),
107
+ timeout=step.timeout,
108
+ poll=poll,
109
+ deadline=deadline,
110
+ on_timeout=step.on_timeout.value,
111
+ from_block=from_block,
112
+ )
113
+
114
+
115
+ def _cardinality(step: StepDefinition, params: JsonMap, done: Mapping[str, StepShape]) -> tuple[int | str, int | None]:
116
+ """Say how many run items a step becomes, and the count behind that where there is one."""
117
+ expression = step.for_each
118
+ if expression is None:
119
+ return 1, 1
120
+ if isinstance(expression, list):
121
+ return len(expression), len(expression)
122
+ adopted = step.adopted_grid
123
+ if adopted is not None:
124
+ upstream = done[adopted].elements if adopted in done else None
125
+ return ADOPTS.format(step=adopted), upstream
126
+ if "steps." in expression:
127
+ # resolve_fan_out refuses a step's output here, because cardinality is fixed when the
128
+ # run is created and no step has run by then.
129
+ return UNKNOWN, None
130
+ try:
131
+ resolved = resolve(expression, ReferenceScope(params=params))
132
+ except UnknownReference:
133
+ return UNKNOWN, None
134
+ if not isinstance(resolved, list):
135
+ return UNKNOWN, None
136
+ return len(resolved), len(resolved)
137
+
138
+
139
+ def _entry(step: StepDefinition, catalog: Catalog | None) -> BlockEntry | None:
140
+ """Find what the catalog says about this step's block, where there is a catalog."""
141
+ return None if catalog is None else catalog.block(step.block)
142
+
143
+
144
+ def _waits(step: StepDefinition, entry: BlockEntry | None) -> tuple[timedelta | None, timedelta | None, list[str]]:
145
+ """Take the cadence and the deadline from the document, then from the block's own defaults."""
146
+ poll, deadline = step.poll, step.deadline
147
+ from_block: list[str] = []
148
+ if entry is None:
149
+ return poll, deadline, from_block
150
+ if poll is None and entry.default_poll_seconds is not None:
151
+ poll = timedelta(seconds=entry.default_poll_seconds)
152
+ from_block.append("poll")
153
+ if deadline is None and entry.default_deadline_seconds is not None:
154
+ deadline = timedelta(seconds=entry.default_deadline_seconds)
155
+ from_block.append("deadline")
156
+ return poll, deadline, from_block
157
+
158
+
159
+ def _retry_wait(policy: RetryPolicy) -> timedelta | None:
160
+ """Add up every backoff the policy allows, at the top of the jitter it spreads them over.
161
+
162
+ ``max_attempts`` tries have one delay fewer between them, and each delay is the engine's
163
+ own: exponential, clamped at ``max_backoff``, and then spread by the jitter.
164
+ """
165
+ if policy.max_attempts <= 1:
166
+ return None
167
+ steady = policy.model_copy(update={"jitter": 0.0})
168
+ total = sum((backoff_delay(steady, attempt) for attempt in range(1, policy.max_attempts)), timedelta(0))
169
+ return total * (1 + policy.jitter)
170
+
171
+
172
+ def _deadline_longest(
173
+ definition: PipelineDefinition, shapes: Mapping[str, StepShape], order: Sequence[str]
174
+ ) -> timedelta | None:
175
+ """Add the deadlines along each path through the DAG and take the longest of them.
176
+
177
+ A step cannot start before its dependencies settled, so the chain is what the document
178
+ allows end to end; a step with no deadline lengthens no chain.
179
+ """
180
+ chains: dict[str, timedelta] = {}
181
+ for name in order:
182
+ upstream = max((chains[one] for one in definition.steps[name].depends_on), default=timedelta(0))
183
+ chains[name] = upstream + (shapes[name].deadline or timedelta(0))
184
+ longest = max(chains.values(), default=timedelta(0))
185
+ return longest or None
186
+
187
+
188
+ def _warnings(rows: Sequence[StepShape], catalog: Catalog | None) -> list[ShapeWarning]:
189
+ """Name what the document leaves unbounded or unresolved, one sentence each."""
190
+ found: list[ShapeWarning] = []
191
+ for row in rows:
192
+ # The step that named the reference is warned about; one that adopts its grid has the
193
+ # same unknown width and no second cause.
194
+ if row.cardinality == UNKNOWN:
195
+ found.append(
196
+ ShapeWarning(
197
+ step=row.name,
198
+ cause="unknown-cardinality",
199
+ message=(
200
+ f"{row.name} fans out over {row.reference}, which only the run can resolve, "
201
+ f"so it is counted as one item here."
202
+ ),
203
+ )
204
+ )
205
+ if row.retry_wait is not None and row.deadline is not None and row.retry_wait > row.deadline:
206
+ found.append(
207
+ ShapeWarning(
208
+ step=row.name,
209
+ cause="retry-outlasts-deadline",
210
+ message=(
211
+ f"{row.name} may spend {format_duration(row.retry_wait)} in backoff between its "
212
+ f"{row.max_attempts} attempts, which is longer than the {format_duration(row.deadline)} "
213
+ f"deadline each one waits under."
214
+ ),
215
+ )
216
+ )
217
+ if row.deadline is None and _is_sensor(row, catalog):
218
+ found.append(ShapeWarning(step=row.name, cause="unbounded-sensor", message=_unbounded(row, catalog)))
219
+ return found
220
+
221
+
222
+ def _unbounded(row: StepShape, catalog: Catalog | None) -> str:
223
+ """Say that nothing bounds a sensor's wait, and where a catalog would still fill one in."""
224
+ if catalog is None:
225
+ return f"{row.name} polls with no deadline, so nothing here bounds its wait: --server reads the block's."
226
+ return f"{row.name} waits on a sensor that neither the document nor {row.block} gives a deadline."
227
+
228
+
229
+ def _is_sensor(row: StepShape, catalog: Catalog | None) -> bool:
230
+ """Report whether a step waits on a sensor, by the catalog where there is one.
231
+
232
+ Offline there is nothing to ask, and a cadence is the one thing only a sensor carries.
233
+ """
234
+ if catalog is None:
235
+ return row.poll is not None
236
+ entry = catalog.block(row.block)
237
+ return entry is not None and entry.kind is BlockKind.SENSOR
@@ -52,6 +52,7 @@ from dirigent_common import Issue
52
52
  from dirigent_core import migrations
53
53
  from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
54
54
  from dirigent_core.logging import configure_logging, silence_stdout
55
+ from dirigent_core.messages import SCHEMA_STALE
55
56
  from dirigent_core.protocol import FORMATS, Format, Record, make
56
57
  from dirigent_core.telemetry import configure_telemetry
57
58
 
@@ -64,6 +65,7 @@ if TYPE_CHECKING:
64
65
  from dirigent_cli.stream import Sink
65
66
  from dirigent_common import JsonMap
66
67
  from dirigent_core import retention
68
+ from dirigent_core.migrations import SchemaDifference
67
69
  from dirigent_core.worker import Worker
68
70
 
69
71
  #: Help is capped rather than stretched: a panel the width of a wide terminal is unreadable.
@@ -703,6 +705,7 @@ def server(
703
705
  problems=[Issue.of(USE_DG_DEV_STANDALONE), Issue.of(OR_NO_SCHEDULER), Issue.of(OR_POSTGRES)],
704
706
  )
705
707
  raise typer.Exit(code=commands.GUARD_EXIT)
708
+ guard_schema(settings)
706
709
  uvicorn.run(
707
710
  "dirigent_cli.main:build_app",
708
711
  factory=True,
@@ -912,6 +915,7 @@ def dev(
912
915
  if cleared is not None:
913
916
  emit(state_cleared(cleared))
914
917
  migrated = _migrate_quietly(settings)
918
+ guard_schema(settings)
915
919
  admin, token = asyncio.run(dev_admin(settings))
916
920
  address = host or settings.host
917
921
  listening = port or settings.port
@@ -966,6 +970,47 @@ def _migrate_quietly(settings: Settings) -> str | None:
966
970
  return head
967
971
 
968
972
 
973
+ async def _schema_differences(settings: Settings) -> list["SchemaDifference"]:
974
+ """Read the live schema once, and dispose of the engine that read it."""
975
+ from dirigent_core.database import create_engine
976
+
977
+ engine = create_engine(settings)
978
+ try:
979
+ return await migrations.schema_differences(engine)
980
+ finally:
981
+ await engine.dispose()
982
+
983
+
984
+ def guard_schema(settings: Settings) -> None:
985
+ """Refuse a database whose schema is not the one this dirigent's models describe.
986
+
987
+ Before 1.0 the baseline migration is edited in place, so a state an older dirigent wrote
988
+ is stamped at the same revision and there is nothing for an upgrade to do. Reflection is
989
+ what answers instead, and a process given such a file would otherwise start, say ready,
990
+ and fail every query it ever ran.
991
+ """
992
+ import asyncio
993
+
994
+ from dirigent_cli.health import where_database
995
+
996
+ # A database nothing has ever migrated is a different fault, which this refusal and its
997
+ # remedies would name wrongly.
998
+ if migrations.current_revision(settings) is None:
999
+ return
1000
+ differences = asyncio.run(_schema_differences(settings))
1001
+ if not differences:
1002
+ return
1003
+ refuse(
1004
+ SCHEMA_STALE,
1005
+ status=commands.GUARD_EXIT,
1006
+ title="A state an older dirigent wrote",
1007
+ where=where_database(settings),
1008
+ differences=len(differences),
1009
+ first=str(differences[0]),
1010
+ )
1011
+ raise typer.Exit(code=commands.GUARD_EXIT)
1012
+
1013
+
969
1014
  def dev_started(
970
1015
  settings: Settings, *, bound: str, admin: str | None, token: str | None, migrated: str | None
971
1016
  ) -> Record:
@@ -1212,6 +1257,7 @@ def worker(
1212
1257
  problems=[Issue.of(USE_DG_DEV), Issue.of(OR_POSTGRES)],
1213
1258
  )
1214
1259
  raise typer.Exit(code=commands.GUARD_EXIT)
1260
+ guard_schema(settings)
1215
1261
  asyncio.run(_worker(settings, concurrency, tag, name))
1216
1262
 
1217
1263
 
@@ -1247,6 +1293,7 @@ def scheduler_command(ctx: typer.Context) -> None:
1247
1293
  problems=[Issue.of(USE_DG_DEV), Issue.of(OR_POSTGRES)],
1248
1294
  )
1249
1295
  raise typer.Exit(code=commands.GUARD_EXIT)
1296
+ guard_schema(settings)
1250
1297
  emit(
1251
1298
  make(
1252
1299
  "process",
@@ -1308,7 +1355,7 @@ VALUE_OPTIONS = frozenset(
1308
1355
  "--concurrency",
1309
1356
  "--tag",
1310
1357
  "--event",
1311
- "--notifier",
1358
+ "--connection",
1312
1359
  "--status",
1313
1360
  "--pipeline",
1314
1361
  "--since",
@@ -217,6 +217,8 @@ MAP_TAKES_A_PATH = CLI.define(
217
217
 
218
218
  NOT_AN_ALERT_EVENT = CLI.define("not_an_alert_event", "{event} is not an alert event ({allowed})")
219
219
 
220
+ NOT_AN_IMPORTANCE = CLI.define("not_an_importance", "--importance {value} is not an importance ({allowed})")
221
+
220
222
  ONE_BODY = CLI.define("one_body", "a rule takes one body: --body or --body-file, not both")
221
223
 
222
224
  SCAFFOLD_REFUSED = CLI.define("scaffold_refused", "{detail}")
@@ -19,7 +19,7 @@ from rich.table import Table
19
19
  from rich.text import Text
20
20
 
21
21
  from dirigent_client.schemas.common import Problem
22
- from dirigent_common import Issue, Message
22
+ from dirigent_common import DurationError, Issue, Message, format_duration, to_timedelta
23
23
  from dirigent_core.protocol import Format, Record, as_json, make
24
24
 
25
25
  #: Whether the environment asked for no colour. Rich decides colour when a console is built,
@@ -474,6 +474,42 @@ def prioritised(priority: object) -> str:
474
474
  return ""
475
475
 
476
476
 
477
+ def watching(scope: object, importance: object) -> str:
478
+ """What an alert rule watches, in one cell: its scope, and the floor it fires at.
479
+
480
+ Almost every rule names no importance, so a column of its own would mostly be empty.
481
+ """
482
+ return f"{scope}, {importance} and above" if importance else str(scope)
483
+
484
+
485
+ #: What each alert event is called in a rendering. The wire's word is what a rule is declared
486
+ #: with; these are the words a reader is shown, here and on the web UI's own listing.
487
+ ALERT_EVENTS: Mapping[str, str] = {
488
+ "run_failed": "Failed",
489
+ "run_completed_with_errors": "Completed with errors",
490
+ "run_succeeded": "Succeeded",
491
+ "run_stuck": "Stuck",
492
+ }
493
+
494
+
495
+ def alert_event(event: object) -> str:
496
+ """Render what a rule watches for, falling back to the wire's word for an unknown event."""
497
+ return ALERT_EVENTS.get(str(event), str(event))
498
+
499
+
500
+ def throttle_note(window: object) -> str:
501
+ """Render a rule's throttle, and of an unthrottled one that it is not throttled.
502
+
503
+ A zero is the absence of a window rather than a very short one, and a column of durations
504
+ is read as durations. A duration this build cannot read is left as the server spelled it.
505
+ """
506
+ try:
507
+ held = to_timedelta(window)
508
+ except DurationError:
509
+ return str(window)
510
+ return format_duration(held) if held else "none"
511
+
512
+
477
513
  def status_cell(value: object, width: int = STATUS_WIDTH) -> str:
478
514
  """Render a status padded to a fixed width, then coloured.
479
515
 
@@ -493,6 +529,19 @@ def render_bool(value: object) -> str:
493
529
  return "[green]yes[/]" if value else "[dim]no[/]"
494
530
 
495
531
 
532
+ def render_check(last_check_at: object, healthy: object) -> str:
533
+ """Render what a connection's last check said, across the four states a row can be in.
534
+
535
+ A check answers yes, no, or that it could not decide; a row nothing has checked answers
536
+ none of the three.
537
+ """
538
+ if not last_check_at:
539
+ return "-"
540
+ if healthy is None:
541
+ return "[dim]not verified[/]"
542
+ return "[green]yes[/]" if healthy else "[dim]no[/]"
543
+
544
+
496
545
  def moment(value: object) -> str:
497
546
  """Render a timestamp compactly, in the local time an operator is reading it in."""
498
547
  if not value:
@@ -1,16 +1,16 @@
1
- """What the closing ``run`` record of a run's stream carries.
1
+ """What the rows inside a record carry: a run's steps, a backfill's windows, a document's shape.
2
2
 
3
- A run's stream is written record by record through :mod:`dirigent_core.protocol`; these are
4
- the shapes of the summaries the last record holds. They are the contract a script filters on,
5
- and they are also what the end-of-run table and the failure diagnosis are rendered from, so a
6
- field the rendering needs belongs here rather than in a second query.
3
+ Records are written through :mod:`dirigent_core.protocol`; these are the shapes of the
4
+ collections one carries beneath its line. They are the contract a script filters on, and they
5
+ are also what the table under that line is rendered from, so a field the rendering needs
6
+ belongs here rather than in a second query.
7
7
  """
8
8
 
9
9
  from uuid import UUID
10
10
 
11
11
  from pydantic import BaseModel, ConfigDict, Field
12
12
 
13
- from dirigent_common import JsonMap
13
+ from dirigent_common import Duration, JsonMap
14
14
 
15
15
 
16
16
  class StepSummary(BaseModel):
@@ -53,6 +53,51 @@ class FailureSummary(BaseModel):
53
53
  """What the attempt was given, which is half of why it failed."""
54
54
 
55
55
 
56
+ class StepShape(BaseModel):
57
+ """What one step of a checked document will become, before anything has run."""
58
+
59
+ model_config = ConfigDict(frozen=True)
60
+
61
+ name: str
62
+ """The step's own map key, which is what every reference to it is written with."""
63
+
64
+ block: str
65
+ depends_on: list[str] = Field(default_factory=list[str])
66
+
67
+ cardinality: int | str = 1
68
+ """How many run items this step becomes: a count, ``adopts <step>``, or ``unknown``."""
69
+
70
+ elements: int | None = 1
71
+ """The count behind the cardinality, which an adoption takes from the grid it adopts."""
72
+
73
+ reference: str | None = None
74
+ """The ``for_each`` this step maps over, where it names one rather than listing it."""
75
+
76
+ items: str = "fail_fast"
77
+ max_attempts: int = 1
78
+
79
+ retry_wait: Duration | None = None
80
+ """The longest this step may spend in backoff, which is every delay its policy allows."""
81
+
82
+ timeout: Duration | None = None
83
+ poll: Duration | None = None
84
+ deadline: Duration | None = None
85
+ on_timeout: str = "fail"
86
+
87
+ from_block: list[str] = Field(default_factory=list[str])
88
+ """Which of this row's waits the block's own defaults filled, the document declaring none."""
89
+
90
+
91
+ class ShapeWarning(BaseModel):
92
+ """One thing about a document's shape worth knowing before the run, said as a sentence."""
93
+
94
+ model_config = ConfigDict(frozen=True)
95
+
96
+ step: str
97
+ cause: str
98
+ message: str
99
+
100
+
56
101
  class BackfillWindow(BaseModel):
57
102
  """One window a backfill enumerated, as the closing backfill record carries it."""
58
103
 
@@ -10,6 +10,7 @@ next week has nothing else to consult either.
10
10
  """
11
11
 
12
12
  from collections.abc import Callable, Mapping, Sequence
13
+ from datetime import timedelta
13
14
  from typing import Final, cast
14
15
 
15
16
  from pydantic import BaseModel
@@ -31,6 +32,7 @@ from dirigent_cli.output import (
31
32
  render_output,
32
33
  styled,
33
34
  )
35
+ from dirigent_common import format_duration, to_timedelta
34
36
  from dirigent_core.protocol import Record
35
37
 
36
38
  #: Fields a record carries for what is drawn beneath its line rather than for the line. A
@@ -39,6 +41,7 @@ BULKY: Final = frozenset(
39
41
  {
40
42
  "steps",
41
43
  "failures",
44
+ "warnings",
42
45
  "windows",
43
46
  "packages",
44
47
  "settings",
@@ -395,6 +398,78 @@ def _graph_step(one: Mapping[str, object]) -> GraphStep:
395
398
  )
396
399
 
397
400
 
401
+ def _shape(record: Record) -> RenderableType | None:
402
+ """Render what a document will cost: each step's shape, the totals, and the warnings."""
403
+ steps: list[schemas.StepShape] = _rows(record, "steps", schemas.StepShape)
404
+ if not steps:
405
+ return None
406
+ parts: list[RenderableType] = [
407
+ build_table(
408
+ f"what {record.get('code')} will cost",
409
+ ["step", "block", "cardinality", "attempts", "retry wait", "timeout", "deadline", "poll"],
410
+ [
411
+ [
412
+ escape(row.name),
413
+ escape(row.block),
414
+ _width(row),
415
+ str(row.max_attempts),
416
+ _wait(row.retry_wait),
417
+ _wait(row.timeout),
418
+ _deadline(row),
419
+ _wait(row.poll, from_block="poll" in row.from_block),
420
+ ]
421
+ for row in steps
422
+ ],
423
+ ),
424
+ "",
425
+ build_fields(
426
+ "the whole document",
427
+ {
428
+ "steps": len(steps),
429
+ "attempts": _attempts(record),
430
+ "deadline chain": _wait(_duration(record, "deadline_longest")),
431
+ },
432
+ ),
433
+ ]
434
+ warned = _rows(record, "warnings", schemas.ShapeWarning)
435
+ if warned:
436
+ parts.append("")
437
+ parts.extend(f" [yellow]-[/] {escape(one.message)}" for one in warned)
438
+ return Group(*parts)
439
+
440
+
441
+ def _width(row: schemas.StepShape) -> str:
442
+ """Say how wide a step is, and where one bad item leaves the rest of the batch running."""
443
+ drawn = escape(str(row.cardinality))
444
+ return f"{drawn} {muted('continue')}" if row.items == "continue" else drawn
445
+
446
+
447
+ def _deadline(row: schemas.StepShape) -> str:
448
+ """Say how long a step may wait, and what an expired deadline makes of it."""
449
+ drawn = _wait(row.deadline, from_block="deadline" in row.from_block)
450
+ return f"{drawn} {muted('then skip')}" if row.deadline is not None and row.on_timeout == "skip" else drawn
451
+
452
+
453
+ def _wait(value: timedelta | None, *, from_block: bool = False) -> str:
454
+ """Render one configured wait, saying where the block's own default filled it in."""
455
+ if value is None:
456
+ return "-"
457
+ drawn = format_duration(value)
458
+ return f"{drawn} {muted('block')}" if from_block else drawn
459
+
460
+
461
+ def _duration(record: Record, field: str) -> timedelta | None:
462
+ """Read one of the record's own durations back as the timedelta it was written from."""
463
+ raw = record.get(field)
464
+ return None if raw is None else to_timedelta(raw)
465
+
466
+
467
+ def _attempts(record: Record) -> str:
468
+ """Say how many attempts the document allows, and where that number is only a floor."""
469
+ total = record.get("attempts_max")
470
+ return f"at least {total}" if record.get("attempts_at_least") else f"at most {total}"
471
+
472
+
398
473
  def _backfill(record: Record) -> RenderableType | None:
399
474
  """Render a backfill: the windows it enumerated, and the run each one became."""
400
475
  windows: list[schemas.BackfillWindow] = _rows(record, "windows", schemas.BackfillWindow)
@@ -442,6 +517,7 @@ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
442
517
  "config": _config,
443
518
  "db.history": _db_history,
444
519
  "validation": _validation,
520
+ "validation.shape": _shape,
445
521
  "error": _refusal,
446
522
  "token.issued": _issued,
447
523
  "process": _issued,
@@ -5,7 +5,7 @@ from typing import Annotated, Any, cast
5
5
 
6
6
  import typer
7
7
 
8
- from dirigent_cli.commands import paged, parse_log_levels, parse_params, parse_priority
8
+ from dirigent_cli.commands import paged, parse_importance, parse_log_levels, parse_params, parse_priority
9
9
  from dirigent_cli.context import Session, client_for, state_of
10
10
  from dirigent_cli.messages import (
11
11
  AT_TAKES_AN_INSTANT,
@@ -15,6 +15,7 @@ from dirigent_cli.messages import (
15
15
  ONE_CLOCK,
16
16
  )
17
17
  from dirigent_cli.output import (
18
+ alert_event,
18
19
  console,
19
20
  emit_fact,
20
21
  emit_one,
@@ -24,8 +25,10 @@ from dirigent_cli.output import (
24
25
  render_bool,
25
26
  styled,
26
27
  table,
28
+ throttle_note,
29
+ watching,
27
30
  )
28
- from dirigent_client import AlertEvent, AlertScope, WebhookTokenOut
31
+ from dirigent_client import LOG_NOTIFIER, AlertEvent, AlertScope, WebhookTokenOut
29
32
  from dirigent_common import Message
30
33
 
31
34
  schedule_app = typer.Typer(
@@ -416,15 +419,15 @@ def alerts_rules_list(
416
419
  return emit_records("alert_rule", rows)
417
420
  table(
418
421
  "alert rules",
419
- ["code", "name", "event", "scope", "notifier", "throttle", "active", "last sent"],
422
+ ["code", "name", "event", "scope", "target", "throttle", "active", "last sent"],
420
423
  [
421
424
  [
422
425
  row.code,
423
426
  row.name or "-",
424
- row.event.value,
425
- row.pipeline or row.scope.value,
426
- row.notifier,
427
- row.throttle,
427
+ alert_event(row.event.value),
428
+ watching(row.pipeline or row.scope.value, row.importance),
429
+ row.connection or LOG_NOTIFIER,
430
+ throttle_note(row.throttle),
428
431
  render_bool(row.active),
429
432
  moment(row.last_sent_at),
430
433
  ]
@@ -444,11 +447,13 @@ def alerts_rules_create(
444
447
  help="The event that fires it: run_failed, run_completed_with_errors, run_succeeded, or run_stuck.",
445
448
  ),
446
449
  ],
447
- notifier: Annotated[str, typer.Option("--notifier", help="The channel to deliver through, such as log.")],
448
450
  name: Annotated[str | None, typer.Option("--name", help="A human title for this rule.")] = None,
449
451
  description: Annotated[str | None, typer.Option("--description", help="What this rule is for.")] = None,
450
452
  pipeline: Annotated[str | None, typer.Option("--pipeline", help="Watch one pipeline instead of all.")] = None,
451
- connection: Annotated[str | None, typer.Option("--connection", help="The credential the channel uses.")] = None,
453
+ connection: Annotated[
454
+ str | None,
455
+ typer.Option("--connection", help="The connection to deliver through; none delivers to the process log."),
456
+ ] = None,
452
457
  template: Annotated[
453
458
  str | None, typer.Option("--template", help="Subject, a Jinja template over the run's facts.")
454
459
  ] = None,
@@ -457,14 +462,22 @@ def alerts_rules_create(
457
462
  Path | None, typer.Option("--body-file", help="Read the body template from a file instead.")
458
463
  ] = None,
459
464
  throttle: Annotated[str, typer.Option("--throttle", help="At most one message per window, e.g. 15m.")] = "0s",
465
+ importance: Annotated[
466
+ str | None,
467
+ typer.Option(
468
+ "--importance",
469
+ help="Only pipelines carrying at least this much: routine, normal, or critical.",
470
+ ),
471
+ ] = None,
460
472
  ) -> None:
461
- """Declare an alert rule binding an event at a scope to a channel."""
473
+ """Declare an alert rule binding an event at a scope to one target."""
462
474
  if event not in set(AlertEvent):
463
475
  _fail(NOT_AN_ALERT_EVENT, event=repr(event), allowed=", ".join(sorted(AlertEvent)))
464
476
  if body is not None and body_file is not None:
465
477
  _fail(ONE_BODY)
466
478
  if body_file is not None:
467
479
  body = body_file.read_text()
480
+ floor = parse_importance(importance)
468
481
  with client_for(state_of(ctx)) as dg:
469
482
  created = dg.call(
470
483
  dg.alerts.create_rule(
@@ -472,9 +485,9 @@ def alerts_rules_create(
472
485
  name=name,
473
486
  description=description,
474
487
  event=AlertEvent(event),
475
- notifier=notifier,
476
488
  scope=AlertScope.PIPELINE if pipeline else AlertScope.GLOBAL,
477
489
  pipeline=pipeline,
490
+ importance=floor,
478
491
  connection=connection,
479
492
  template=template,
480
493
  body=body,
@@ -488,6 +501,7 @@ def alerts_rules_create(
488
501
  name=created.name,
489
502
  event=created.event.value,
490
503
  scope=created.pipeline or created.scope.value,
504
+ importance=created.importance.value if created.importance else None,
491
505
  notifier=created.notifier,
492
506
  connection=created.connection,
493
507
  template=created.template is not None,
File without changes
File without changes