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.
dirigent_cli/local.py ADDED
@@ -0,0 +1,790 @@
1
+ """``dg run --local``: run a document to completion with no server and no dependencies."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import shutil
6
+ import tempfile
7
+ from collections.abc import AsyncIterator, Mapping, Sequence
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any, Final, cast
11
+ from uuid import UUID
12
+
13
+ import sqlalchemy as sa
14
+ import yaml
15
+ from cryptography.fernet import Fernet
16
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr
17
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
18
+
19
+ from dirigent_client.enums import AttemptStatus, LogLevel, RunStatus, TriggerKind
20
+ from dirigent_common import JsonMap
21
+ from dirigent_core import migrations
22
+ from dirigent_core.config import Settings, get_settings
23
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
24
+ from dirigent_core.documents import DocumentError, load_pipeline_text, safe_load
25
+ from dirigent_core.engine import Attribution, ParameterError, create_run
26
+ from dirigent_core.engine.definition import PipelineDefinition
27
+ from dirigent_core.engine.runs import RunCreationError, RunWindow
28
+ from dirigent_core.engine.services import EngineServices
29
+ from dirigent_core.engine.state import in_execution_order
30
+ from dirigent_core.models import ArtifactRef, Connection, LogEntry, Run, RunItem, StepAttempt
31
+ from dirigent_core.pipelines import apply_document
32
+ from dirigent_core.plugins import load_plugin_host
33
+ from dirigent_core.schemas import SchemaRefused, resolve_identity, store_schema
34
+ from dirigent_core.storage import scratch_prefix
35
+ from dirigent_core.worker import Worker
36
+
37
+ #: How long the driver waits between polls when the run keeps moving, and how far that
38
+ #: wait widens while nothing does.
39
+ POLL_SECONDS: Final = 0.1
40
+ POLL_WIDEN: Final = 1.5
41
+ POLL_CEILING: Final = 1.0
42
+
43
+ #: How many log entries one read takes, so a step that logged a million lines is streamed
44
+ #: rather than held in memory.
45
+ LOCAL_LOG_PAGE: Final = 500
46
+
47
+ DEFAULT_DEADLINE_SECONDS: Final = 600.0
48
+
49
+ TERMINAL: Final = (
50
+ RunStatus.SUCCEEDED,
51
+ RunStatus.COMPLETED_WITH_ERRORS,
52
+ RunStatus.FAILED,
53
+ RunStatus.CANCELLED,
54
+ )
55
+
56
+
57
+ class LocalError(Exception):
58
+ """A local run could not be set up; the message says what was wrong with the input."""
59
+
60
+
61
+ #: How long a fan-out element's key may be before the stream labels it by position instead.
62
+ ITEM_LABEL_MAX: Final = 24
63
+
64
+ #: Attempt states a run has finished with, which is what the end-of-run summary reads.
65
+ SETTLED: Final = (
66
+ AttemptStatus.SUCCEEDED,
67
+ AttemptStatus.FAILED,
68
+ AttemptStatus.SKIPPED,
69
+ AttemptStatus.CANCELLED,
70
+ )
71
+
72
+
73
+ class ConnectionSpec(BaseModel):
74
+ """One connection a local run is given."""
75
+
76
+ model_config = ConfigDict(frozen=True)
77
+
78
+ code: str
79
+ name: str | None = None
80
+ kind: str = "http"
81
+ config: JsonMap = Field(default_factory=dict)
82
+
83
+
84
+ class SchemaSpec(BaseModel):
85
+ """One named schema a local run is given, so a document may reference it by code."""
86
+
87
+ model_config = ConfigDict(frozen=True)
88
+
89
+ body: JsonMap
90
+ code: str | None = None
91
+ """The code to store under; a carried schema's is its map key. A ``--schema`` file leaves
92
+ this unset and takes its code from the body's ``$id`` or the filename."""
93
+
94
+ fallback_code: str | None = None
95
+ """The filename stem, used as the code when the schema names none through ``$id``."""
96
+
97
+
98
+ class LogLine(BaseModel):
99
+ """One product-telemetry entry, streamed to the terminal."""
100
+
101
+ model_config = ConfigDict(frozen=True)
102
+
103
+ level: str
104
+ step: str | None
105
+ message: str
106
+ at: datetime
107
+ fields: JsonMap = Field(default_factory=dict)
108
+ item: str | None = None
109
+ """Which element of a fan-out wrote this, when one did."""
110
+
111
+
112
+ class LocalStarted(BaseModel):
113
+ """The run exists; the first thing a local run yields."""
114
+
115
+ model_config = ConfigDict(frozen=True)
116
+
117
+ run_id: UUID
118
+ pipeline: str
119
+ scratch: str
120
+ """The prefix every URI this run writes sits under."""
121
+
122
+ root: Path
123
+ """The directory holding this run's database and artifacts."""
124
+
125
+ steps: list[str] = Field(default_factory=list[str])
126
+ """The step names in the order the document wrote them, which is how they are tracked."""
127
+
128
+
129
+ class Spill(BaseModel):
130
+ """Where an output was written, when it was too large to inline on the attempt."""
131
+
132
+ model_config = ConfigDict(frozen=True)
133
+
134
+ uri: str
135
+ size_bytes: int | None = None
136
+
137
+
138
+ class StepTransition(BaseModel):
139
+ """One step changing state."""
140
+
141
+ model_config = ConfigDict(frozen=True)
142
+
143
+ step: str
144
+ block: str
145
+ attempt: int
146
+ status: AttemptStatus
147
+ at: datetime
148
+ item: str | None = None
149
+ """Which element of a fan-out moved, when the step fans out."""
150
+
151
+ settled: bool = False
152
+ """Whether this is the status the attempt finished on rather than one on the way."""
153
+
154
+ duration_ms: int | None = None
155
+ output: JsonMap | None = None
156
+ spill: Spill | None = None
157
+
158
+
159
+ class StepFailure(BaseModel):
160
+ """Everything needed to diagnose one failed step.
161
+
162
+ A local run deletes its database when it ends, so anything not read out here is lost.
163
+ """
164
+
165
+ model_config = ConfigDict(frozen=True)
166
+
167
+ step: str
168
+ block: str
169
+ attempt: int
170
+ error_class: str | None = None
171
+ error: str | None = None
172
+ logs: list[str] = Field(default_factory=list[str])
173
+ input: JsonMap | None = None
174
+ """What the attempt was given, which is half of why it went wrong."""
175
+
176
+
177
+ class StepResult(BaseModel):
178
+ """What one settled attempt amounted to, so a local run can be read after it ends."""
179
+
180
+ model_config = ConfigDict(frozen=True)
181
+
182
+ step: str
183
+ block: str
184
+ status: AttemptStatus
185
+ depends_on: list[str] = Field(default_factory=list[str])
186
+ """The steps this one waited for, which is why it ran when it did."""
187
+
188
+ warnings: int = 0
189
+ """How many warnings or errors this attempt logged, whatever it settled as."""
190
+
191
+ item: str | None = None
192
+ duration_ms: int | None = None
193
+ output: JsonMap | None = None
194
+ spill: Spill | None = None
195
+
196
+
197
+ class LocalOutcome(BaseModel):
198
+ """What a local run amounted to."""
199
+
200
+ model_config = ConfigDict(frozen=True)
201
+
202
+ run_id: UUID
203
+ pipeline: str
204
+ status: RunStatus
205
+ error: str | None = None
206
+ failures: list[StepFailure] = Field(default_factory=list[StepFailure])
207
+ results: list[StepResult] = Field(default_factory=list[StepResult])
208
+ """Every settled attempt, in the order it settled; a local run has nowhere else to read."""
209
+
210
+ kept_at: Path | None = None
211
+ """Where the instance was left, when --keep or --root asked for it to stay."""
212
+
213
+ scratch: str | None = None
214
+ """The prefix this run's files sit under, reported when the instance was kept."""
215
+
216
+ @property
217
+ def exit_code(self) -> int:
218
+ """Map the outcome onto a process exit code."""
219
+ return 0 if self.status is RunStatus.SUCCEEDED else 1
220
+
221
+ @property
222
+ def tolerated(self) -> bool:
223
+ """Report whether this is the "finished, but something was tolerated" outcome."""
224
+ return self.status is RunStatus.COMPLETED_WITH_ERRORS
225
+
226
+
227
+ def connections_for(definitions: Sequence[PipelineDefinition], given: Sequence[ConnectionSpec]) -> list[ConnectionSpec]:
228
+ """Take the documents' own connections, and let one given by code replace it.
229
+
230
+ A published document carries what it needs to run; ``--connections`` is how somebody
231
+ points the same document at their own instance instead. Every document the run applies
232
+ is read, supporting ones included, and the first to carry a code keeps it.
233
+ """
234
+ embedded: dict[str, ConnectionSpec] = {}
235
+ for definition in definitions:
236
+ for code, carried in definition.connections.items():
237
+ embedded.setdefault(code, ConnectionSpec(code=code, kind=carried.kind, config=carried.config))
238
+ replaced = {spec.code for spec in given}
239
+ return [spec for code, spec in embedded.items() if code not in replaced] + list(given)
240
+
241
+
242
+ def schemas_for(definitions: Sequence[PipelineDefinition], given: Sequence[SchemaSpec]) -> list[SchemaSpec]:
243
+ """Take the documents' own schemas, and let one given by code replace it.
244
+
245
+ A published document carries the shapes it references; ``--schema`` is how somebody hands
246
+ the same document a schema of their own instead. Every document the run applies is read,
247
+ supporting ones included, so a child started through ``pipeline.run`` resolves the codes
248
+ it carries; the first document to carry a code keeps it.
249
+ """
250
+ replaced = {code for spec in given if (code := _resolved_code(spec)) is not None}
251
+ embedded: dict[str, SchemaSpec] = {}
252
+ for definition in definitions:
253
+ for code, body in definition.schemas.items():
254
+ if code not in replaced:
255
+ embedded.setdefault(code, SchemaSpec(body=body, code=code))
256
+ return list(embedded.values()) + list(given)
257
+
258
+
259
+ def _resolved_code(spec: SchemaSpec) -> str | None:
260
+ """Work out the code a schema spec will store under, or nothing when it names none."""
261
+ try:
262
+ code, _, _ = resolve_identity(
263
+ spec.body, code=spec.code, name=None, description=None, fallback_code=spec.fallback_code
264
+ )
265
+ except SchemaRefused:
266
+ return None
267
+ return code
268
+
269
+
270
+ def load_schema_specs(path: Path) -> SchemaSpec:
271
+ """Read one JSON Schema file into a spec, taking its fallback code from the filename."""
272
+ try:
273
+ loaded = safe_load(path.read_text())
274
+ except (OSError, yaml.YAMLError) as error:
275
+ raise LocalError(f"{path} could not be read: {error}") from error
276
+ if not isinstance(loaded, dict):
277
+ raise LocalError(f"{path} is not a JSON Schema: a schema is an object")
278
+ return SchemaSpec(body=cast("JsonMap", loaded), fallback_code=path.stem)
279
+
280
+
281
+ def load_connection_specs(path: Path) -> list[ConnectionSpec]:
282
+ """Read a connections file: a mapping of codes to kinds and configs, or a list of them."""
283
+ try:
284
+ loaded = safe_load(path.read_text())
285
+ except (OSError, yaml.YAMLError) as error:
286
+ raise LocalError(f"{path} could not be read: {error}") from error
287
+ if isinstance(loaded, dict) and "connections" in loaded:
288
+ loaded = cast("dict[str, Any]", loaded)["connections"]
289
+ if isinstance(loaded, dict):
290
+ return [
291
+ ConnectionSpec(code=code, **cast("dict[str, Any]", body))
292
+ for code, body in cast("dict[str, Any]", loaded).items()
293
+ ]
294
+ if isinstance(loaded, list):
295
+ return [ConnectionSpec(**cast("dict[str, Any]", body)) for body in cast("list[object]", loaded)]
296
+ raise LocalError(f"{path} should hold a connections mapping or a list of connections")
297
+
298
+
299
+ def prepare_root(root: Path) -> Path:
300
+ """Make the directory a local run is being held in, refusing a path that is not one."""
301
+ if root.exists() and not root.is_dir():
302
+ raise LocalError(f"{root} is not a directory, so a local run cannot be held there")
303
+ root.mkdir(parents=True, exist_ok=True)
304
+ return root
305
+
306
+
307
+ def local_settings(root: Path, *, inherited: Settings | None = None) -> Settings:
308
+ """Build the settings a local run uses: a throwaway database, the host's own policy.
309
+
310
+ The unsafe-block allowlist must stay inherited: a local run may execute no block the
311
+ host has not allowlisted.
312
+ """
313
+ base = inherited or get_settings()
314
+ return Settings(
315
+ database_url=f"sqlite+aiosqlite:///{root / 'dirigent.db'}",
316
+ artifact_root=f"file://{root / 'artifacts'}",
317
+ work_root=str(root / "work"),
318
+ secret_key=SecretStr(Fernet.generate_key().decode()),
319
+ enabled_unsafe_blocks=list(base.enabled_unsafe_blocks),
320
+ storage_connections=dict(base.storage_connections),
321
+ inline_artifact_max=base.inline_artifact_max,
322
+ log_level=base.log_level,
323
+ log_format=base.log_format,
324
+ claim_idle=timedelta(milliseconds=50),
325
+ heartbeat=timedelta(seconds=5),
326
+ sweep_interval=timedelta(seconds=5),
327
+ worker_concurrency=base.worker_concurrency,
328
+ )
329
+
330
+
331
+ async def _seed_connections(
332
+ sessions: async_sessionmaker[AsyncSession],
333
+ services: EngineServices,
334
+ specs: Sequence[ConnectionSpec],
335
+ ) -> None:
336
+ """Store the connections a local run was given, sealed with its ephemeral key."""
337
+ for spec in specs:
338
+ contributed = services.host.connection_kinds.get(spec.kind)
339
+ if contributed is None:
340
+ known = ", ".join(sorted(services.host.connection_kinds)) or "none are installed"
341
+ raise LocalError(f"no connection kind {spec.kind!r} is installed ({known})")
342
+ validated = contributed.config_model.model_validate(spec.config)
343
+ public, envelope, key_id = services.secrets.encrypt_config(contributed.config_model, validated)
344
+ async with session_scope(sessions) as session:
345
+ session.add(
346
+ Connection(
347
+ code=spec.code,
348
+ name=spec.name,
349
+ kind=spec.kind,
350
+ config=public,
351
+ secret_envelope=envelope,
352
+ secret_key_id=key_id,
353
+ )
354
+ )
355
+
356
+
357
+ async def _seed_schemas(
358
+ sessions: async_sessionmaker[AsyncSession],
359
+ specs: Sequence[SchemaSpec],
360
+ ) -> None:
361
+ """Store the named schemas a local run was given, so a document may reference them by code."""
362
+ for spec in specs:
363
+ try:
364
+ async with session_scope(sessions) as session:
365
+ await store_schema(session, spec.body, code=spec.code, fallback_code=spec.fallback_code)
366
+ except SchemaRefused as error:
367
+ raise LocalError(str(error)) from error
368
+
369
+
370
+ async def _new_logs(
371
+ sessions: async_sessionmaker[AsyncSession], run_id: UUID, after: int, labels: Mapping[UUID, str]
372
+ ) -> tuple[list[LogLine], int, bool]:
373
+ """Read one page of the log entries written since the last poll.
374
+
375
+ The third value says the page came back full, which is how the caller knows to read
376
+ again before it sleeps.
377
+ """
378
+ async with session_scope(sessions) as session:
379
+ rows = await session.execute(
380
+ sa.select(LogEntry)
381
+ .where(LogEntry.run_id == run_id, LogEntry.id > after)
382
+ .order_by(LogEntry.id)
383
+ .limit(LOCAL_LOG_PAGE + 1)
384
+ )
385
+ entries = list(rows.scalars())
386
+ lines = [
387
+ LogLine(
388
+ level=entry.level.value,
389
+ step=entry.step_name,
390
+ message=entry.message,
391
+ at=entry.created_at,
392
+ fields=dict(entry.fields or {}),
393
+ item=labels.get(entry.step_attempt_id) if entry.step_attempt_id else None,
394
+ )
395
+ for entry in entries
396
+ ]
397
+ return lines, entries[-1].id if entries else after, len(entries) > LOCAL_LOG_PAGE
398
+
399
+
400
+ async def _warnings(session: AsyncSession, run_id: UUID) -> dict[UUID, int]:
401
+ """Count the warnings and errors each attempt logged, which a succeeded step can still have."""
402
+ rows = await session.execute(
403
+ sa.select(LogEntry.step_attempt_id, sa.func.count())
404
+ .where(LogEntry.run_id == run_id, LogEntry.level.in_((LogLevel.WARNING, LogLevel.ERROR)))
405
+ .group_by(LogEntry.step_attempt_id)
406
+ )
407
+ return {attempt_id: count for attempt_id, count in rows.all() if attempt_id is not None}
408
+
409
+
410
+ async def _item_indexes(session: AsyncSession, run_id: UUID) -> dict[UUID, int]:
411
+ """Map each attempt of a fan-out step to the position of the element it was created for."""
412
+ rows = await session.execute(
413
+ sa.select(StepAttempt.id, RunItem.item_index)
414
+ .join(RunItem, RunItem.id == StepAttempt.run_item_id)
415
+ .where(StepAttempt.run_id == run_id)
416
+ )
417
+ return {attempt_id: index for attempt_id, index in rows.all()}
418
+
419
+
420
+ async def _item_labels(session: AsyncSession, run_id: UUID) -> dict[UUID, str]:
421
+ """Map each attempt of a fan-out step to the element it was created for."""
422
+ rows = await session.execute(
423
+ sa.select(StepAttempt.id, RunItem.item_index, RunItem.item_key)
424
+ .join(RunItem, RunItem.id == StepAttempt.run_item_id)
425
+ .where(StepAttempt.run_id == run_id)
426
+ )
427
+ return {attempt_id: item_label(index, key) for attempt_id, index, key in rows.all()}
428
+
429
+
430
+ def item_label(index: int, key: str) -> str:
431
+ """Render one fan-out element the way the stream shows it: its key, or its position."""
432
+ trimmed = key.strip()
433
+ return trimmed if trimmed and len(trimmed) <= ITEM_LABEL_MAX else str(index)
434
+
435
+
436
+ FAILURE_LOG_LINES: Final = 20
437
+
438
+
439
+ async def _failures(
440
+ sessions: async_sessionmaker[AsyncSession], run_id: UUID, order: Sequence[str] = ()
441
+ ) -> list[StepFailure]:
442
+ """Gather every failed attempt with its error and its own log lines."""
443
+ async with session_scope(sessions) as session:
444
+ rows = await session.execute(
445
+ sa.select(StepAttempt, RunItem.item_index)
446
+ .outerjoin(RunItem, RunItem.id == StepAttempt.run_item_id)
447
+ .where(StepAttempt.run_id == run_id, StepAttempt.status == AttemptStatus.FAILED)
448
+ )
449
+ found = rows.all()
450
+ indexes = {attempt.id: index for attempt, index in found if index is not None}
451
+ attempts = in_execution_order([attempt for attempt, _ in found], indexes, order)
452
+ failures: list[StepFailure] = []
453
+ for attempt in attempts:
454
+ logs = await session.execute(
455
+ sa.select(LogEntry)
456
+ .where(LogEntry.step_attempt_id == attempt.id)
457
+ .order_by(LogEntry.id.desc())
458
+ .limit(FAILURE_LOG_LINES)
459
+ )
460
+ failures.append(
461
+ StepFailure(
462
+ step=attempt.step_name,
463
+ block=attempt.block_id,
464
+ attempt=attempt.attempt,
465
+ error_class=attempt.error_class,
466
+ error=attempt.error,
467
+ logs=[f"{entry.level.value}: {entry.message}" for entry in reversed(list(logs.scalars()))],
468
+ input=dict(attempt.input) if attempt.input else None,
469
+ )
470
+ )
471
+ return failures
472
+
473
+
474
+ async def _spills(session: AsyncSession, run_id: UUID) -> dict[UUID, Spill]:
475
+ """Map each attempt whose output was written to storage to where it went.
476
+
477
+ An artifact with an inline value is not a spill: the attempt row carries it already.
478
+ """
479
+ rows = await session.execute(
480
+ sa.select(ArtifactRef.step_attempt_id, ArtifactRef.uri, ArtifactRef.size_bytes).where(
481
+ ArtifactRef.run_id == run_id, ArtifactRef.uri.is_not(None)
482
+ )
483
+ )
484
+ return {
485
+ attempt_id: Spill(uri=uri, size_bytes=size)
486
+ for attempt_id, uri, size in rows.all()
487
+ if attempt_id is not None and uri is not None
488
+ }
489
+
490
+
491
+ async def _results(
492
+ sessions: async_sessionmaker[AsyncSession],
493
+ run_id: UUID,
494
+ edges: Mapping[str, Sequence[str]],
495
+ order: Sequence[str] = (),
496
+ ) -> list[StepResult]:
497
+ """Gather every settled attempt with its output, which a deleted database cannot be asked for."""
498
+ async with session_scope(sessions) as session:
499
+ labels = await _item_labels(session, run_id)
500
+ spills = await _spills(session, run_id)
501
+ warned = await _warnings(session, run_id)
502
+ rows = await session.execute(
503
+ sa.select(StepAttempt, RunItem.item_index)
504
+ .outerjoin(RunItem, RunItem.id == StepAttempt.run_item_id)
505
+ .where(StepAttempt.run_id == run_id, StepAttempt.status.in_(SETTLED))
506
+ )
507
+ found = rows.all()
508
+ indexes = {attempt.id: index for attempt, index in found if index is not None}
509
+ return [
510
+ StepResult(
511
+ step=attempt.step_name,
512
+ block=attempt.block_id,
513
+ status=attempt.status,
514
+ depends_on=list(edges.get(attempt.step_name, ())),
515
+ warnings=warned.get(attempt.id, 0),
516
+ item=labels.get(attempt.id),
517
+ duration_ms=_duration_ms(attempt),
518
+ output=dict(attempt.output) if attempt.output else None,
519
+ spill=spills.get(attempt.id),
520
+ )
521
+ for attempt in in_execution_order([attempt for attempt, _ in found], indexes, order)
522
+ ]
523
+
524
+
525
+ def _duration_ms(attempt: StepAttempt) -> int | None:
526
+ """Report how long one attempt took, or nothing when it never started or never finished."""
527
+ if attempt.started_at is None or attempt.finished_at is None:
528
+ return None
529
+ return round((attempt.finished_at - attempt.started_at).total_seconds() * 1000)
530
+
531
+
532
+ class _Items:
533
+ """A run's fan-out labels and positions, re-read only when a new attempt turns up."""
534
+
535
+ def __init__(self) -> None:
536
+ """Start knowing no attempt at all."""
537
+ self.known: set[UUID] = set()
538
+ self.labels: dict[UUID, str] = {}
539
+ self.indexes: dict[UUID, int] = {}
540
+
541
+ async def see(self, session: AsyncSession, run_id: UUID, attempts: Sequence[StepAttempt]) -> None:
542
+ """Take the attempts one poll read, and re-read the maps if any of them is new."""
543
+ ids = {attempt.id for attempt in attempts}
544
+ if ids <= self.known:
545
+ return
546
+ self.known = ids
547
+ self.labels = await _item_labels(session, run_id)
548
+ self.indexes = await _item_indexes(session, run_id)
549
+
550
+
551
+ class Tick(BaseModel):
552
+ """What one poll of a run's own tables saw: what moved, and where the run stands."""
553
+
554
+ transitions: list[StepTransition]
555
+ status: RunStatus | None
556
+ error: str | None
557
+
558
+
559
+ async def _transitions(
560
+ sessions: async_sessionmaker[AsyncSession],
561
+ run_id: UUID,
562
+ seen: dict[UUID, AttemptStatus],
563
+ items: _Items,
564
+ order: Sequence[str] = (),
565
+ ) -> Tick:
566
+ """Report every attempt whose status changed since the last poll, and the run's own status."""
567
+ async with session_scope(sessions) as session:
568
+ run = await session.get(Run, run_id)
569
+ status = run.status if run else None
570
+ error = run.error if run else None
571
+ rows = await session.execute(sa.select(StepAttempt).where(StepAttempt.run_id == run_id))
572
+ found = list(rows.scalars())
573
+ await items.see(session, run_id, found)
574
+ labels, indexes = items.labels, items.indexes
575
+ attempts = in_execution_order(found, indexes, order)
576
+ spills = await _spills(session, run_id)
577
+ changed: list[StepTransition] = []
578
+ for attempt in attempts:
579
+ # Keyed on the attempt itself. Every element of a fan-out is attempt 1 of the same
580
+ # step, so a key built from the step name needs the item to tell them apart -- and an
581
+ # item whose label is not readable yet leaves two of them sharing one key, which
582
+ # announces one and swallows the rest. The id is the identity of the thing that moved.
583
+ if seen.get(attempt.id) is attempt.status:
584
+ continue
585
+ seen[attempt.id] = attempt.status
586
+ if attempt.status is AttemptStatus.PENDING:
587
+ continue
588
+ settled = attempt.status in SETTLED
589
+ changed.append(
590
+ StepTransition(
591
+ step=attempt.step_name,
592
+ block=attempt.block_id,
593
+ attempt=attempt.attempt,
594
+ status=attempt.status,
595
+ at=attempt.finished_at or attempt.started_at or attempt.available_at or attempt.updated_at,
596
+ item=labels.get(attempt.id),
597
+ settled=settled,
598
+ duration_ms=_duration_ms(attempt) if settled else None,
599
+ output=dict(attempt.output) if settled and attempt.output else None,
600
+ spill=spills.get(attempt.id) if settled else None,
601
+ )
602
+ )
603
+ # Stable, so steps that became claimable in the same transaction -- which is every root
604
+ # of a run -- keep the order in_execution_order put them in, which is the written one.
605
+ return Tick(transitions=sorted(changed, key=lambda item: item.at), status=status, error=error)
606
+
607
+
608
+ async def run_document(
609
+ text: str,
610
+ *,
611
+ params: JsonMap | None = None,
612
+ window: RunWindow | None = None,
613
+ log_levels: JsonMap | None = None,
614
+ connections: Sequence[ConnectionSpec] = (),
615
+ schemas: Sequence[SchemaSpec] = (),
616
+ also: Sequence[str] = (),
617
+ inherited: Settings | None = None,
618
+ deadline_seconds: float = DEFAULT_DEADLINE_SECONDS,
619
+ keep: bool = False,
620
+ root: Path | None = None,
621
+ ) -> AsyncIterator[LocalStarted | LogLine | StepTransition | LocalOutcome]:
622
+ """Apply and run a document in a throwaway instance, yielding logs then the outcome.
623
+
624
+ The last item yielded is always the outcome. ``also`` holds documents to apply first
625
+ and not run, for a pipeline that composes another one. ``root`` names a directory to hold
626
+ the instance in and keep, so a later run reads what this one wrote.
627
+ """
628
+ held = prepare_root(root) if root is not None else Path(tempfile.mkdtemp(prefix="dirigent-local-"))
629
+ leave = keep or root is not None
630
+ definition = load_pipeline_text(text)
631
+ # The instance is a directory rather than a context manager because ``keep`` leaves it
632
+ # behind: a run whose database is deleted cannot be asked what it produced.
633
+ try:
634
+ settings = local_settings(held, inherited=inherited)
635
+ await migrations.upgrade_async(settings=settings)
636
+ services = EngineServices.build(settings, load_plugin_host())
637
+ engine = create_engine(settings)
638
+ sessions = create_session_factory(engine)
639
+ try:
640
+ supporting = [_parse_supporting(extra) for extra in also]
641
+ carried = [definition, *supporting]
642
+ await _seed_connections(sessions, services, connections_for(carried, connections))
643
+ await _seed_schemas(sessions, schemas_for(carried, schemas))
644
+ for extra_definition in supporting:
645
+ async with session_scope(sessions) as session:
646
+ await _apply_supporting(session, services, extra_definition)
647
+ async with session_scope(sessions) as session:
648
+ applied = await apply_document(session, services, definition)
649
+ if not applied.plan.ok:
650
+ raise LocalError(
651
+ "the document does not validate against the installed catalog:\n"
652
+ + "\n".join(f" {issue}" for issue in applied.plan.issues)
653
+ )
654
+ version = await _current_version(session, definition.code)
655
+ try:
656
+ run = await create_run(
657
+ session,
658
+ services,
659
+ version,
660
+ params=params or {},
661
+ attribution=Attribution(kind=TriggerKind.USER, label="dg run --local"),
662
+ window=window,
663
+ log_levels=log_levels,
664
+ )
665
+ except (RunCreationError, ParameterError) as error:
666
+ raise LocalError(str(error)) from error
667
+ if run is None: # pragma: no cover - a throwaway instance has no other runs
668
+ raise LocalError("the run was skipped by the pipeline's concurrency policy")
669
+ run_id = run.id
670
+ scratch = scratch_prefix(settings.artifact_root, run_id)
671
+ yield LocalStarted(
672
+ run_id=run_id,
673
+ pipeline=definition.code,
674
+ scratch=scratch,
675
+ root=held,
676
+ steps=list(definition.steps),
677
+ )
678
+ kept = {"kept_at": held, "scratch": scratch}
679
+ edges = {name: list(step.depends_on) for name, step in definition.steps.items()}
680
+ async for item in _drive(
681
+ sessions, services, run_id, definition.code, edges, list(definition.steps), deadline_seconds
682
+ ):
683
+ yield item.model_copy(update=kept) if leave and isinstance(item, LocalOutcome) else item
684
+ finally:
685
+ await engine.dispose()
686
+ finally:
687
+ if not leave:
688
+ shutil.rmtree(held, ignore_errors=True)
689
+
690
+
691
+ def _parse_supporting(text: str) -> PipelineDefinition:
692
+ """Read one document a run depends on but does not run."""
693
+ try:
694
+ return load_pipeline_text(text)
695
+ except DocumentError as error:
696
+ raise LocalError(f"a supporting document does not parse: {error}") from error
697
+
698
+
699
+ async def _apply_supporting(session: AsyncSession, services: EngineServices, supporting: PipelineDefinition) -> None:
700
+ """Apply one document a run depends on, without running it."""
701
+ applied = await apply_document(session, services, supporting)
702
+ if not applied.plan.ok:
703
+ raise LocalError(
704
+ f"the supporting document {supporting.code!r} does not validate:\n"
705
+ + "\n".join(f" {issue}" for issue in applied.plan.issues)
706
+ )
707
+
708
+
709
+ async def _current_version(session: AsyncSession, code: str) -> Any:
710
+ """Read the version the apply just wrote."""
711
+ from dirigent_core.pipelines import get_version, require_pipeline
712
+
713
+ return await get_version(session, await require_pipeline(session, code))
714
+
715
+
716
+ def _merged(transitions: list[StepTransition], lines: list[LogLine]) -> list[StepTransition | LogLine]:
717
+ """Interleave transitions and log lines in the order they actually happened.
718
+
719
+ The engine commits an attempt's log entries while it runs and the rest with its outcome,
720
+ so a poll can hold both and only the timestamps order the two lists against each other.
721
+ Each list keeps its own order rather than being re-sorted: steps that have not started
722
+ have only the microseconds of their row's creation to sort on, which is not an order
723
+ anybody wrote or watched.
724
+ """
725
+ merged: list[StepTransition | LogLine] = []
726
+ left, right = list(transitions), list(lines)
727
+ while left and right:
728
+ merged.append(left.pop(0) if left[0].at <= right[0].at else right.pop(0))
729
+ merged.extend(left)
730
+ merged.extend(right)
731
+ return merged
732
+
733
+
734
+ async def _drive(
735
+ sessions: async_sessionmaker[AsyncSession],
736
+ services: EngineServices,
737
+ run_id: UUID,
738
+ pipeline: str,
739
+ edges: Mapping[str, Sequence[str]],
740
+ order: Sequence[str],
741
+ deadline_seconds: float,
742
+ ) -> AsyncIterator[LogLine | StepTransition | LocalOutcome]:
743
+ """Run an embedded worker until the run settles, streaming transitions and logs."""
744
+ worker = Worker(sessions, services, name="local", sweeper=False)
745
+ task = asyncio.create_task(worker.run())
746
+ cursor = 0
747
+ waited = 0.0
748
+ interval = POLL_SECONDS
749
+ seen: dict[UUID, AttemptStatus] = {}
750
+ items = _Items()
751
+ try:
752
+ while waited < deadline_seconds:
753
+ # The status is read before the log is: a run that had settled by then wrote its
754
+ # last entry before it settled, so the pages below carry the whole of it.
755
+ tick = await _transitions(sessions, run_id, seen, items, order)
756
+ moved = bool(tick.transitions)
757
+ transitions = tick.transitions
758
+ while True:
759
+ lines, cursor, more = await _new_logs(sessions, run_id, cursor, items.labels)
760
+ moved = moved or bool(lines)
761
+ for event in _merged(transitions, lines):
762
+ yield event
763
+ transitions = []
764
+ if not more:
765
+ break
766
+ if tick.status in TERMINAL:
767
+ yield LocalOutcome(
768
+ run_id=run_id,
769
+ pipeline=pipeline,
770
+ status=cast("RunStatus", tick.status),
771
+ error=tick.error,
772
+ failures=await _failures(sessions, run_id, order),
773
+ results=await _results(sessions, run_id, edges, order),
774
+ )
775
+ return
776
+ interval = POLL_SECONDS if moved else min(interval * POLL_WIDEN, POLL_CEILING)
777
+ await asyncio.sleep(interval)
778
+ waited += interval
779
+ yield LocalOutcome(
780
+ run_id=run_id,
781
+ pipeline=pipeline,
782
+ status=RunStatus.FAILED,
783
+ error=f"the run did not finish within {deadline_seconds:.0f}s",
784
+ failures=await _failures(sessions, run_id, order),
785
+ results=await _results(sessions, run_id, edges, order),
786
+ )
787
+ finally:
788
+ worker.request_stop()
789
+ with contextlib.suppress(asyncio.CancelledError):
790
+ await task