dirigent-cli 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.
@@ -0,0 +1,2525 @@
1
+ """The commands that talk to a server: definitions, execution, catalog, connections, admin."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from collections.abc import Callable, Iterator, Mapping, Sequence
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Annotated, Any, Final, NoReturn, cast
10
+ from uuid import UUID
11
+
12
+ import typer
13
+ import yaml
14
+ from rich.markup import escape
15
+
16
+ from dirigent_cli import schemas
17
+ from dirigent_cli.context import CliState, Session, client_for, state_of
18
+ from dirigent_cli.graph import GraphStep, render_graph, steps_of_document
19
+ from dirigent_cli.local import (
20
+ ConnectionSpec,
21
+ LocalError,
22
+ LocalOutcome,
23
+ LocalStarted,
24
+ LogLine,
25
+ SchemaSpec,
26
+ Spill,
27
+ StepTransition,
28
+ load_connection_specs,
29
+ load_schema_specs,
30
+ run_document,
31
+ )
32
+ from dirigent_cli.output import (
33
+ Detail,
34
+ age,
35
+ configure,
36
+ console,
37
+ elapsed,
38
+ emit_event,
39
+ emit_fact,
40
+ emit_one,
41
+ emit_problem,
42
+ emit_record,
43
+ emit_records,
44
+ fields,
45
+ flagged,
46
+ json_mode,
47
+ moment,
48
+ muted,
49
+ output_mode,
50
+ prioritised,
51
+ refuse,
52
+ render_bool,
53
+ stream_line,
54
+ styled,
55
+ table,
56
+ )
57
+ from dirigent_cli.params import ParamError, build_params
58
+ from dirigent_cli.project import COMPOSE_TEMPLATE_NAME, ProjectError, check_template, find_project, scaffold
59
+ from dirigent_cli.scaffold import ScaffoldedRecord, ScaffoldError, ScaffoldRecord, scaffold_pack
60
+ from dirigent_cli.sources import Document, SourceError, looks_like_a_document, read_document, read_path
61
+ from dirigent_cli.stream import Sink, track_steps, use_scratch_prefix
62
+ from dirigent_cli.timing import RunProfile, attempt_timing, by_step, profile, step_timing
63
+ from dirigent_client import (
64
+ ApplyResult,
65
+ AttemptEvent,
66
+ AttemptOut,
67
+ AttemptStatus,
68
+ BackfillAccepted,
69
+ BlockKind,
70
+ Catalog,
71
+ DocumentKind,
72
+ ItemOut,
73
+ LogEntryOut,
74
+ LogLevel,
75
+ NotFound,
76
+ Page,
77
+ PlanAction,
78
+ ProvenanceSource,
79
+ RunDetail,
80
+ RunOut,
81
+ RunPriority,
82
+ RunReport,
83
+ RunStatus,
84
+ ValidationIssue,
85
+ )
86
+ from dirigent_common import JsonMap
87
+ from dirigent_core import migrations
88
+ from dirigent_core.config import STATE_DIR, Settings
89
+ from dirigent_core.documents import (
90
+ DocumentError,
91
+ TriggerTarget,
92
+ load_pipeline_text,
93
+ load_text,
94
+ validate_against_catalog,
95
+ )
96
+ from dirigent_core.engine.definition import PipelineDefinition, TriggersDefinition, load_definition
97
+ from dirigent_core.engine.runs import RunWindow
98
+ from dirigent_core.engine.state import in_execution_order
99
+ from dirigent_core.schemas import code_from_id
100
+
101
+ RUNS_PAGE = 50
102
+
103
+ #: How many of a failed attempt's own log lines the diagnosis prints.
104
+ FAILURE_LOG_LINES = 20
105
+
106
+ # Refused before any work started. Distinct from 1 ("it ran and the answer was no") and
107
+ # from Click's own 2 for a usage error.
108
+ GUARD_EXIT = 3
109
+
110
+ pipeline_app = typer.Typer(name="pipeline", help="Inspect and manage stored pipelines.", no_args_is_help=True)
111
+ runs_app = typer.Typer(name="runs", help="Runs held by this instance.", no_args_is_help=True)
112
+ connection_app = typer.Typer(
113
+ name="connection", help="Named credential records of contributed kinds.", no_args_is_help=True
114
+ )
115
+ schema_app = typer.Typer(name="schema", help="Named JSON Schemas the instance holds.", no_args_is_help=True)
116
+ blocks_app = typer.Typer(name="blocks", help="The block catalog every plugin contributes to.", no_args_is_help=True)
117
+ token_app = typer.Typer(name="token", help="API tokens.", no_args_is_help=True)
118
+ user_app = typer.Typer(name="user", help="Local accounts.", no_args_is_help=True)
119
+ auth_app = typer.Typer(name="auth", help="Logging in, and which identity the CLI holds.", no_args_is_help=True)
120
+ system_app = typer.Typer(name="system", help="What this instance is, and whether it is healthy.", no_args_is_help=True)
121
+ admin_app = typer.Typer(name="admin", help="Accounts and API tokens.", no_args_is_help=True)
122
+ admin_app.add_typer(user_app)
123
+ admin_app.add_typer(token_app)
124
+
125
+
126
+ #: The largest page any listing will answer, so a walk asks for as few round trips as it can.
127
+ MAX_PAGE = 500
128
+
129
+
130
+ def paged[T](fetch: Callable[[str | None, int], Page[T]], limit: int | None) -> Iterator[T]:
131
+ """Yield a listing's items across pages until the caller's limit, hiding the cursor."""
132
+ after: str | None = None
133
+ remaining = limit
134
+ while True:
135
+ size = MAX_PAGE if remaining is None else min(remaining, MAX_PAGE)
136
+ page = fetch(after, size)
137
+ for row in page.items:
138
+ yield row
139
+ if remaining is not None:
140
+ remaining -= 1
141
+ if remaining <= 0:
142
+ return
143
+ if page.next is None:
144
+ return
145
+ after = page.next
146
+
147
+
148
+ def fail(message: str) -> NoReturn:
149
+ """Write a refusal the CLI decided on as a record, and exit non-zero."""
150
+ refuse(message)
151
+ raise typer.Exit(code=1)
152
+
153
+
154
+ def ask(label: str, *, hide: bool = False) -> str:
155
+ """Read one value from the terminal, refusing under --json where nothing can answer.
156
+
157
+ Prompting is done here rather than by Click's ``prompt=True`` so that a machine-mode
158
+ invocation fails with a JSON object instead of blocking on a terminal that is a pipe.
159
+ """
160
+ if json_mode():
161
+ fail(f"--json cannot prompt for {label}; pass it as an option")
162
+ return str(typer.prompt(label, hide_input=hide))
163
+
164
+
165
+ def _apply_one(
166
+ dg: Session,
167
+ document: Document,
168
+ *,
169
+ code: str | None,
170
+ dry_run: bool,
171
+ paused: bool = False,
172
+ source: ProvenanceSource | None = None,
173
+ ) -> ApplyResult:
174
+ """Apply one document and return the server's result."""
175
+ return dg.call(
176
+ dg.pipelines.apply(
177
+ cast("dict[str, Any]", yaml.safe_load(document.text)),
178
+ code=code,
179
+ source=source if source is not None else document.source,
180
+ source_ref=document.ref,
181
+ dry_run=dry_run,
182
+ pause_schedules=paused,
183
+ )
184
+ )
185
+
186
+
187
+ def _print_plan(result: ApplyResult, origin: str) -> bool:
188
+ """Print one apply result the way a plan reads, and say whether it was accepted."""
189
+ plan = result.plan
190
+ if plan.action is PlanAction.INVALID:
191
+ console.print(f"[red]invalid[/] {plan.code} [dim]({result.kind.value}, {origin})[/]")
192
+ for issue in plan.issues:
193
+ console.print(f" [red]-[/] {issue.location}: {issue.message}")
194
+ return False
195
+ version = result.version or plan.next_version
196
+ detail = (
197
+ f"triggers for {plan.pipeline}"
198
+ if result.kind is DocumentKind.TRIGGERS
199
+ else (f"version {version}" if version else "")
200
+ )
201
+ console.print(f"{styled(plan.action.value):<10} {plan.code} [dim]{detail} ({origin})[/]")
202
+ for warning in plan.warnings:
203
+ console.print(f" [yellow]![/] {warning.location}: {warning.message}")
204
+ diff = plan.diff
205
+ if diff and plan.action is PlanAction.UPDATE:
206
+ for label, changed in (
207
+ ("added", diff.steps_added),
208
+ ("removed", diff.steps_removed),
209
+ ("changed", diff.steps_changed),
210
+ ):
211
+ if changed:
212
+ console.print(f" [dim]steps {label}:[/] {', '.join(changed)}")
213
+ for label, flag in (("params", diff.params_changed), ("triggers", diff.triggers_changed)):
214
+ if flag:
215
+ console.print(f" [dim]{label} changed[/]")
216
+ return True
217
+
218
+
219
+ def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
220
+ """Print a pipeline's shape: roots at the margin, each step under what it waits for."""
221
+ lines = render_graph(steps)
222
+ if not lines:
223
+ return
224
+ console.print(f"\n[bold]{escape(title)}[/] {muted('each step under the last one it waits for')}")
225
+ for line in lines:
226
+ console.print(f" {escape(line)}", highlight=False)
227
+
228
+
229
+ def graph_steps(definition: PipelineDefinition) -> list[GraphStep]:
230
+ """Read a definition's steps in the shape the tree is drawn from."""
231
+ return [
232
+ GraphStep(name=name, block=step.block, depends_on=tuple(step.depends_on), rule=step.rule.value)
233
+ for name, step in definition.steps.items()
234
+ ]
235
+
236
+
237
+ def _print_definition_graph(definition: PipelineDefinition) -> None:
238
+ """Print the shape of a document, which needs no instance to be worth seeing."""
239
+ _print_graph(graph_steps(definition), title=f"steps of {definition.code}")
240
+
241
+
242
+ def _runnable(text: str) -> PipelineDefinition:
243
+ """Read a document a local run is about to execute, refusing the kind that cannot be run."""
244
+ definition = load_text(text)
245
+ if not isinstance(definition, PipelineDefinition):
246
+ fail("a triggers document declares clocks for a pipeline and cannot be run; run the pipeline it names")
247
+ return definition
248
+
249
+
250
+ def _print_document_graph(text: str) -> None:
251
+ """Print the shape of a document read from a file, when it parses at all."""
252
+ try:
253
+ definition = load_text(text)
254
+ except DocumentError:
255
+ return
256
+ if isinstance(definition, PipelineDefinition):
257
+ _print_definition_graph(definition)
258
+
259
+
260
+ def apply_command(
261
+ ctx: typer.Context,
262
+ reference: Annotated[str | None, typer.Argument(help="A file, a URL, or - for standard input.")] = None,
263
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan without writing anything.")] = False,
264
+ as_code: Annotated[str | None, typer.Option("--as", help="Register under a different code.")] = None,
265
+ paused: Annotated[
266
+ bool,
267
+ typer.Option("--paused", help="Create the schedules this apply mints paused; existing ones are untouched."),
268
+ ] = False,
269
+ prune: Annotated[
270
+ bool,
271
+ typer.Option(
272
+ "--prune",
273
+ help="Reconcile: deactivate directory-provenance pipelines the project no longer holds. "
274
+ "Marks this apply's documents with directory provenance, so a later prune knows its own.",
275
+ ),
276
+ ] = False,
277
+ ) -> None:
278
+ """Apply a pipeline document, or every document in the project when given none.
279
+
280
+ `--paused` governs what this apply brings into being: a schedule it creates is created
281
+ paused, and a schedule the instance already holds keeps the state it is in. `--prune`
282
+ reconciles the whole project: what the project no longer holds is deactivated, never
283
+ deleted, and only pipelines a directory apply wrote are ever touched.
284
+ """
285
+ state = state_of(ctx)
286
+ if prune and reference is not None:
287
+ fail("--prune reconciles a whole project, so it cannot be used with a single document")
288
+ documents = _documents_to_apply(reference, as_code)
289
+ source = ProvenanceSource.DIRECTORY if prune else None
290
+ with client_for(state) as dg:
291
+ results = [
292
+ (
293
+ _apply_one(dg, document, code=as_code, dry_run=dry_run, paused=paused, source=source),
294
+ document.label,
295
+ )
296
+ for document in documents
297
+ ]
298
+ pruned = None
299
+ if prune:
300
+ keep = [result.plan.code for result, _ in results]
301
+ pruned = dg.call(dg.pipelines.prune(keep, dry_run=dry_run))
302
+ if state_of(ctx).json_output:
303
+ emit_records("apply", [result for result, _ in results])
304
+ if pruned is not None:
305
+ emit_records("prune", [pruned])
306
+ if any(result.plan.action is PlanAction.INVALID for result, _ in results):
307
+ raise typer.Exit(code=1)
308
+ return
309
+ accepted = all(_print_plan(result, origin) for result, origin in results)
310
+ if pruned is not None:
311
+ for code in pruned.pruned:
312
+ console.print(f"{styled('deactivated'):<10} {code} [dim](absent from the project)[/]")
313
+ for code in pruned.trigger_documents_removed:
314
+ console.print(f"{styled('deleted'):<10} {code} [dim](triggers document absent from the project)[/]")
315
+ if not pruned.pruned and not pruned.trigger_documents_removed:
316
+ console.print("[dim]Nothing to prune: the project holds everything it applied.[/]")
317
+ if dry_run:
318
+ for document in documents:
319
+ _print_document_graph(document.text)
320
+ console.print("[dim]Nothing was written: this was a dry run.[/]")
321
+ if not accepted:
322
+ raise typer.Exit(code=1)
323
+
324
+
325
+ def _documents_to_apply(reference: str | None, as_code: str | None) -> list[Document]:
326
+ """Read the document, or every document in the project when none was named."""
327
+ if reference is not None:
328
+ try:
329
+ return [read_document(reference)]
330
+ except SourceError as error:
331
+ fail(str(error))
332
+ project = find_project()
333
+ if project is None:
334
+ fail("no document was named and this directory is not a project; run dg init, or name a file")
335
+ if as_code is not None:
336
+ fail("--as recodes one document, so it cannot be used when applying a whole project")
337
+ try:
338
+ paths = cast("Any", project).documents()
339
+ except ProjectError as error:
340
+ fail(str(error))
341
+ if not paths:
342
+ fail(f"{cast('Any', project).pipelines_dir} holds no documents")
343
+ # Pipelines go before triggers documents: one that names a pipeline the instance does not
344
+ # hold yet is refused, and applying the pipeline first is what makes it present.
345
+ return sorted((read_path(path) for path in paths), key=_declares_triggers)
346
+
347
+
348
+ def _declares_triggers(document: Document) -> bool:
349
+ """Peek at a document's kind, without holding an unreadable one against the format here."""
350
+ try:
351
+ return isinstance(load_text(document.text), TriggersDefinition)
352
+ except DocumentError:
353
+ return False
354
+
355
+
356
+ def export_command(
357
+ ctx: typer.Context,
358
+ code: Annotated[str, typer.Argument(help="The pipeline to export.")],
359
+ file: Annotated[Path | None, typer.Option("-f", "--file", help="Write to a file instead of stdout.")] = None,
360
+ version: Annotated[int | None, typer.Option(help="Export this version instead of the current one.")] = None,
361
+ ) -> None:
362
+ """Export a pipeline as canonical YAML."""
363
+ with client_for(state_of(ctx)) as dg:
364
+ text = dg.call(dg.pipelines.export(code, version=version))
365
+ if state_of(ctx).json_output:
366
+ return emit_fact("pipeline.exported", message="exported", code=code, version=version, document=text)
367
+ if file is None:
368
+ console.print(text, end="", highlight=False, markup=False)
369
+ return
370
+ file.write_text(text)
371
+ console.print(f"Wrote [bold]{file}[/].")
372
+
373
+
374
+ def validate_command(
375
+ ctx: typer.Context,
376
+ reference: Annotated[str | None, typer.Argument(help="A file, a URL, or - for standard input.")] = None,
377
+ server: Annotated[
378
+ bool, typer.Option("--server", help="Also check it against a server's catalog and requirements.")
379
+ ] = False,
380
+ ) -> None:
381
+ """Check a document offline, or against a server's catalog too with --server.
382
+
383
+ Offline, a triggers document is checked at the schema level: its clocks and its codes.
384
+ `--server` adds the pipeline it names and the parameters its schedules pin.
385
+ """
386
+ state = state_of(ctx)
387
+ documents = _documents_to_apply(reference, None)
388
+ failures = 0
389
+ parsed: list[tuple[PipelineDefinition | TriggersDefinition, str]] = []
390
+ for document in documents:
391
+ try:
392
+ parsed.append((load_text(document.text), document.label))
393
+ except DocumentError as error:
394
+ emit_fact(
395
+ "validation",
396
+ level="error",
397
+ message="invalid",
398
+ document=document.label,
399
+ problems=list(error.problems),
400
+ )
401
+ failures += 1
402
+ catalog: Catalog | None = None
403
+ connections: list[str] = []
404
+ pipelines: list[str] = []
405
+ targets: dict[str, TriggerTarget] = {}
406
+ if server:
407
+ with client_for(state) as dg:
408
+ catalog = dg.call(dg.blocks.catalog())
409
+ connections = [
410
+ row.code
411
+ for row in paged(lambda after, size: dg.call(dg.connections.list(after=after, limit=size)), None)
412
+ ]
413
+ pipelines = [
414
+ row.code for row in paged(lambda after, size: dg.call(dg.pipelines.list(after=after, limit=size)), None)
415
+ ]
416
+ targets = _trigger_targets(dg, {one.pipeline for one, _ in parsed if isinstance(one, TriggersDefinition)})
417
+ for definition, label in parsed:
418
+ issues = validate_against_catalog(
419
+ definition,
420
+ catalog or Catalog(),
421
+ connections=connections,
422
+ pipelines=pipelines,
423
+ check_blocks=catalog is not None,
424
+ target=targets.get(definition.pipeline) if isinstance(definition, TriggersDefinition) else None,
425
+ )
426
+ if issues:
427
+ emit_fact(
428
+ "validation",
429
+ level="error",
430
+ message="invalid",
431
+ code=definition.code,
432
+ document=label,
433
+ problems=[str(issue) for issue in issues],
434
+ )
435
+ failures += 1
436
+ continue
437
+ emit_fact(
438
+ "validation",
439
+ message="valid",
440
+ code=definition.code,
441
+ document=label,
442
+ checked="document and catalog" if catalog is not None else "document, offline",
443
+ steps=[step._asdict() for step in graph_steps(definition)]
444
+ if isinstance(definition, PipelineDefinition)
445
+ else None,
446
+ )
447
+ emit_fact(
448
+ "validated",
449
+ level="error" if failures else "info",
450
+ message="invalid" if failures else "valid",
451
+ documents=len(documents),
452
+ invalid=failures,
453
+ )
454
+ if failures:
455
+ raise typer.Exit(code=1)
456
+
457
+
458
+ def _trigger_targets(dg: Session, wanted: set[str]) -> dict[str, TriggerTarget]:
459
+ """Read each pipeline a triggers document names, as the server currently holds it."""
460
+ found: dict[str, TriggerTarget] = {}
461
+ for code in sorted(wanted):
462
+ try:
463
+ detail = dg.call(dg.pipelines.get(code))
464
+ except NotFound:
465
+ continue
466
+ current = load_definition(detail.document) if detail.document else None
467
+ found[code] = TriggerTarget(code=code, active=detail.active, definition=current)
468
+ return found
469
+
470
+
471
+ def init_command(
472
+ ctx: typer.Context,
473
+ directory: Annotated[Path, typer.Argument(help="Where to create the instance and project.")] = Path(),
474
+ template: Annotated[str, typer.Option(help="Which template to scaffold: basic, ci or compose.")] = "basic",
475
+ documents_only: Annotated[
476
+ bool,
477
+ typer.Option("--documents-only", help="Scaffold the documents and initialise no instance."),
478
+ ] = False,
479
+ admin: Annotated[str, typer.Option(help="The first admin account's username.")] = "admin",
480
+ password: Annotated[str | None, typer.Option(help="Its password; prompted for when omitted.")] = None,
481
+ ) -> None:
482
+ """Initialise a uv project and the instance it addresses, ready for `dg dev`.
483
+
484
+ Writes the documents and a `pyproject.toml` pinning the running dirigent, so `uv sync`
485
+ builds the project's environment and `uv run dg` is the runtime it was scaffolded on.
486
+ Then creates the state directory, migrates the schema, creates the first admin and mints
487
+ it a token. `--documents-only` stops after the documents. `--template compose` writes the
488
+ documents and a container stack instead, and initialises nothing locally: the instance is
489
+ the containers.
490
+ """
491
+ import asyncio
492
+
493
+ state = state_of(ctx)
494
+ # Run once by a person at a terminal, so it renders unless records were asked for.
495
+ if not state.chosen:
496
+ configure(output="console")
497
+ root = directory.resolve()
498
+ # Refusing and asking both happen before anything is written, so a run that cannot
499
+ # finish has not half-created a project, and nobody is asked for a password to satisfy
500
+ # a command that was going to fail anyway.
501
+ try:
502
+ check_template(template)
503
+ except ProjectError as error:
504
+ fail(str(error))
505
+ stack = template == COMPOSE_TEMPLATE_NAME
506
+ if stack and documents_only:
507
+ fail("--documents-only does not apply to the compose template, which writes no instance to skip")
508
+ if stack and admin != "admin":
509
+ fail("--admin does not apply to the compose template; the stack's first admin is named admin")
510
+ if not documents_only and not stack:
511
+ _refuse_an_existing_instance(root)
512
+ secret = (
513
+ ""
514
+ if documents_only
515
+ else (password or os.environ.get(BOOTSTRAP_PASSWORD_ENV) or _prompt_for_a_password(stack=stack))
516
+ )
517
+ version = cli_version()
518
+ try:
519
+ made = scaffold(directory, template=template, version=version, password=secret)
520
+ except ProjectError as error:
521
+ fail(str(error))
522
+ left = [_within(path, directory) for path in made.skipped]
523
+ if documents_only or stack:
524
+ starting = [
525
+ "uv sync",
526
+ "docker compose up -d",
527
+ "uv run dg auth login --username admin",
528
+ ]
529
+ emit_fact(
530
+ "project.scaffolded",
531
+ message="scaffolded",
532
+ template=template,
533
+ directory=str(directory),
534
+ version=version,
535
+ files=[_within(path, directory) for path in made.files],
536
+ **({"skipped": left} if left else {}),
537
+ **({"next": starting} if stack else {}),
538
+ )
539
+ return
540
+ settings = instance_settings(root)
541
+ migrated = migrations.head_revision(settings) or "none"
542
+ migrations.upgrade("head", settings)
543
+ token = asyncio.run(first_admin(settings, admin, secret))
544
+ emit_fact(
545
+ "instance.initialised",
546
+ message="initialised",
547
+ template=template,
548
+ directory=str(root),
549
+ state=STATE_DIR,
550
+ schema=migrated,
551
+ admin=admin,
552
+ token=token,
553
+ version=version,
554
+ files=[_within(path, root) for path in made.files],
555
+ **({"skipped": [_within(path, root) for path in made.skipped]} if made.skipped else {}),
556
+ )
557
+
558
+
559
+ #: Where the first admin's password comes from when the command is not asked interactively.
560
+ BOOTSTRAP_PASSWORD_ENV: Final = "DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD"
561
+
562
+
563
+ def instance_settings(root: Path) -> Settings:
564
+ """Point an instance's state at one directory, whatever the working directory is.
565
+
566
+ The defaults are relative, so initialising somewhere else would otherwise migrate the
567
+ schema of the instance the shell happens to be standing in.
568
+ """
569
+ state = root / STATE_DIR
570
+ return Settings(
571
+ database_url=f"sqlite+aiosqlite:///{state / 'dirigent.db'}",
572
+ artifact_root=f"file://{state / 'artifacts'}",
573
+ )
574
+
575
+
576
+ def _refuse_an_existing_instance(root: Path) -> None:
577
+ """Refuse to initialise over an instance that is already there.
578
+
579
+ Migrating and re-admining a live database is not what somebody running init a second
580
+ time means by it, and there is no undo for the version it would apply.
581
+ """
582
+ existing = instance_settings(root).sqlite_path
583
+ if existing is not None and existing.exists():
584
+ refuse(
585
+ f"{existing} already exists, so this directory holds an instance already",
586
+ problems=[
587
+ "dg dev --keep-state starts it",
588
+ "dg db upgrade brings its schema forward",
589
+ "dg init --documents-only scaffolds documents beside it",
590
+ ],
591
+ )
592
+ raise typer.Exit(code=1)
593
+
594
+
595
+ def _prompt_for_a_password(*, stack: bool = False) -> str:
596
+ """Ask for the first admin's password, or say how to give it without a prompt."""
597
+ if not sys.stdin.isatty():
598
+ fail(f"no password for the first admin: pass --password, or set {BOOTSTRAP_PASSWORD_ENV}")
599
+ asked = "password for the stack's first admin" if stack else "password for the first admin"
600
+ return str(typer.prompt(asked, hide_input=True, confirmation_prompt=True))
601
+
602
+
603
+ def cli_version() -> str:
604
+ """The version of dirigent-cli that is running, which is the image tag it scaffolds."""
605
+ from importlib.metadata import PackageNotFoundError, version
606
+
607
+ try:
608
+ return version("dirigent-cli")
609
+ except PackageNotFoundError:
610
+ return "0.0.0"
611
+
612
+
613
+ async def first_admin(settings: Settings, username: str, password: str) -> str:
614
+ """Create the instance's first admin and mint it one token, which is returned once."""
615
+ from dirigent_client.enums import UserRole
616
+ from dirigent_core.auth import AuthError, create_user, issue_token
617
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
618
+
619
+ engine = create_engine(settings)
620
+ try:
621
+ async with session_scope(create_session_factory(engine)) as session:
622
+ user = await create_user(session, username, password, role=UserRole.ADMIN)
623
+ issued = await issue_token(session, user, name="init")
624
+ return issued.secret.get_secret_value()
625
+ except AuthError as error:
626
+ fail(str(error))
627
+ finally:
628
+ await engine.dispose()
629
+
630
+
631
+ def _within(path: Path, directory: Path) -> str:
632
+ """Name a path against the directory it is under, or in full when it is not under it."""
633
+ try:
634
+ return str(path.resolve().relative_to(directory.resolve()))
635
+ except ValueError:
636
+ return str(path)
637
+
638
+
639
+ @pipeline_app.command("list")
640
+ def pipeline_list(
641
+ ctx: typer.Context,
642
+ tag: Annotated[
643
+ list[str] | None,
644
+ typer.Option("--tag", help="Only pipelines wearing this tag; repeat it to name more."),
645
+ ] = None,
646
+ ) -> None:
647
+ """List the pipelines this instance holds, or the ones wearing every tag named."""
648
+ tags = tag or []
649
+ with client_for(state_of(ctx)) as dg:
650
+ rows = list(paged(lambda after, size: dg.call(dg.pipelines.list(after=after, limit=size, tags=tags)), None))
651
+ if state_of(ctx).json_output:
652
+ return emit_records("pipeline", rows)
653
+ table(
654
+ "pipelines",
655
+ ["code", "name", "tags", "version", "active", "runs in flight", "updated"],
656
+ [
657
+ [
658
+ row.code,
659
+ row.name or "-",
660
+ " ".join(row.tags) or "-",
661
+ str(row.current_version or "-"),
662
+ render_bool(row.active),
663
+ str(row.active_runs),
664
+ moment(row.updated_at),
665
+ ]
666
+ for row in rows
667
+ ],
668
+ )
669
+
670
+
671
+ @pipeline_app.command("show")
672
+ def pipeline_show(
673
+ ctx: typer.Context,
674
+ code: Annotated[str, typer.Argument(help="The pipeline to show.")],
675
+ ) -> None:
676
+ """Show a pipeline, and the shape of the document its current version holds."""
677
+ with client_for(state_of(ctx)) as dg:
678
+ row = dg.call(dg.pipelines.get(code))
679
+ if state_of(ctx).json_output:
680
+ return emit_one("pipeline", row)
681
+ fields(
682
+ f"pipeline {code}",
683
+ {
684
+ "id": row.id,
685
+ "name": row.name or "-",
686
+ "description": row.description or "-",
687
+ "tags": " ".join(row.tags) or "-",
688
+ "active": "yes" if row.active else "no",
689
+ "current version": row.current_version,
690
+ "runs in flight": row.active_runs,
691
+ "created": moment(row.created_at),
692
+ },
693
+ )
694
+ if row.document:
695
+ _print_graph(steps_of_document(row.document))
696
+
697
+
698
+ @pipeline_app.command("versions")
699
+ def pipeline_versions(
700
+ ctx: typer.Context,
701
+ code: Annotated[str, typer.Argument(help="The pipeline whose history to list.")],
702
+ ) -> None:
703
+ """List a pipeline's immutable versions, newest first, with their provenance."""
704
+ with client_for(state_of(ctx)) as dg:
705
+ rows = list(paged(lambda after, size: dg.call(dg.pipelines.versions(code, after=after, limit=size)), None))
706
+ if state_of(ctx).json_output:
707
+ return emit_records("pipeline_version", rows)
708
+ table(
709
+ f"versions of {code}",
710
+ ["version", "digest", "source", "applied by", "created"],
711
+ [
712
+ [
713
+ str(row.version),
714
+ row.digest[7:19],
715
+ row.provenance_ref or row.provenance_source.value,
716
+ row.applied_by or "-",
717
+ moment(row.created_at),
718
+ ]
719
+ for row in rows
720
+ ],
721
+ )
722
+
723
+
724
+ @pipeline_app.command("validate")
725
+ def pipeline_validate(
726
+ ctx: typer.Context,
727
+ code: Annotated[str | None, typer.Argument(help="The pipeline to re-check; omitted with --all.")] = None,
728
+ version: Annotated[int | None, typer.Option("--version", help="Check this version, not the current one.")] = None,
729
+ every: Annotated[bool, typer.Option("--all", help="Check every pipeline this instance holds.")] = False,
730
+ ) -> None:
731
+ """Re-check what the instance already holds against what it has now.
732
+
733
+ An instance drifts -- a plugin uninstalled, a connection deleted, the allowlist tightened
734
+ -- and a pipeline that applied cleanly then fails when it next runs. This is the check
735
+ apply makes, run again on demand.
736
+ """
737
+ state = state_of(ctx)
738
+ if every and (code is not None or version is not None):
739
+ fail("--all checks every pipeline, so it takes neither a code nor a version")
740
+ if not every and code is None:
741
+ fail("name a pipeline, or pass --all to check every one of them")
742
+ with client_for(state) as dg:
743
+ codes = (
744
+ [row.code for row in paged(lambda after, size: dg.call(dg.pipelines.list(after=after, limit=size)), None)]
745
+ if every
746
+ else [cast("str", code)]
747
+ )
748
+ checked: dict[str, list[ValidationIssue]] = {
749
+ one: list(dg.call(dg.pipelines.validate(one, version=version))) for one in codes
750
+ }
751
+ if state.json_output:
752
+ for one, found in checked.items():
753
+ emit_record(
754
+ "validation",
755
+ code=one,
756
+ valid=not found,
757
+ issues=[issue.model_dump(mode="json") for issue in found],
758
+ )
759
+ if any(checked.values()):
760
+ raise typer.Exit(code=1)
761
+ return
762
+ for one, found in checked.items():
763
+ if not found:
764
+ console.print(f"[green]valid[/] {one}")
765
+ continue
766
+ console.print(f"[red]invalid[/] {one}")
767
+ for issue in found:
768
+ stream_line(f" {issue}")
769
+ if any(checked.values()):
770
+ raise typer.Exit(code=1)
771
+
772
+
773
+ @pipeline_app.command("activate")
774
+ def pipeline_activate(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
775
+ """Make a pipeline runnable again."""
776
+ with client_for(state_of(ctx)) as dg:
777
+ dg.call(dg.pipelines.activate(code))
778
+ emit_fact("pipeline.activated", message="activated", code=code)
779
+
780
+
781
+ @pipeline_app.command("deactivate")
782
+ def pipeline_deactivate(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
783
+ """Stop a pipeline being runnable. Its history is kept."""
784
+ with client_for(state_of(ctx)) as dg:
785
+ dg.call(dg.pipelines.deactivate(code))
786
+ emit_fact("pipeline.deactivated", message="deactivated", code=code)
787
+
788
+
789
+ @pipeline_app.command("delete")
790
+ def pipeline_delete(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
791
+ """Delete a pipeline and every run ever attributed to it. Runs in flight refuse it."""
792
+ with client_for(state_of(ctx)) as dg:
793
+ dg.call(dg.pipelines.delete(code))
794
+ emit_fact("pipeline.deleted", message="deleted", code=code, runs="deleted")
795
+
796
+
797
+ def parse_params(
798
+ pairs: list[str] | None,
799
+ *,
800
+ schema: dict[str, Any] | None = None,
801
+ files: list[Path] | None = None,
802
+ ) -> dict[str, Any]:
803
+ """Build a run's parameters from files and flags, coerced against the pipeline's schema."""
804
+ try:
805
+ return build_params(schema or {}, pairs=pairs or [], files=files or [])
806
+ except ParamError as error:
807
+ fail(str(error))
808
+
809
+
810
+ #: What separates the two ends of a ``--window`` value.
811
+ WINDOW_SEPARATOR: Final = ".."
812
+
813
+ #: The grammar, said once, so every refusal says the same thing.
814
+ WINDOW_GRAMMAR: Final = "--window takes START..END: two ISO 8601 instants separated by '..'"
815
+
816
+
817
+ def read_instant(value: str, flag: str) -> datetime:
818
+ """Read one ISO 8601 instant off a flag, anchored in UTC where it names no offset.
819
+
820
+ A bare flag names no timezone, and a value whose meaning depended on the shell's own
821
+ would put the same command on two different windows on two machines.
822
+ """
823
+ try:
824
+ moment = datetime.fromisoformat(value.strip())
825
+ except ValueError as error:
826
+ raise ParamError(f"{flag} takes one ISO 8601 instant, and {value!r} is not one: {error}") from error
827
+ return moment.replace(tzinfo=UTC) if moment.tzinfo is None else moment
828
+
829
+
830
+ def check_interval(start: datetime, end: datetime) -> None:
831
+ """Refuse an empty or backwards interval, which covers nothing at all."""
832
+ if start >= end:
833
+ raise ParamError(
834
+ f"an interval runs forwards and covers something: {start.isoformat()} is not before {end.isoformat()}"
835
+ )
836
+
837
+
838
+ def read_window(value: str) -> tuple[datetime, datetime]:
839
+ """Read a ``START..END`` window, or say what the grammar is."""
840
+ start_text, separator, end_text = value.partition(WINDOW_SEPARATOR)
841
+ if not separator:
842
+ raise ParamError(f"{WINDOW_GRAMMAR}, not {value!r}")
843
+ start = read_instant(start_text, "--window")
844
+ end = read_instant(end_text, "--window")
845
+ check_interval(start, end)
846
+ return start, end
847
+
848
+
849
+ def parse_window(value: str | None) -> tuple[datetime, datetime] | None:
850
+ """Read the ``--window`` flag, or nothing where it was not given."""
851
+ if value is None:
852
+ return None
853
+ try:
854
+ return read_window(value)
855
+ except ParamError as error:
856
+ fail(str(error))
857
+
858
+
859
+ def parse_log_levels(values: list[str] | None) -> dict[str, LogLevel] | None:
860
+ """Read the repeated ``--log-level`` flag into the map a run carries.
861
+
862
+ A bare level means every block: ``--log-level debug`` is ``{"*": debug}``. A
863
+ ``PATTERN=LEVEL`` pair names one family, and a later repeat of the same pattern wins.
864
+ """
865
+ if not values:
866
+ return None
867
+ levels: dict[str, LogLevel] = {}
868
+ for value in values:
869
+ pattern, _, named = value.partition("=")
870
+ if not named:
871
+ pattern, named = "*", value
872
+ if not pattern:
873
+ fail(f"--log-level {value!r} names no pattern before the =")
874
+ try:
875
+ levels[pattern] = LogLevel(named.lower())
876
+ except ValueError:
877
+ allowed = ", ".join(level.value for level in LogLevel)
878
+ fail(f"--log-level {value!r}: {named!r} is not a level ({allowed})")
879
+ return levels
880
+
881
+
882
+ def parse_priority(value: str | None) -> RunPriority | None:
883
+ """Read the priority a run was asked for, naming the vocabulary when it is not one."""
884
+ if value is None:
885
+ return None
886
+ try:
887
+ return RunPriority(value.lower())
888
+ except ValueError:
889
+ fail(f"--priority {value!r} is not a priority ({', '.join(priority.value for priority in RunPriority)})")
890
+
891
+
892
+ def run_command(
893
+ ctx: typer.Context,
894
+ target: Annotated[str, typer.Argument(help="A pipeline code, or a document file, URL, or -.")],
895
+ param: Annotated[
896
+ list[str] | None,
897
+ typer.Option(
898
+ "-p",
899
+ "--param",
900
+ help="key=value, repeatable; a dotted key addresses a nested leaf and [i] an array element.",
901
+ ),
902
+ ] = None,
903
+ params_file: Annotated[
904
+ list[Path] | None,
905
+ typer.Option("-P", "--params-file", help="A whole parameter payload, in YAML or JSON."),
906
+ ] = None,
907
+ watch: Annotated[bool, typer.Option("--watch", help="Poll until the run finishes.")] = False,
908
+ local: Annotated[bool, typer.Option("--local", help="Run it here, with no server at all.")] = False,
909
+ as_code: Annotated[str | None, typer.Option("--as", help="Register a document under another code.")] = None,
910
+ connections: Annotated[
911
+ Path | None,
912
+ typer.Option("--connections", help="A connections document the --local run creates its credentials from."),
913
+ ] = None,
914
+ schema_files: Annotated[
915
+ list[Path] | None,
916
+ typer.Option(
917
+ "--schema", help="A JSON Schema file a --local run holds by code, so a document may name it; repeatable."
918
+ ),
919
+ ] = None,
920
+ also_apply: Annotated[
921
+ list[Path] | None,
922
+ typer.Option(
923
+ "--also-apply", help="Another document a --local run should apply first, such as a child pipeline."
924
+ ),
925
+ ] = None,
926
+ enable_unsafe: Annotated[
927
+ list[str] | None,
928
+ typer.Option(
929
+ "--enable-unsafe",
930
+ help="Allow a block that runs code on the worker; repeatable, or comma-separated.",
931
+ ),
932
+ ] = None,
933
+ strict: Annotated[bool, typer.Option("--strict", help="Treat completed_with_errors as a failure.")] = False,
934
+ keep: Annotated[
935
+ bool,
936
+ typer.Option("--keep", help="Leave a --local run's throwaway instance on disk, and say where."),
937
+ ] = False,
938
+ root: Annotated[
939
+ Path | None,
940
+ typer.Option("--root", help="Hold a --local run's instance in DIR and keep it, so a later run reads it."),
941
+ ] = None,
942
+ window: Annotated[
943
+ str | None,
944
+ typer.Option("--window", help="The logical interval this run covers, as START..END in ISO 8601."),
945
+ ] = None,
946
+ log_level: Annotated[
947
+ list[str] | None,
948
+ typer.Option(
949
+ "--log-level",
950
+ help="A level to keep (debug) or PATTERN=LEVEL for one block family; repeatable. "
951
+ "Omitted keeps info and up.",
952
+ ),
953
+ ] = None,
954
+ priority: Annotated[
955
+ str | None,
956
+ typer.Option(
957
+ "--priority",
958
+ help="How far ahead of other runs this one is claimed: low, normal, or high. "
959
+ "Omitted takes the pipeline's own.",
960
+ ),
961
+ ] = None,
962
+ ) -> None:
963
+ """Run a pipeline by code, or apply and run a document in one command.
964
+
965
+ A code starts a run of a stored pipeline. A document is applied first, as ``dg apply``
966
+ would, and then run. ``--local`` does either in a throwaway instance with no server.
967
+ """
968
+ state = state_of(ctx)
969
+ if strict and not (watch or local):
970
+ fail("--strict decides on a run's outcome, so it needs --watch or --local")
971
+ if keep and not local:
972
+ fail("--keep leaves a --local run's throwaway instance behind; a real instance keeps its own")
973
+ if root is not None and not local:
974
+ fail("--root holds a --local run's instance in a directory; a real instance has its own")
975
+ covered = parse_window(window)
976
+ levels = parse_log_levels(log_level)
977
+ wanted = parse_priority(priority)
978
+ if wanted is not None and local:
979
+ fail("--priority orders a run against the others queued, and a --local run has none")
980
+ if local:
981
+ _run_locally(
982
+ state,
983
+ target,
984
+ param or [],
985
+ params_file or [],
986
+ connections,
987
+ schema_files or [],
988
+ also_apply or [],
989
+ enable_unsafe or [],
990
+ window=covered,
991
+ log_levels={pattern: level.value for pattern, level in levels.items()} if levels else None,
992
+ strict=strict,
993
+ keep=keep,
994
+ root=root,
995
+ )
996
+ return
997
+ with client_for(state) as dg:
998
+ code = target
999
+ schema: dict[str, Any] = {}
1000
+ if looks_like_a_document(target):
1001
+ try:
1002
+ document = read_document(target)
1003
+ schema = dict(load_pipeline_text(document.text).params)
1004
+ except (SourceError, DocumentError) as error:
1005
+ fail(str(error))
1006
+ result = _apply_one(dg, document, code=as_code, dry_run=False)
1007
+ if not _print_plan(result, document.label):
1008
+ if state.json_output:
1009
+ emit_problem(
1010
+ f"{result.plan.code} does not validate",
1011
+ status=422,
1012
+ title="Unprocessable Content",
1013
+ problems=[f"{issue.location}: {issue.message}" for issue in result.plan.issues],
1014
+ )
1015
+ raise typer.Exit(code=1)
1016
+ code = result.plan.code
1017
+ else:
1018
+ stored = dg.call(dg.pipelines.get(code))
1019
+ schema = dict((stored.document or {}).get("params") or {})
1020
+ params = parse_params(param, schema=schema, files=params_file)
1021
+ accepted = dg.call(dg.pipelines.run(code, params=params, window=covered, log_levels=levels, priority=wanted))
1022
+ if accepted.run_id is None:
1023
+ if state.json_output:
1024
+ emit_problem(accepted.detail or "the concurrency policy refused this run", status=409, title="Skipped")
1025
+ return
1026
+ console.print(f"[yellow]skipped[/] {accepted.detail or 'the concurrency policy refused this run'}")
1027
+ return
1028
+ sink(state).event(
1029
+ "run",
1030
+ message="started",
1031
+ pipeline=code,
1032
+ run_id=str(accepted.run_id),
1033
+ local=False,
1034
+ **({"priority": wanted.value} if wanted is not None else {}),
1035
+ )
1036
+ if watch:
1037
+ _watch(dg, accepted.run_id, code, strict=strict, state=state)
1038
+
1039
+
1040
+ def backfill_command(
1041
+ ctx: typer.Context,
1042
+ pipeline: Annotated[str, typer.Argument(help="The pipeline whose windows are being filled.")],
1043
+ schedule: Annotated[
1044
+ str,
1045
+ typer.Option("--schedule", help="The schedule on that pipeline whose cadence defines the windows."),
1046
+ ],
1047
+ from_: Annotated[
1048
+ str,
1049
+ typer.Option("--from", help="First instant to enumerate, inclusive; ISO 8601, UTC where no offset is given."),
1050
+ ],
1051
+ to: Annotated[
1052
+ str,
1053
+ typer.Option("--to", help="Last instant to enumerate, exclusive; ISO 8601, UTC where no offset is given."),
1054
+ ],
1055
+ dry_run: Annotated[
1056
+ bool,
1057
+ typer.Option("--dry-run", help="Ask the server for the plan and create nothing."),
1058
+ ] = False,
1059
+ ) -> None:
1060
+ """Create one run per window a schedule's cadence has already gone past.
1061
+
1062
+ The schedule's own clock is untouched: this fills what is behind it. Every run is
1063
+ attributed to the backfill, carries the window that firing would have carried, and is
1064
+ given the schedule's own pinned parameters.
1065
+ """
1066
+ state = state_of(ctx)
1067
+ try:
1068
+ start = read_instant(from_, "--from")
1069
+ end = read_instant(to, "--to")
1070
+ check_interval(start, end)
1071
+ except ParamError as error:
1072
+ fail(str(error))
1073
+ with client_for(state) as dg:
1074
+ accepted = dg.call(dg.pipelines.backfill(pipeline, schedule=schedule, start=start, end=end, dry_run=dry_run))
1075
+ _report_backfill(accepted, state=state)
1076
+
1077
+
1078
+ def _report_backfill(accepted: BackfillAccepted, *, state: CliState) -> None:
1079
+ """Write what the backfill amounted to as one record, windows and all."""
1080
+ sink(state).event(
1081
+ "backfill",
1082
+ message="planned" if accepted.dry_run else "created",
1083
+ pipeline=accepted.pipeline,
1084
+ schedule=accepted.schedule,
1085
+ dry_run=accepted.dry_run,
1086
+ windows_total=len(accepted.windows),
1087
+ runs_created=accepted.created,
1088
+ windows=[one.model_dump(mode="json") for one in accepted.windows],
1089
+ )
1090
+
1091
+
1092
+ def _run_locally(
1093
+ state: CliState,
1094
+ target: str,
1095
+ pairs: list[str],
1096
+ params_files: list[Path],
1097
+ connections: Path | None,
1098
+ schemas_files: list[Path],
1099
+ also_apply: list[Path],
1100
+ enable_unsafe: list[str],
1101
+ *,
1102
+ window: tuple[datetime, datetime] | None = None,
1103
+ log_levels: dict[str, str] | None = None,
1104
+ strict: bool,
1105
+ keep: bool = False,
1106
+ root: Path | None = None,
1107
+ ) -> None:
1108
+ """Apply and run a document in a throwaway instance, streaming what it logs."""
1109
+ if state.url is not None or state.profile is not None or state.token is not None:
1110
+ fail("--local runs here with no server, so it cannot be combined with --url, --token, or --profile")
1111
+ if not looks_like_a_document(target):
1112
+ fail(f"--local runs a document, and {target!r} is not a file, a URL, or '-'")
1113
+ try:
1114
+ document = read_document(target)
1115
+ specs: list[ConnectionSpec] = load_connection_specs(connections) if connections else []
1116
+ schema_specs = [load_schema_specs(path) for path in schemas_files]
1117
+ supporting = [read_document(str(path)).text for path in also_apply]
1118
+ schema = dict(_runnable(document.text).params)
1119
+ except (SourceError, LocalError, DocumentError) as error:
1120
+ fail(str(error))
1121
+ params = parse_params(pairs, schema=schema, files=params_files)
1122
+ settings = _local_settings(enable_unsafe)
1123
+ outcome = asyncio.run(
1124
+ _stream_local(
1125
+ document.text,
1126
+ params,
1127
+ specs,
1128
+ schema_specs,
1129
+ supporting,
1130
+ settings,
1131
+ window=window,
1132
+ log_levels=log_levels,
1133
+ state=state,
1134
+ keep=keep,
1135
+ root=root,
1136
+ )
1137
+ )
1138
+ if outcome is None: # pragma: no cover - the generator always ends with an outcome
1139
+ fail("the local run produced no outcome")
1140
+ _report_outcome(outcome, strict=strict, state=state)
1141
+
1142
+
1143
+ def split_unsafe(values: list[str]) -> list[str]:
1144
+ """Read the --enable-unsafe flag, which is repeatable and also takes a comma-separated list."""
1145
+ return [block.strip() for value in values for block in value.split(",") if block.strip()]
1146
+
1147
+
1148
+ def _local_settings(enable_unsafe: list[str]) -> Any:
1149
+ """Fold --enable-unsafe into the settings a local run inherits.
1150
+
1151
+ The flag adds to the allowlist for this command; it never turns the gate off.
1152
+ """
1153
+ from dirigent_core.config import get_settings
1154
+
1155
+ settings = get_settings()
1156
+ named = split_unsafe(enable_unsafe)
1157
+ if not named:
1158
+ return settings
1159
+ allowed = sorted({*settings.enabled_unsafe_blocks, *named})
1160
+ return settings.model_copy(update={"enabled_unsafe_blocks": allowed})
1161
+
1162
+
1163
+ async def _stream_local(
1164
+ text: str,
1165
+ params: dict[str, Any],
1166
+ connections: list[ConnectionSpec],
1167
+ schemas: list[SchemaSpec],
1168
+ also: list[str],
1169
+ settings: Any,
1170
+ *,
1171
+ window: tuple[datetime, datetime] | None = None,
1172
+ log_levels: dict[str, str] | None = None,
1173
+ state: CliState,
1174
+ keep: bool = False,
1175
+ root: Path | None = None,
1176
+ ) -> LocalOutcome | None:
1177
+ """Drive a local run, printing each transition and log line as it lands."""
1178
+ outcome: LocalOutcome | None = None
1179
+ covered = RunWindow(start=window[0], end=window[1]) if window else None
1180
+ try:
1181
+ async for item in run_document(
1182
+ text,
1183
+ params=params,
1184
+ window=covered,
1185
+ log_levels=dict(log_levels) if log_levels else None,
1186
+ connections=connections,
1187
+ schemas=schemas,
1188
+ also=also,
1189
+ inherited=settings,
1190
+ keep=keep,
1191
+ root=root,
1192
+ ):
1193
+ match item:
1194
+ case LocalStarted():
1195
+ track_steps(item.steps)
1196
+ sink(state).event(
1197
+ "run",
1198
+ message="started",
1199
+ pipeline=item.pipeline,
1200
+ run_id=str(item.run_id),
1201
+ local=True,
1202
+ scratch=item.scratch,
1203
+ root=str(item.root),
1204
+ )
1205
+ use_scratch_prefix(item.scratch)
1206
+ case StepTransition():
1207
+ _show_transition(item, state=state)
1208
+ case LogLine():
1209
+ _show_log(item, state=state)
1210
+ case LocalOutcome():
1211
+ outcome = item
1212
+ except LocalError as error:
1213
+ remedies = (
1214
+ [
1215
+ "for this run only: dg run --local ... --enable-unsafe shell.run",
1216
+ "for the instance: export DIRIGENT_ENABLED_UNSAFE_BLOCKS='[\"shell.run\"]'",
1217
+ ]
1218
+ if "DIRIGENT_ENABLED_UNSAFE_BLOCKS" in str(error)
1219
+ else []
1220
+ )
1221
+ refuse(str(error), status=GUARD_EXIT, title="Refused", problems=remedies)
1222
+ raise typer.Exit(code=GUARD_EXIT) from error
1223
+ return outcome
1224
+
1225
+
1226
+ def sink(state: CliState) -> Sink:
1227
+ """The writer this invocation's story goes to, in the spelling it asked for."""
1228
+ return Sink(state.output)
1229
+
1230
+
1231
+ def _show_transition(item: StepTransition, *, state: CliState) -> None:
1232
+ """Write one attempt's change of state, and what it produced once it has settled."""
1233
+ level = state.detail
1234
+ out = sink(state)
1235
+ out.event(
1236
+ "step",
1237
+ at=item.at,
1238
+ step=item.step,
1239
+ item=item.item,
1240
+ message=item.status.value,
1241
+ block=item.block,
1242
+ attempt=item.attempt,
1243
+ **_settled_fields(item),
1244
+ )
1245
+ if not item.settled or not (item.output or item.spill):
1246
+ return
1247
+ if level is Detail.SUMMARY:
1248
+ return
1249
+ out.event(
1250
+ "output",
1251
+ at=item.at,
1252
+ step=item.step,
1253
+ item=item.item,
1254
+ message=item.status.value,
1255
+ **_output_fields(item),
1256
+ )
1257
+
1258
+
1259
+ def _show_log(item: LogLine, *, state: CliState) -> None:
1260
+ """Write one line a block logged."""
1261
+ if item.level == "debug" and state.detail is not Detail.FULL:
1262
+ return
1263
+ sink(state).event(
1264
+ "log",
1265
+ at=item.at,
1266
+ level=item.level,
1267
+ step=item.step,
1268
+ item=item.item,
1269
+ message=item.message,
1270
+ fields=dict(item.fields),
1271
+ )
1272
+
1273
+
1274
+ def _settled_fields(item: StepTransition) -> dict[str, Any]:
1275
+ """The fields a transition carries once it has settled, and nothing before."""
1276
+ return {"duration_ms": item.duration_ms} if item.settled and item.duration_ms is not None else {}
1277
+
1278
+
1279
+ def _output_fields(item: StepTransition) -> dict[str, Any]:
1280
+ """What a settled step produced: the values themselves, or the artifact holding them."""
1281
+ if item.spill is not None:
1282
+ return {"artifact": item.spill.uri, "bytes": item.spill.size_bytes}
1283
+ return dict(item.output or {})
1284
+
1285
+
1286
+ def _attempt_settled_fields(attempt: AttemptOut) -> dict[str, Any]:
1287
+ """The same, for an attempt read back from a server."""
1288
+ duration = _attempt_duration_ms(attempt)
1289
+ return {"duration_ms": duration} if duration is not None else {}
1290
+
1291
+
1292
+ def _attempt_output_fields(attempt: AttemptOut) -> dict[str, Any]:
1293
+ """The same, for an attempt read back from a server."""
1294
+ if attempt.output_uri is not None:
1295
+ return {"artifact": attempt.output_uri, "bytes": attempt.output_bytes}
1296
+ return dict(attempt.output or {})
1297
+
1298
+
1299
+ def _uri(spill: Spill | None) -> str | None:
1300
+ """Read a spill's URI, or nothing when the output inlined."""
1301
+ return spill.uri if spill is not None else None
1302
+
1303
+
1304
+ def _bytes(spill: Spill | None) -> int | None:
1305
+ """Read a spill's size, or nothing when the output inlined."""
1306
+ return spill.size_bytes if spill is not None else None
1307
+
1308
+
1309
+ def _report_outcome(outcome: LocalOutcome, *, strict: bool, state: CliState) -> None:
1310
+ """Print a local run's outcome and exit with the code CI should read."""
1311
+ tolerated = outcome.tolerated and not strict
1312
+ code = 0 if tolerated else outcome.exit_code
1313
+ out = sink(state)
1314
+ finished = _local_finished_event(outcome, code)
1315
+ out.event(
1316
+ "run",
1317
+ level="error" if outcome.exit_code else "info",
1318
+ message=outcome.status.value,
1319
+ pipeline=outcome.pipeline,
1320
+ run_id=str(outcome.run_id),
1321
+ exit_code=code,
1322
+ error=outcome.error,
1323
+ steps=[step.model_dump(mode="json") for step in finished.steps],
1324
+ failures=[row.model_dump(mode="json") for row in finished.failures],
1325
+ kept_at=finished.kept_at,
1326
+ )
1327
+ if not out.rendered:
1328
+ if code:
1329
+ raise typer.Exit(code=code)
1330
+ return
1331
+ if tolerated:
1332
+ console.print("[yellow]warning[/] the run completed with errors; --strict would fail on this")
1333
+ return
1334
+ if code:
1335
+ raise typer.Exit(code=code)
1336
+
1337
+
1338
+ def _local_finished_event(outcome: LocalOutcome, code: int) -> schemas.RunFinished:
1339
+ """Turn a local run's outcome into the closing event of its stream."""
1340
+ return schemas.RunFinished(
1341
+ run_id=outcome.run_id,
1342
+ pipeline=outcome.pipeline,
1343
+ status=outcome.status.value,
1344
+ error=outcome.error,
1345
+ exit_code=code,
1346
+ steps=[
1347
+ schemas.StepSummary(
1348
+ step=row.step,
1349
+ item=row.item,
1350
+ block=row.block,
1351
+ status=row.status.value,
1352
+ depends_on=row.depends_on,
1353
+ warnings=row.warnings,
1354
+ duration_ms=row.duration_ms,
1355
+ output=row.output,
1356
+ artifact_uri=_uri(row.spill),
1357
+ artifact_bytes=_bytes(row.spill),
1358
+ )
1359
+ for row in outcome.results
1360
+ ],
1361
+ failures=[
1362
+ schemas.FailureSummary(
1363
+ step=row.step,
1364
+ block=row.block,
1365
+ attempt=row.attempt,
1366
+ error_class=row.error_class,
1367
+ error=row.error,
1368
+ logs=row.logs,
1369
+ input=row.input,
1370
+ )
1371
+ for row in outcome.failures
1372
+ ],
1373
+ kept_at=str(outcome.kept_at) if outcome.kept_at is not None else None,
1374
+ )
1375
+
1376
+
1377
+ # Tie-break for two things stamped with the same instant: a transition prints before the
1378
+ # output it caused.
1379
+ _TRANSITION_FIRST = 0
1380
+ _LOG_SECOND = 1
1381
+
1382
+ #: One thing that happened, in the order it happened: when, its tie-break rank, and what.
1383
+ type WatchEvent = tuple[datetime, int, AttemptOut | LogEntryOut, str | None]
1384
+
1385
+
1386
+ def moment_of(value: object) -> datetime:
1387
+ """Read a server timestamp for ordering, treating a missing one as the beginning of time."""
1388
+ if isinstance(value, datetime):
1389
+ return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
1390
+ if not isinstance(value, str):
1391
+ return datetime.min.replace(tzinfo=UTC)
1392
+ try:
1393
+ parsed = datetime.fromisoformat(value)
1394
+ except ValueError: # pragma: no cover - the server writes ISO instants
1395
+ return datetime.min.replace(tzinfo=UTC)
1396
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
1397
+
1398
+
1399
+ def item_labels(items: Sequence[ItemOut], attempts: Sequence[AttemptOut]) -> dict[UUID, str]:
1400
+ """Map each attempt of a fan-out step to the element it was created for."""
1401
+ keys = {item.id: item.item_key.strip() or str(item.item_index) for item in items}
1402
+ return {
1403
+ attempt.id: keys[attempt.run_item_id]
1404
+ for attempt in attempts
1405
+ if attempt.run_item_id is not None and attempt.run_item_id in keys
1406
+ }
1407
+
1408
+
1409
+ def run_items(dg: Session, run_id: UUID) -> list[ItemOut]:
1410
+ """Walk a run's fan-out items, which the detail counts rather than carries."""
1411
+ return list(paged(lambda after, size: dg.call(dg.runs.items(run_id, after=after, limit=size)), None))
1412
+
1413
+
1414
+ def run_attempts(
1415
+ dg: Session,
1416
+ run_id: UUID,
1417
+ *,
1418
+ step: str | None = None,
1419
+ status: AttemptStatus | None = None,
1420
+ ) -> list[AttemptOut]:
1421
+ """Walk a run's attempts, which the detail counts rather than carries."""
1422
+ return list(
1423
+ paged(
1424
+ lambda after, size: dg.call(dg.runs.attempts(run_id, step=step, status=status, after=after, limit=size)),
1425
+ None,
1426
+ )
1427
+ )
1428
+
1429
+
1430
+ def in_run_order(attempts: Sequence[AttemptOut], items: Sequence[ItemOut], steps: Sequence[str]) -> list[AttemptOut]:
1431
+ """Order walked attempts the way a person watched them, which creation order is not.
1432
+
1433
+ A step sorts on when it first started rather than on when its rows were written, and the
1434
+ order the DAG names its nodes in is the written order for the steps that never started.
1435
+ """
1436
+ indexes = {item.id: item.item_index for item in items}
1437
+ positions = {
1438
+ attempt.id: indexes[attempt.run_item_id]
1439
+ for attempt in attempts
1440
+ if attempt.run_item_id is not None and attempt.run_item_id in indexes
1441
+ }
1442
+ return in_execution_order(attempts, positions, steps)
1443
+
1444
+
1445
+ def transition_event(attempt: AttemptEvent, seen: dict[str, str]) -> list[WatchEvent]:
1446
+ """Report one streamed attempt when it has moved since the stream last named it.
1447
+
1448
+ The server replays every attempt on connect and after a reconnection, so an attempt in a
1449
+ state already printed is nothing to say.
1450
+ """
1451
+ key = f"{attempt.step_name}#{attempt.item or ''}#{attempt.attempt}"
1452
+ if seen.get(key) == attempt.status.value or attempt.status is AttemptStatus.PENDING:
1453
+ return []
1454
+ seen[key] = attempt.status.value
1455
+ return [(moment_of(attempt.finished_at or attempt.started_at), _TRANSITION_FIRST, attempt, attempt.item)]
1456
+
1457
+
1458
+ def log_events(entries: list[LogEntryOut], labels: dict[UUID, str] | None = None) -> list[WatchEvent]:
1459
+ """Report a page of log entries, with the fan-out element each was written by."""
1460
+ found = labels or {}
1461
+ return [
1462
+ (moment_of(entry.created_at), _LOG_SECOND, entry, found.get(entry.step_attempt_id or UUID(int=0)))
1463
+ for entry in entries
1464
+ ]
1465
+
1466
+
1467
+ def print_events(events: list[WatchEvent], level: Detail = Detail.SUMMARY, *, out: Sink | None = None) -> None:
1468
+ """Write transitions and log lines in the order they happened."""
1469
+ writer = out or Sink()
1470
+ for when, _, subject, label in sorted(events, key=lambda event: (event[0], event[1])):
1471
+ if isinstance(subject, LogEntryOut):
1472
+ if subject.level is LogLevel.DEBUG and level is not Detail.FULL:
1473
+ continue
1474
+ writer.event(
1475
+ "log",
1476
+ at=subject.created_at,
1477
+ level=subject.level.value,
1478
+ step=subject.step_name,
1479
+ item=label,
1480
+ message=subject.message,
1481
+ fields=dict(subject.fields or {}),
1482
+ )
1483
+ continue
1484
+ writer.event(
1485
+ "step",
1486
+ at=when,
1487
+ step=subject.step_name,
1488
+ item=label,
1489
+ message=subject.status.value,
1490
+ block=subject.block_id,
1491
+ attempt=subject.attempt,
1492
+ **_attempt_settled_fields(subject),
1493
+ )
1494
+ if subject.status is not AttemptStatus.SUCCEEDED or not (subject.output or subject.output_uri):
1495
+ continue
1496
+ if level is Detail.SUMMARY:
1497
+ continue
1498
+ writer.event(
1499
+ "output",
1500
+ at=when,
1501
+ step=subject.step_name,
1502
+ item=label,
1503
+ message=subject.status.value,
1504
+ **_attempt_output_fields(subject),
1505
+ )
1506
+
1507
+
1508
+ def _attempt_duration_ms(attempt: AttemptOut) -> int | None:
1509
+ """Report how long one attempt took, or nothing when it never started or never finished."""
1510
+ if attempt.started_at is None or attempt.finished_at is None:
1511
+ return None
1512
+ return round((attempt.finished_at - attempt.started_at).total_seconds() * 1000)
1513
+
1514
+
1515
+ def _watch(dg: Session, run_id: UUID, pipeline: str, *, strict: bool, state: CliState) -> None:
1516
+ """Read a run's event stream to its end, printing its transitions and its blocks' output.
1517
+
1518
+ The stream ends with the run's terminal state; the closing record is read from the run
1519
+ detail and the report.
1520
+ """
1521
+ seen: dict[str, str] = {}
1522
+ labels: dict[UUID, str] = {}
1523
+ # A step's colour follows the order the document wrote it in, which the stream never states.
1524
+ track_steps(node.code for node in dg.call(dg.runs.get(run_id)).dag.nodes)
1525
+ for event in dg.iterate(dg.runs.events(run_id)):
1526
+ if isinstance(event, RunOut):
1527
+ continue
1528
+ if isinstance(event, AttemptEvent):
1529
+ if event.item is not None:
1530
+ labels[event.id] = event.item
1531
+ _relay(transition_event(event, seen), state)
1532
+ continue
1533
+ _relay(log_events([event], labels), state)
1534
+ watched = dg.call(dg.runs.get(run_id))
1535
+ items = run_items(dg, run_id)
1536
+ attempts = in_run_order(run_attempts(dg, run_id), items, [node.code for node in watched.dag.nodes])
1537
+ tolerated = watched.run.status is RunStatus.COMPLETED_WITH_ERRORS and not strict
1538
+ code = 0 if tolerated or watched.run.status is RunStatus.SUCCEEDED else 1
1539
+ out = sink(state)
1540
+ finished = remote_finished_event(
1541
+ watched,
1542
+ pipeline,
1543
+ code,
1544
+ attempts=attempts,
1545
+ items=items,
1546
+ report=dg.call(dg.runs.report(run_id)),
1547
+ logs=failure_logs(dg, run_id),
1548
+ )
1549
+ out.event(
1550
+ "run",
1551
+ level="error" if code else "info",
1552
+ message=watched.run.status.value,
1553
+ pipeline=pipeline,
1554
+ run_id=str(run_id),
1555
+ pipeline_version=finished.pipeline_version,
1556
+ triggered_by=finished.triggered_by,
1557
+ duration_ms=finished.duration_ms,
1558
+ items_total=finished.items_total,
1559
+ items_failed=finished.items_failed,
1560
+ exit_code=code,
1561
+ priority=watched.run.priority.value,
1562
+ error=watched.run.error,
1563
+ steps=[step.model_dump(mode="json") for step in finished.steps],
1564
+ failures=[row.model_dump(mode="json") for row in finished.failures],
1565
+ )
1566
+ if not out.rendered:
1567
+ if code:
1568
+ raise typer.Exit(code=code)
1569
+ return
1570
+ if tolerated:
1571
+ console.print("[yellow]warning[/] the run completed with errors; --strict would fail on this")
1572
+ return
1573
+ if code:
1574
+ raise typer.Exit(code=code)
1575
+
1576
+
1577
+ def _relay(events: list[WatchEvent], state: CliState) -> None:
1578
+ """Send events wherever this invocation is reading them."""
1579
+ print_events(events, state.detail, out=sink(state))
1580
+
1581
+
1582
+ def remote_finished_event(
1583
+ watched: RunDetail,
1584
+ pipeline: str,
1585
+ code: int,
1586
+ *,
1587
+ attempts: Sequence[AttemptOut],
1588
+ items: Sequence[ItemOut],
1589
+ report: RunReport | None = None,
1590
+ logs: Mapping[UUID, list[str]] | None = None,
1591
+ ) -> schemas.RunFinished:
1592
+ """Turn a finished server-side run into the closing event of its stream.
1593
+
1594
+ The report and the failure log lines are folded in here rather than fetched again when
1595
+ the run is rendered: the record is what the table and the diagnosis are read from.
1596
+ """
1597
+ labels = item_labels(items, attempts)
1598
+ reported = {step.step: step for step in (report.steps if report is not None else [])}
1599
+ fetched = logs or {}
1600
+ return schemas.RunFinished(
1601
+ run_id=watched.run.id,
1602
+ pipeline=pipeline,
1603
+ pipeline_version=report.pipeline_version if report is not None else None,
1604
+ status=watched.run.status.value,
1605
+ triggered_by=report.triggered_by if report is not None else None,
1606
+ duration_ms=report.duration_ms if report is not None else None,
1607
+ items_total=report.items_total if report is not None else 0,
1608
+ items_failed=report.items_failed if report is not None else 0,
1609
+ error=watched.run.error,
1610
+ exit_code=code,
1611
+ steps=[
1612
+ schemas.StepSummary(
1613
+ step=attempt.step_name,
1614
+ item=labels.get(attempt.id),
1615
+ block=attempt.block_id,
1616
+ status=attempt.status.value,
1617
+ depends_on=list(reported[attempt.step_name].depends_on) if attempt.step_name in reported else [],
1618
+ warnings=reported[attempt.step_name].warnings if attempt.step_name in reported else 0,
1619
+ attempts=attempt.attempt,
1620
+ duration_ms=_attempt_duration_ms(attempt),
1621
+ output=attempt.output,
1622
+ error=attempt.error,
1623
+ artifact_uri=attempt.output_uri,
1624
+ artifact_bytes=attempt.output_bytes,
1625
+ )
1626
+ for attempt in attempts
1627
+ if attempt.status in SETTLED_ATTEMPTS
1628
+ ],
1629
+ failures=[
1630
+ schemas.FailureSummary(
1631
+ step=attempt.step_name,
1632
+ block=attempt.block_id,
1633
+ attempt=attempt.attempt,
1634
+ error_class=attempt.error_class,
1635
+ error=attempt.error,
1636
+ logs=fetched.get(attempt.id, []),
1637
+ )
1638
+ for attempt in attempts
1639
+ if attempt.status is AttemptStatus.FAILED
1640
+ ],
1641
+ )
1642
+
1643
+
1644
+ def failure_logs(dg: Session, run_id: UUID) -> dict[UUID, list[str]]:
1645
+ """Read the last log lines of every failed attempt, once, for the closing record."""
1646
+ collected: dict[UUID, list[str]] = {}
1647
+ for attempt in run_attempts(dg, run_id, status=AttemptStatus.FAILED):
1648
+ page = dg.call(dg.runs.logs(run_id, step=attempt.step_name, limit=FAILURE_LOG_LINES))
1649
+ collected[attempt.id] = [f"{entry.level.value}: {entry.message}" for entry in page.items]
1650
+ return collected
1651
+
1652
+
1653
+ @runs_app.command("list")
1654
+ def runs_list(
1655
+ ctx: typer.Context,
1656
+ pipeline: Annotated[str | None, typer.Option(help="Only runs of this pipeline.")] = None,
1657
+ status: Annotated[str | None, typer.Option(help="Only runs in this state.")] = None,
1658
+ since: Annotated[str | None, typer.Option(help="Only runs within this window, such as 24h.")] = None,
1659
+ tag: Annotated[
1660
+ list[str] | None,
1661
+ typer.Option("--tag", help="Only runs whose pipeline wears this tag; repeat it to name more."),
1662
+ ] = None,
1663
+ limit: Annotated[int, typer.Option(help="How many runs at most; the server decides when omitted.")] = RUNS_PAGE,
1664
+ ) -> None:
1665
+ """List runs, newest first, or the ones whose pipeline wears every tag named."""
1666
+ if status is not None and status not in set(RunStatus):
1667
+ fail(f"{status!r} is not a run status ({', '.join(sorted(RunStatus))})")
1668
+ tags = tag or []
1669
+ with client_for(state_of(ctx)) as dg:
1670
+ rows = list(
1671
+ paged(
1672
+ lambda after, size: dg.call(
1673
+ dg.runs.list(
1674
+ pipeline=pipeline,
1675
+ status=RunStatus(status) if status else None,
1676
+ since=since,
1677
+ tags=tags,
1678
+ after=after,
1679
+ limit=size,
1680
+ )
1681
+ ),
1682
+ limit,
1683
+ )
1684
+ )
1685
+ if state_of(ctx).json_output:
1686
+ return emit_records("run", rows)
1687
+ table(
1688
+ "runs",
1689
+ ["run", "pipeline", "status", "triggered by", "started", "finished"],
1690
+ [
1691
+ [
1692
+ str(row.id) + prioritised(row.priority.value),
1693
+ row.pipeline,
1694
+ styled(row.status.value),
1695
+ row.triggered_by_label or row.triggered_by_kind.value,
1696
+ moment(row.started_at),
1697
+ moment(row.finished_at),
1698
+ ]
1699
+ for row in rows
1700
+ ],
1701
+ )
1702
+
1703
+
1704
+ @runs_app.command("show")
1705
+ def runs_show(
1706
+ ctx: typer.Context,
1707
+ run_id: Annotated[UUID, typer.Argument(help="The run to show.")],
1708
+ ) -> None:
1709
+ """Show a run: its status, its DAG, where each step's time went, and its item grid."""
1710
+ with client_for(state_of(ctx)) as dg:
1711
+ detail = dg.call(dg.runs.get(run_id))
1712
+ items = run_items(dg, run_id)
1713
+ attempts = in_run_order(run_attempts(dg, run_id), items, [node.code for node in detail.dag.nodes])
1714
+ now = datetime.now(UTC)
1715
+ grouped = by_step(attempts)
1716
+ timings = {node.code: step_timing(node, grouped.get(node.code, []), at=now) for node in detail.dag.nodes}
1717
+ if state_of(ctx).json_output:
1718
+ document = detail.model_dump(mode="json")
1719
+ for node in cast("list[dict[str, Any]]", document["dag"]["nodes"]):
1720
+ timing = timings[cast("str", node["code"])]
1721
+ node.update(timing.model_dump(mode="json", include={"queued_ms", "running_ms", "waiting_ms"}))
1722
+ return emit_record(
1723
+ "run.detail",
1724
+ fields={
1725
+ **document,
1726
+ "items": [item.model_dump(mode="json") for item in items],
1727
+ "attempts": [
1728
+ {
1729
+ **attempt.model_dump(mode="json"),
1730
+ **attempt_timing(attempt, at=now).model_dump(mode="json"),
1731
+ }
1732
+ for attempt in attempts
1733
+ ],
1734
+ },
1735
+ )
1736
+ run = detail.run
1737
+ fields(
1738
+ f"run {run_id}",
1739
+ {
1740
+ "pipeline": f"{run.pipeline} (version {run.pipeline_version})",
1741
+ "status": run.status.value,
1742
+ "triggered by": run.triggered_by_label or run.triggered_by_kind.value,
1743
+ "started": moment(run.started_at),
1744
+ "finished": moment(run.finished_at),
1745
+ "trace": run.trace_id or "-",
1746
+ "error": run.error or "-",
1747
+ **(
1748
+ {"waiting for": f"a worker carrying {', '.join(detail.waiting_for_workers)}"}
1749
+ if detail.waiting_for_workers
1750
+ else {}
1751
+ ),
1752
+ **(
1753
+ {"log levels": ", ".join(f"{pattern}={level.value}" for pattern, level in run.log_levels.items())}
1754
+ if run.log_levels
1755
+ else {}
1756
+ ),
1757
+ },
1758
+ )
1759
+ console.print()
1760
+ table(
1761
+ "steps",
1762
+ ["step", "block", "outcome", "after", "attempts", "queued", "running", "waiting", "items"],
1763
+ [
1764
+ [
1765
+ node.code,
1766
+ node.block,
1767
+ styled(node.outcome),
1768
+ ", ".join(node.depends_on) or "-",
1769
+ str(node.attempts),
1770
+ elapsed(timings[node.code].queued_ms),
1771
+ elapsed(timings[node.code].running_ms),
1772
+ elapsed(timings[node.code].waiting_ms),
1773
+ f"{node.items_total - node.items_failed}/{node.items_total}" if node.fan_out else "-",
1774
+ ]
1775
+ for node in detail.dag.nodes
1776
+ ],
1777
+ )
1778
+ if items:
1779
+ console.print()
1780
+ table(
1781
+ "items",
1782
+ ["step", "index", "key", "status", "error"],
1783
+ [
1784
+ [
1785
+ item.step_name,
1786
+ str(item.item_index),
1787
+ item.item_key,
1788
+ styled(item.status.value),
1789
+ item.error or "-",
1790
+ ]
1791
+ for item in items
1792
+ ],
1793
+ )
1794
+
1795
+
1796
+ @runs_app.command("cancel")
1797
+ def runs_cancel(ctx: typer.Context, run_id: Annotated[UUID, typer.Argument()]) -> None:
1798
+ """Cancel a run: stop what has not started, and tell the remote about what has."""
1799
+ with client_for(state_of(ctx)) as dg:
1800
+ row = dg.call(dg.runs.cancel(run_id))
1801
+ emit_fact("run.cancelled", message="cancelled", run_id=str(run_id), status=row.status.value)
1802
+
1803
+
1804
+ RETRYABLE = (AttemptStatus.FAILED, AttemptStatus.SKIPPED, AttemptStatus.CANCELLED)
1805
+
1806
+ #: Attempt states nothing moves an attempt out of, which is what "step_finished" means.
1807
+ SETTLED_ATTEMPTS = (
1808
+ AttemptStatus.SUCCEEDED,
1809
+ AttemptStatus.FAILED,
1810
+ AttemptStatus.SKIPPED,
1811
+ AttemptStatus.CANCELLED,
1812
+ )
1813
+
1814
+
1815
+ @runs_app.command("retry")
1816
+ def runs_retry(
1817
+ ctx: typer.Context,
1818
+ run_id: Annotated[UUID, typer.Argument(help="The run holding the failed step.")],
1819
+ step: Annotated[str, typer.Option("--step", help="Which step to retry.")],
1820
+ failed_items: Annotated[bool, typer.Option("--failed-items", help="Retry every failed item of a fan-out.")] = False,
1821
+ ) -> None:
1822
+ """Create one manual attempt of a failed step, reading its upstream stored outputs."""
1823
+ with client_for(state_of(ctx)) as dg:
1824
+ candidates = [row for row in run_attempts(dg, run_id, step=step) if row.status in RETRYABLE]
1825
+ if not candidates:
1826
+ fail(f"run {run_id} has no settled failure of step {step!r} to retry")
1827
+ retried = candidates if failed_items else candidates[-1:]
1828
+ labels = item_labels(run_items(dg, run_id), retried)
1829
+ for attempt in retried:
1830
+ created = dg.call(dg.runs.retry(attempt.id))
1831
+ emit_fact(
1832
+ "run.retried",
1833
+ message="queued",
1834
+ step=step,
1835
+ item=labels.get(attempt.id),
1836
+ run_id=str(run_id),
1837
+ attempt=created.attempt,
1838
+ )
1839
+
1840
+
1841
+ @runs_app.command("logs")
1842
+ def runs_logs(
1843
+ ctx: typer.Context,
1844
+ run_id: Annotated[UUID, typer.Argument(help="The run whose logs to read.")],
1845
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Stream new entries as they land.")] = False,
1846
+ step: Annotated[str | None, typer.Option("--step", help="Only entries from this step.")] = None,
1847
+ ) -> None:
1848
+ """Read a run's log entries, or follow them live."""
1849
+ state = state_of(ctx)
1850
+ with client_for(state) as dg:
1851
+ entries = list(
1852
+ paged(lambda after, size: dg.call(dg.runs.logs(run_id, after=after, limit=size, step=step)), None)
1853
+ )
1854
+ if state.json_output and not follow:
1855
+ return emit_records("log_entry", entries)
1856
+ for entry in entries:
1857
+ _relay_log(entry, state)
1858
+ if not follow:
1859
+ return
1860
+ cursor = entries[-1].id if entries else 0
1861
+ for entry in dg.iterate(dg.runs.follow_logs(run_id, after=cursor, step=step)):
1862
+ _relay_log(entry, state)
1863
+
1864
+
1865
+ def _relay_log(entry: LogEntryOut, state: CliState) -> None:
1866
+ """Write one followed log entry."""
1867
+ sink(state).event(
1868
+ "log",
1869
+ at=entry.created_at,
1870
+ level=entry.level.value,
1871
+ step=entry.step_name,
1872
+ message=entry.message,
1873
+ fields=dict(entry.fields or {}),
1874
+ )
1875
+
1876
+
1877
+ @runs_app.command("report")
1878
+ def runs_report(
1879
+ ctx: typer.Context,
1880
+ run_id: Annotated[UUID, typer.Argument(help="The run to summarise.")],
1881
+ ) -> None:
1882
+ """Summarise a run: what each step amounted to, and how long it took."""
1883
+ with client_for(state_of(ctx)) as dg:
1884
+ report = dg.call(dg.runs.report(run_id))
1885
+ if state_of(ctx).json_output:
1886
+ return emit_one("run.report", report)
1887
+ _print_report(report)
1888
+
1889
+
1890
+ def _print_report(report: RunReport) -> None:
1891
+ """Print a run report."""
1892
+ fields(
1893
+ f"run {report.run_id}",
1894
+ {
1895
+ "pipeline": f"{report.pipeline} (version {report.pipeline_version})",
1896
+ "status": report.status.value,
1897
+ "triggered by": report.triggered_by or "-",
1898
+ "duration": elapsed(report.duration_ms),
1899
+ "items": f"{report.items_total - report.items_failed}/{report.items_total}" if report.items_total else "-",
1900
+ },
1901
+ )
1902
+ console.print()
1903
+ table(
1904
+ "steps",
1905
+ ["step", "block", "outcome", "after", "attempts", "duration", "error"],
1906
+ [
1907
+ [
1908
+ step.step + flagged(step.warnings),
1909
+ step.block,
1910
+ styled(step.outcome),
1911
+ ", ".join(step.depends_on) or "-",
1912
+ str(step.attempts),
1913
+ elapsed(step.duration_ms),
1914
+ step.error or "-",
1915
+ ]
1916
+ for step in report.steps
1917
+ ],
1918
+ )
1919
+
1920
+
1921
+ @runs_app.command("profile")
1922
+ def runs_profile(
1923
+ ctx: typer.Context,
1924
+ run_id: Annotated[UUID, typer.Argument(help="The run to break down.")],
1925
+ ) -> None:
1926
+ """Break a run into where its wall clock went, and name what held it up.
1927
+
1928
+ A run is as slow as the chain through its DAG that decided when it ended: each step on
1929
+ that chain waited for the one before it, so the queued, running and waiting time along it
1930
+ adds up to the run. Everything off the chain ran beside it and cost the run nothing.
1931
+
1932
+ The warnings say only what the timestamps prove -- a probe cadence longer than the work it
1933
+ waited on, a deadline many times the wait it needed, a fan-out whose elements never
1934
+ overlapped -- so a run with none of those is told nothing rather than guessed at.
1935
+ """
1936
+ with client_for(state_of(ctx)) as dg:
1937
+ detail = dg.call(dg.runs.get(run_id))
1938
+ items = run_items(dg, run_id)
1939
+ attempts = in_run_order(run_attempts(dg, run_id), items, [node.code for node in detail.dag.nodes])
1940
+ _write_profile(profile(detail, attempts, at=datetime.now(UTC)))
1941
+
1942
+
1943
+ def _write_profile(measured: RunProfile) -> None:
1944
+ """Write the profile as records, rendered by the same formatter `dg format` renders with."""
1945
+ sink = Sink(output_mode())
1946
+ sink.event(
1947
+ "run.profile",
1948
+ message=f"{measured.status} in {elapsed(measured.duration_ms)}",
1949
+ run_id=measured.run_id,
1950
+ pipeline=measured.pipeline,
1951
+ status=measured.status,
1952
+ duration_ms=measured.duration_ms,
1953
+ critical_path=measured.critical_path,
1954
+ queued_ms=measured.queued_ms,
1955
+ running_ms=measured.running_ms,
1956
+ waiting_ms=measured.waiting_ms,
1957
+ )
1958
+ for row in measured.steps:
1959
+ sink.event(
1960
+ "run.profile.step",
1961
+ step=row.step,
1962
+ message="on the critical path",
1963
+ run_id=measured.run_id,
1964
+ block=row.block,
1965
+ attempts=row.attempts,
1966
+ queued_ms=row.queued_ms,
1967
+ running_ms=row.running_ms,
1968
+ waiting_ms=row.waiting_ms,
1969
+ )
1970
+ for warning in measured.warnings:
1971
+ sink.event(
1972
+ "run.profile.warning",
1973
+ level="warning",
1974
+ step=warning.step,
1975
+ message=warning.message,
1976
+ run_id=measured.run_id,
1977
+ cause=warning.cause,
1978
+ )
1979
+
1980
+
1981
+ @blocks_app.command("new")
1982
+ def blocks_new(
1983
+ ctx: typer.Context,
1984
+ name: Annotated[str, typer.Argument(help="The pack's name: dirigent-NAME, contributing NAME.hello.")],
1985
+ directory: Annotated[
1986
+ Path, typer.Option(help="The directory to create the pack under; here when omitted.")
1987
+ ] = Path(),
1988
+ ) -> None:
1989
+ """Scaffold a new block pack: a package, one operator, and a passing test.
1990
+
1991
+ The generated pack registers through the same entry-point group every installed pack
1992
+ does, so `uv sync && uv run pytest` inside it is a working plugin from the first minute.
1993
+ """
1994
+ try:
1995
+ written = scaffold_pack(directory.resolve(), name)
1996
+ except ScaffoldError as error:
1997
+ fail(str(error))
1998
+ root = directory.resolve() / f"dirigent-{name}"
1999
+ if state_of(ctx).json_output:
2000
+ for path in written:
2001
+ emit_event(ScaffoldRecord(path=str(path)))
2002
+ emit_event(ScaffoldedRecord(directory=str(root), next=["uv sync", "uv run pytest"]))
2003
+ return
2004
+ for path in written:
2005
+ console.print(f" {path.relative_to(root.parent)}")
2006
+ console.print("\nA working pack. Wire the [bold]tool.uv.sources[/] its README names, then:")
2007
+ console.print(" [bold]uv sync && uv run pytest[/]")
2008
+
2009
+
2010
+ @blocks_app.command("list")
2011
+ def blocks_list(
2012
+ ctx: typer.Context,
2013
+ kind: Annotated[str | None, typer.Option(help="Only operators, or only sensors.")] = None,
2014
+ ) -> None:
2015
+ """List the blocks this instance can run."""
2016
+ if kind is not None and kind not in set(BlockKind):
2017
+ fail(f"{kind!r} is not a block kind ({', '.join(sorted(BlockKind))})")
2018
+ with client_for(state_of(ctx)) as dg:
2019
+ catalog = dg.call(dg.blocks.catalog(kind=BlockKind(kind) if kind else None))
2020
+ if state_of(ctx).json_output:
2021
+ emit_records("block", catalog.blocks)
2022
+ emit_records("storage_scheme", catalog.storage_schemes)
2023
+ emit_records("connection_kind", catalog.connection_kinds)
2024
+ return
2025
+ table(
2026
+ "blocks",
2027
+ ["id", "kind", "plugin", "summary"],
2028
+ [[block.id, block.kind.value, block.plugin, block.summary] for block in catalog.blocks],
2029
+ )
2030
+ console.print(
2031
+ f"[dim]storage schemes:[/] {', '.join(entry.id for entry in catalog.storage_schemes) or '-'} "
2032
+ f"[dim]connection kinds:[/] {', '.join(entry.id for entry in catalog.connection_kinds) or '-'}"
2033
+ )
2034
+
2035
+
2036
+ @blocks_app.command("show")
2037
+ def blocks_show(
2038
+ ctx: typer.Context,
2039
+ block_id: Annotated[str, typer.Argument(help="The block to describe.")],
2040
+ ) -> None:
2041
+ """Show one block's config and output schemas."""
2042
+ with client_for(state_of(ctx)) as dg:
2043
+ entry = dg.call(dg.blocks.get(block_id))
2044
+ if state_of(ctx).json_output:
2045
+ return emit_one("block", entry)
2046
+ fields(
2047
+ f"block {block_id}",
2048
+ {
2049
+ "kind": entry.kind.value,
2050
+ "plugin": entry.plugin,
2051
+ "summary": entry.summary,
2052
+ "idempotent": "yes" if entry.idempotent else "no",
2053
+ "runs code on the worker": "yes" if entry.local_execution else "no",
2054
+ },
2055
+ )
2056
+ console.print("\n[bold]config[/]")
2057
+ _print_schema(entry.config_schema)
2058
+ console.print("\n[bold]output[/]")
2059
+ _print_schema(entry.output_schema)
2060
+
2061
+
2062
+ def _print_schema(schema: dict[str, Any]) -> None:
2063
+ """Print a published JSON Schema as a field list."""
2064
+ properties = cast("dict[str, Any]", schema.get("properties") or {})
2065
+ required = set(cast("list[str]", schema.get("required") or []))
2066
+ if not properties:
2067
+ console.print(" [dim]no fields[/]")
2068
+ return
2069
+ for name, body in properties.items():
2070
+ kind = body.get("type") or ("enum" if "enum" in body else "any")
2071
+ mark = "[red]*[/]" if name in required else " "
2072
+ default = f" [dim](default {body['default']!r})[/]" if "default" in body else ""
2073
+ console.print(f" {mark} {name:<22} {kind}{default}")
2074
+
2075
+
2076
+ @connection_app.command("list")
2077
+ def connection_list(
2078
+ ctx: typer.Context,
2079
+ ) -> None:
2080
+ """List the credential records this instance holds, with their secrets redacted."""
2081
+ with client_for(state_of(ctx)) as dg:
2082
+ rows = list(paged(lambda after, size: dg.call(dg.connections.list(after=after, limit=size)), None))
2083
+ if state_of(ctx).json_output:
2084
+ return emit_records("connection", rows)
2085
+ table(
2086
+ "connections",
2087
+ ["code", "name", "kind", "last check", "healthy", "description"],
2088
+ [
2089
+ [
2090
+ row.code,
2091
+ row.name or "-",
2092
+ row.kind,
2093
+ moment(row.last_check_at),
2094
+ render_bool(row.last_check_healthy),
2095
+ row.description or "-",
2096
+ ]
2097
+ for row in rows
2098
+ ],
2099
+ )
2100
+
2101
+
2102
+ @connection_app.command("create")
2103
+ def connection_create(
2104
+ ctx: typer.Context,
2105
+ kind_id: Annotated[str, typer.Argument(metavar="KIND", help="The connection kind, such as http.")],
2106
+ code: Annotated[str, typer.Argument(help="What to call it; documents reference this code.")],
2107
+ name: Annotated[str | None, typer.Option(help="A human title for this connection.")] = None,
2108
+ set_value: Annotated[list[str] | None, typer.Option("--set", help="field=value, repeatable.")] = None,
2109
+ description: Annotated[str | None, typer.Option(help="What this credential is for.")] = None,
2110
+ ) -> None:
2111
+ """Create a connection, prompting for the secret fields without echoing them.
2112
+
2113
+ Prompting happens only on a terminal; in a script every value must arrive via ``--set``.
2114
+ """
2115
+ with client_for(state_of(ctx)) as dg:
2116
+ schema = _connection_schema(dg.call(dg.blocks.catalog()), kind_id)
2117
+ config = parse_params(set_value, schema=schema)
2118
+ required = set(cast("list[str]", schema.get("required") or []))
2119
+ interactive = sys.stdin.isatty() and not json_mode()
2120
+ for field, body in cast("dict[str, Any]", schema.get("properties") or {}).items():
2121
+ if field in config:
2122
+ continue
2123
+ # An optional secret nobody set is simply unset: a prompt is an offer, and
2124
+ # refusing the invocation for declining it would make the bot-token form of a
2125
+ # slack connection unreachable from any script.
2126
+ if json_mode() and field in required:
2127
+ fail(f"--json cannot prompt for {field}; pass it as --set {field}=...")
2128
+ if not interactive:
2129
+ continue
2130
+ if _is_secret_field(body):
2131
+ value = typer.prompt(f"{field}", hide_input=True, default="", show_default=False)
2132
+ if value:
2133
+ config[field] = value
2134
+ elif field in required:
2135
+ config[field] = typer.prompt(f"{field}")
2136
+ created = dg.call(dg.connections.create(code, kind=kind_id, config=config, name=name, description=description))
2137
+ emit_fact(
2138
+ "connection.created",
2139
+ message="created",
2140
+ code=created.code,
2141
+ connection_kind=created.kind,
2142
+ name=created.name,
2143
+ description=created.description,
2144
+ config=created.config,
2145
+ )
2146
+
2147
+
2148
+ def _connection_schema(catalog: Catalog, kind_id: str) -> dict[str, Any]:
2149
+ """Find one connection kind's published schema in the catalog."""
2150
+ for entry in catalog.connection_kinds:
2151
+ if entry.id == kind_id:
2152
+ return entry.config_schema
2153
+ known = ", ".join(entry.id for entry in catalog.connection_kinds) or "none"
2154
+ fail(f"no connection kind {kind_id!r} is installed ({known})")
2155
+
2156
+
2157
+ def _is_secret_field(body: dict[str, Any]) -> bool:
2158
+ """Report whether a schema field is one the server will redact.
2159
+
2160
+ ``format: password`` is the same declaration that makes the engine encrypt the field,
2161
+ so hiding the prompt and encrypting the value stay in agreement.
2162
+ """
2163
+ if body.get("format") == "password":
2164
+ return True
2165
+ options = cast("list[object]", body.get("anyOf") or [])
2166
+ return any(
2167
+ isinstance(option, dict) and cast("dict[str, Any]", option).get("format") == "password" for option in options
2168
+ )
2169
+
2170
+
2171
+ @connection_app.command("show")
2172
+ def connection_show(
2173
+ ctx: typer.Context,
2174
+ code: Annotated[str, typer.Argument()],
2175
+ ) -> None:
2176
+ """Show one credential record, with its secrets redacted."""
2177
+ with client_for(state_of(ctx)) as dg:
2178
+ row = dg.call(dg.connections.get(code))
2179
+ if state_of(ctx).json_output:
2180
+ return emit_one("connection", row)
2181
+ fields(
2182
+ f"connection {code}",
2183
+ {"name": row.name or "-", "kind": row.kind, "description": row.description or "-", **row.config},
2184
+ )
2185
+
2186
+
2187
+ @connection_app.command("check")
2188
+ def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2189
+ """Ask a connection's own kind whether its external system answers."""
2190
+ with client_for(state_of(ctx)) as dg:
2191
+ report = dg.call(dg.connections.check(code))
2192
+ emit_fact(
2193
+ "connection.checked",
2194
+ message="healthy" if report.healthy else "unhealthy",
2195
+ code=code,
2196
+ healthy=report.healthy,
2197
+ detail=report.detail,
2198
+ version=report.version,
2199
+ )
2200
+ if not report.healthy:
2201
+ raise typer.Exit(code=1)
2202
+
2203
+
2204
+ @connection_app.command("delete")
2205
+ def connection_delete(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2206
+ """Remove a credential record."""
2207
+ with client_for(state_of(ctx)) as dg:
2208
+ dg.call(dg.connections.delete(code))
2209
+ emit_fact("connection.deleted", message="deleted", code=code)
2210
+
2211
+
2212
+ @schema_app.command("list")
2213
+ @schema_app.command("ls", hidden=True)
2214
+ def schema_list(ctx: typer.Context) -> None:
2215
+ """List the JSON Schemas this instance holds."""
2216
+ with client_for(state_of(ctx)) as dg:
2217
+ rows = list(paged(lambda after, size: dg.call(dg.schemas.list(after=after, limit=size)), None))
2218
+ if state_of(ctx).json_output:
2219
+ return emit_records("schema", rows)
2220
+ table(
2221
+ "schemas",
2222
+ ["code", "name", "description"],
2223
+ [[row.code, row.name or "-", row.description or "-"] for row in rows],
2224
+ )
2225
+
2226
+
2227
+ @schema_app.command("create")
2228
+ def schema_create(
2229
+ ctx: typer.Context,
2230
+ reference: Annotated[str, typer.Argument(help="A JSON Schema file, or - for standard input.")],
2231
+ code: Annotated[
2232
+ str | None, typer.Option(help="What to call it; taken from $id or the filename when omitted.")
2233
+ ] = None,
2234
+ name: Annotated[str | None, typer.Option(help="A human title; taken from the schema's title when omitted.")] = None,
2235
+ description: Annotated[
2236
+ str | None, typer.Option(help="What the schema is for; taken from the schema's description when omitted.")
2237
+ ] = None,
2238
+ ) -> None:
2239
+ """Store a locally authored JSON Schema, taking its identity from its own keywords."""
2240
+ try:
2241
+ document = read_document(reference)
2242
+ except SourceError as error:
2243
+ fail(str(error))
2244
+ try:
2245
+ body = yaml.safe_load(document.text)
2246
+ except yaml.YAMLError as error:
2247
+ fail(f"{document.label} is not readable JSON or YAML: {error}")
2248
+ if not isinstance(body, dict):
2249
+ fail(f"{document.label} is not a JSON Schema: a schema is an object, and this is {type(body).__name__}")
2250
+ schema = cast("JsonMap", body)
2251
+ resolved = code
2252
+ if resolved is None and not (isinstance(schema.get("$id"), str) and code_from_id(cast("str", schema["$id"]))):
2253
+ resolved = code_from_id(Path(document.ref).name) if document.ref != "(stdin)" else None
2254
+ with client_for(state_of(ctx)) as dg:
2255
+ created = dg.call(dg.schemas.create(schema, code=resolved, name=name, description=description))
2256
+ if state_of(ctx).json_output:
2257
+ return emit_fact("schema.created", message="created", code=created.code, name=created.name)
2258
+ console.print(f"[green]stored[/] schema [bold]{created.code}[/]")
2259
+
2260
+
2261
+ @schema_app.command("show")
2262
+ def schema_show(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2263
+ """Show one schema: its labels and the JSON Schema body."""
2264
+ with client_for(state_of(ctx)) as dg:
2265
+ row = dg.call(dg.schemas.get(code))
2266
+ if state_of(ctx).json_output:
2267
+ return emit_one("schema", row)
2268
+ fields(f"schema {code}", {"name": row.name or "-", "description": row.description or "-"})
2269
+ console.print_json(data=row.body)
2270
+
2271
+
2272
+ @schema_app.command("delete")
2273
+ def schema_delete(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2274
+ """Remove a schema."""
2275
+ with client_for(state_of(ctx)) as dg:
2276
+ dg.call(dg.schemas.delete(code))
2277
+ emit_fact("schema.deleted", message="deleted", code=code)
2278
+
2279
+
2280
+ @system_app.command("workers")
2281
+ def workers_command(
2282
+ ctx: typer.Context,
2283
+ ) -> None:
2284
+ """List the worker registry: which workers are alive, on what version, with which plugins."""
2285
+ with client_for(state_of(ctx)) as dg:
2286
+ rows = list(paged(lambda after, size: dg.call(dg.workers.list(after=after, limit=size)), None))
2287
+ if state_of(ctx).json_output:
2288
+ return emit_records("worker", rows)
2289
+ table(
2290
+ "workers",
2291
+ ["name", "host", "version", "status", "concurrency", "same code", "last seen"],
2292
+ [
2293
+ [
2294
+ row.name,
2295
+ row.hostname,
2296
+ row.version,
2297
+ styled(row.status.value),
2298
+ str(row.concurrency),
2299
+ render_bool(row.code_matches_server),
2300
+ age(row.last_seen_at),
2301
+ ]
2302
+ for row in rows
2303
+ ],
2304
+ )
2305
+
2306
+
2307
+ @system_app.command("info")
2308
+ def system_info(
2309
+ ctx: typer.Context,
2310
+ ) -> None:
2311
+ """Describe the instance, and check every connection it holds."""
2312
+ with client_for(state_of(ctx)) as dg:
2313
+ info = dg.call(dg.system.info())
2314
+ if state_of(ctx).json_output:
2315
+ return emit_one("system.info", info)
2316
+ fields(
2317
+ "instance",
2318
+ {
2319
+ "version": info.version,
2320
+ "environment": info.environment,
2321
+ "database": info.database,
2322
+ "plugins": ", ".join(info.plugins) or "-",
2323
+ "blocks": info.blocks,
2324
+ "storage schemes": ", ".join(info.storage_schemes) or "-",
2325
+ "workers live": info.workers_live,
2326
+ "secrets configured": "yes" if info.secrets_configured else "no",
2327
+ "unsafe blocks": ", ".join(info.unsafe_blocks_enabled) or "none",
2328
+ },
2329
+ )
2330
+ if info.connections:
2331
+ console.print()
2332
+ table(
2333
+ "connections",
2334
+ ["code", "name", "kind", "connected", "detail"],
2335
+ [
2336
+ [row.code, row.name or "-", row.kind, render_bool(row.connected), row.detail or "-"]
2337
+ for row in info.connections
2338
+ ],
2339
+ )
2340
+
2341
+
2342
+ @token_app.command("create")
2343
+ def token_create(
2344
+ ctx: typer.Context,
2345
+ name: Annotated[str, typer.Argument(help="What to call the token.")],
2346
+ user: Annotated[str | None, typer.Option("--user", help="Mint it for this account rather than your own.")] = None,
2347
+ ) -> None:
2348
+ """Mint a bearer token and print its secret once."""
2349
+ with client_for(state_of(ctx)) as dg:
2350
+ if user is None:
2351
+ created = dg.call(dg.admin.tokens.create(name))
2352
+ else:
2353
+ created = dg.call(dg.admin.users.create_token(user, name))
2354
+ if state_of(ctx).json_output:
2355
+ return emit_fact(
2356
+ "token.issued",
2357
+ message="created",
2358
+ username=created.username,
2359
+ name=created.name,
2360
+ prefix=created.prefix,
2361
+ token=created.token,
2362
+ )
2363
+ console.print(f"[green]created[/] token [bold]{created.name}[/] for [bold]{created.username}[/]")
2364
+ console.print(f"\n {created.token}\n")
2365
+ console.print("[yellow]This is the only time the token is shown.[/] Store it, or create another.")
2366
+
2367
+
2368
+ @token_app.command("list")
2369
+ def token_list(
2370
+ ctx: typer.Context,
2371
+ ) -> None:
2372
+ """List API tokens, without their secrets."""
2373
+ with client_for(state_of(ctx)) as dg:
2374
+ rows = list(paged(lambda after, size: dg.call(dg.admin.tokens.list(after=after, limit=size)), None))
2375
+ if state_of(ctx).json_output:
2376
+ return emit_records("token", rows)
2377
+ table(
2378
+ "tokens",
2379
+ ["user", "name", "prefix", "created", "last used", "revoked"],
2380
+ [
2381
+ [
2382
+ row.username,
2383
+ row.name,
2384
+ row.prefix,
2385
+ moment(row.created_at),
2386
+ moment(row.last_used_at),
2387
+ moment(row.revoked_at),
2388
+ ]
2389
+ for row in rows
2390
+ ],
2391
+ )
2392
+
2393
+
2394
+ @token_app.command("revoke")
2395
+ def token_revoke(
2396
+ ctx: typer.Context,
2397
+ name: Annotated[str, typer.Argument()],
2398
+ user: Annotated[
2399
+ str | None, typer.Option("--user", help="Revoke this account's token rather than your own.")
2400
+ ] = None,
2401
+ ) -> None:
2402
+ """Revoke the live tokens of that name held by one account."""
2403
+ with client_for(state_of(ctx)) as dg:
2404
+ if user is None:
2405
+ dg.call(dg.admin.tokens.revoke(name))
2406
+ else:
2407
+ dg.call(dg.admin.users.revoke_token(user, name))
2408
+ if user is None:
2409
+ emit_fact("token.revoked", message="revoked", code=name)
2410
+ else:
2411
+ emit_fact("token.revoked", message="revoked", code=name, username=user)
2412
+
2413
+
2414
+ @user_app.command("list")
2415
+ def user_list(
2416
+ ctx: typer.Context,
2417
+ ) -> None:
2418
+ """List the accounts this instance holds."""
2419
+ with client_for(state_of(ctx)) as dg:
2420
+ rows = list(paged(lambda after, size: dg.call(dg.admin.users.list(after=after, limit=size)), None))
2421
+ if state_of(ctx).json_output:
2422
+ return emit_records("user", rows)
2423
+ table(
2424
+ "users",
2425
+ ["username", "role", "active", "last login"],
2426
+ [[row.username, row.role.value, render_bool(row.active), moment(row.last_login_at)] for row in rows],
2427
+ )
2428
+
2429
+
2430
+ @user_app.command("deactivate")
2431
+ def user_deactivate(ctx: typer.Context, username: Annotated[str, typer.Argument()]) -> None:
2432
+ """Bar an account from logging in and revoke the sessions it already holds."""
2433
+ with client_for(state_of(ctx)) as dg:
2434
+ row = dg.call(dg.admin.users.deactivate(username))
2435
+ if state_of(ctx).json_output:
2436
+ return emit_records("user", [row])
2437
+ console.print(f"[red]deactivated[/] user {row.username}")
2438
+
2439
+
2440
+ @user_app.command("activate")
2441
+ def user_activate(ctx: typer.Context, username: Annotated[str, typer.Argument()]) -> None:
2442
+ """Let an account log in again; the sessions it lost are not restored."""
2443
+ with client_for(state_of(ctx)) as dg:
2444
+ row = dg.call(dg.admin.users.activate(username))
2445
+ if state_of(ctx).json_output:
2446
+ return emit_records("user", [row])
2447
+ console.print(f"[green]activated[/] user {row.username}")
2448
+
2449
+
2450
+ @user_app.command("password")
2451
+ def user_password(
2452
+ ctx: typer.Context,
2453
+ username: Annotated[str, typer.Argument(help="The account whose password to replace.")],
2454
+ password: Annotated[str | None, typer.Option(help="The password to set; asked for, hidden, when omitted.")] = None,
2455
+ ) -> None:
2456
+ """Set another account's password, ending every session it holds; its API tokens survive."""
2457
+ password = password or ask("new password", hide=True)
2458
+ with client_for(state_of(ctx)) as dg:
2459
+ dg.call(dg.admin.users.reset_password(username, password))
2460
+ emit_fact("password.reset", message="reset", username=username, sessions="revoked")
2461
+
2462
+
2463
+ @auth_app.command("password")
2464
+ def auth_password(
2465
+ ctx: typer.Context,
2466
+ current: Annotated[str | None, typer.Option(help="The password in force; asked for, hidden, when omitted.")] = None,
2467
+ new: Annotated[str | None, typer.Option(help="The password to set; asked for, hidden, when omitted.")] = None,
2468
+ ) -> None:
2469
+ """Change this account's own password, ending every other session it holds."""
2470
+ current = current or ask("current password", hide=True)
2471
+ new = new or ask("new password", hide=True)
2472
+ with client_for(state_of(ctx)) as dg:
2473
+ dg.call(dg.auth.change_password(current, new))
2474
+ emit_fact("password.changed", message="changed", other_sessions="revoked")
2475
+
2476
+
2477
+ @auth_app.command("login")
2478
+ def auth_login(
2479
+ ctx: typer.Context,
2480
+ username: Annotated[str | None, typer.Option(help="The account to log in as; asked for when omitted.")] = None,
2481
+ password: Annotated[str | None, typer.Option(help="Its password; asked for, hidden, when omitted.")] = None,
2482
+ ) -> None:
2483
+ """Log in and mint an API token to put in a profile.
2484
+
2485
+ The token, not a cookie, is what goes into ``profiles.yaml`` or ``DG_TOKEN``.
2486
+ """
2487
+ state = state_of(ctx)
2488
+ username = username or ask("username")
2489
+ password = password or ask("password", hide=True)
2490
+ endpoint = state.endpoint(needs_token=False)
2491
+ with client_for(state, needs_token=False) as dg:
2492
+ dg.call(dg.auth.login(username, password))
2493
+ created = dg.call(dg.admin.tokens.create(f"cli-{username}"))
2494
+ emit_fact(
2495
+ "token.issued",
2496
+ message="logged in",
2497
+ username=username,
2498
+ url=endpoint.url,
2499
+ name=created.name,
2500
+ token=created.token,
2501
+ )
2502
+
2503
+
2504
+ @auth_app.command("status")
2505
+ def auth_status(ctx: typer.Context) -> None:
2506
+ """Say which server the CLI is talking to, and who it is."""
2507
+ state = state_of(ctx)
2508
+ if state.resolved.token is None:
2509
+ refuse(
2510
+ f"no token for {state.resolved.url}",
2511
+ title="Not authenticated",
2512
+ problems=["set DG_TOKEN", "or add a token to a profile", "or mint one with dg auth login"],
2513
+ )
2514
+ raise typer.Exit(code=1)
2515
+ with client_for(state) as dg:
2516
+ me = dg.call(dg.auth.whoami())
2517
+ emit_fact(
2518
+ "auth",
2519
+ message="authenticated",
2520
+ url=state.resolved.url,
2521
+ source=state.resolved.source,
2522
+ username=me.username,
2523
+ role=me.role.value,
2524
+ via=me.via.value if me.via else "-",
2525
+ )