dirigent-core 0.17.2__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/PKG-INFO +4 -4
  2. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/pyproject.toml +4 -4
  3. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/pyproject.toml.orig +4 -4
  4. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +3 -0
  5. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/artifacts.py +41 -2
  6. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/config.py +6 -4
  7. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/database.py +25 -1
  8. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/documents.py +5 -0
  9. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/claim.py +3 -0
  10. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/context.py +36 -2
  11. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/executor.py +33 -5
  12. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/references.py +15 -3
  13. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/runs.py +1 -0
  14. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/logging.py +0 -3
  15. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/messages.py +25 -3
  16. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/models.py +4 -0
  17. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/LICENSE +0 -0
  18. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/README.md +0 -0
  19. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/__init__.py +0 -0
  20. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/env.py +0 -0
  21. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/script.py.mako +0 -0
  22. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alerting.py +0 -0
  23. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/auth.py +0 -0
  24. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/blockdocs.py +0 -0
  25. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/configdocs.py +0 -0
  26. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/directory.py +0 -0
  27. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/documentschema.py +0 -0
  28. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/__init__.py +0 -0
  29. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/definition.py +0 -0
  30. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/failure.py +0 -0
  31. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/recovery.py +0 -0
  32. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/services.py +0 -0
  33. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/state.py +0 -0
  34. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/errors.py +0 -0
  35. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/examples.py +0 -0
  36. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/ids.py +0 -0
  37. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/migrations.py +0 -0
  38. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/pipelines.py +0 -0
  39. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/plugins.py +0 -0
  40. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/protocol.py +0 -0
  41. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/py.typed +0 -0
  42. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/ratelimit.py +0 -0
  43. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/registry.py +0 -0
  44. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/reporting.py +0 -0
  45. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/retention.py +0 -0
  46. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/scheduler.py +0 -0
  47. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/schemas.py +0 -0
  48. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/secrets.py +0 -0
  49. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/storage.py +0 -0
  50. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/telemetry.py +0 -0
  51. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/trigger_documents.py +0 -0
  52. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/__init__.py +0 -0
  53. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/backfill.py +0 -0
  54. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/materialize.py +0 -0
  55. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/schedules.py +0 -0
  56. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/webhooks.py +0 -0
  57. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/types.py +0 -0
  58. {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-core
3
- Version: 0.17.2
3
+ Version: 0.18.0
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
12
12
  Requires-Dist: asyncpg>=0.31.0
13
13
  Requires-Dist: cronsim>=2.7
14
14
  Requires-Dist: cryptography>=46.0.5
15
- Requires-Dist: dirigent-client==0.17.2
16
- Requires-Dist: dirigent-common==0.17.2
17
- Requires-Dist: dirigent-plugin==0.17.2
15
+ Requires-Dist: dirigent-client==0.18.0
16
+ Requires-Dist: dirigent-common==0.18.0
17
+ Requires-Dist: dirigent-plugin==0.18.0
18
18
  Requires-Dist: jsonschema>=4.26.0
19
19
  Requires-Dist: opentelemetry-api>=1.44.0
20
20
  Requires-Dist: pydantic-settings>=2.15.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.17.2"
3
+ version = "0.18.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.17.2",
21
- "dirigent-common==0.17.2",
22
- "dirigent-plugin==0.17.2",
20
+ "dirigent-client==0.18.0",
21
+ "dirigent-common==0.18.0",
22
+ "dirigent-plugin==0.18.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.17.2"
3
+ version = "0.18.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.17.2",
21
- "dirigent-common==0.17.2",
22
- "dirigent-plugin==0.17.2",
20
+ "dirigent-client==0.18.0",
21
+ "dirigent-common==0.18.0",
22
+ "dirigent-plugin==0.18.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -808,6 +808,9 @@ def upgrade() -> None:
808
808
  sa.Column(
809
809
  "remote_handle", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
810
810
  ),
811
+ sa.Column(
812
+ "fetched_output", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
813
+ ),
811
814
  sa.Column(
812
815
  "poke_cursor", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
813
816
  ),
@@ -2,6 +2,7 @@
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ from collections.abc import AsyncGenerator
5
6
  from contextlib import suppress
6
7
  from typing import Final
7
8
  from uuid import UUID
@@ -9,6 +10,8 @@ from uuid import UUID
9
10
  from sqlalchemy.ext.asyncio import AsyncSession
10
11
 
11
12
  from dirigent_common import JsonMap
13
+ from dirigent_core.errors import DomainError
14
+ from dirigent_core.messages import OBJECT_MISSING
12
15
  from dirigent_core.models import ArtifactRef, StepAttempt
13
16
  from dirigent_core.storage import Storage, StorageError, join_uri, parse_uri
14
17
 
@@ -19,6 +22,24 @@ MARKDOWN_CONTENT_TYPE: Final = "text/markdown"
19
22
  #: The key a text document inlines under, so an inline row is still a JSON document.
20
23
  TEXT_KEY: Final = "text"
21
24
 
25
+ #: What an artifact row belonging to the run rather than to a step attempt names as its attempt.
26
+ NO_ATTEMPT: Final = "-"
27
+
28
+
29
+ class ObjectMissing(DomainError):
30
+ """The object this row names is not in storage."""
31
+
32
+ status = 404
33
+ message = OBJECT_MISSING
34
+
35
+ def __init__(self, reference: ArtifactRef) -> None:
36
+ """Name the object that is gone, and the run and attempt whose row still names it."""
37
+ super().__init__(
38
+ uri=reference.uri or "",
39
+ run=str(reference.run_id),
40
+ attempt=str(reference.step_attempt_id) if reference.step_attempt_id else NO_ATTEMPT,
41
+ )
42
+
22
43
 
23
44
  def canonical_json(value: object) -> bytes:
24
45
  """Serialize a value the one way the engine hashes and stores it."""
@@ -108,6 +129,24 @@ async def persist_document(
108
129
  return reference
109
130
 
110
131
 
132
+ async def _present_uri(storage: Storage, reference: ArtifactRef) -> str:
133
+ """Return the URI a row names once storage confirms the object is there, or refuse.
134
+
135
+ A database restored without the artifact root it was taken beside still holds every row,
136
+ so the object is stat-ed before it is opened: a read that refuses here names what is gone,
137
+ where one that opened the stream first would break mid-body with the status already sent.
138
+ """
139
+ uri = reference.uri or ""
140
+ if await storage.stat(uri) is None:
141
+ raise ObjectMissing(reference)
142
+ return uri
143
+
144
+
145
+ async def open_artifact(storage: Storage, reference: ArtifactRef) -> AsyncGenerator[bytes]:
146
+ """Open the stored object a row names for streaming, refusing before the first byte."""
147
+ return storage.open_read(await _present_uri(storage, reference))
148
+
149
+
111
150
  async def load_document(storage: Storage, reference: ArtifactRef) -> str:
112
151
  """Read a text document back, from the row when it inlined and from storage when it did not."""
113
152
  if reference.inline_value is not None:
@@ -115,7 +154,7 @@ async def load_document(storage: Storage, reference: ArtifactRef) -> str:
115
154
  return inlined if isinstance(inlined, str) else ""
116
155
  if reference.uri is None:
117
156
  return ""
118
- return (await storage.read_bytes(reference.uri)).decode()
157
+ return (await storage.read_bytes(await _present_uri(storage, reference))).decode()
119
158
 
120
159
 
121
160
  async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UUID) -> JsonMap | None:
@@ -127,6 +166,6 @@ async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UU
127
166
  return reference.inline_value
128
167
  if reference.uri is None:
129
168
  return None
130
- payload = await storage.read_bytes(reference.uri)
169
+ payload = await storage.read_bytes(await _present_uri(storage, reference))
131
170
  loaded: JsonMap = json.loads(payload)
132
171
  return loaded
@@ -161,10 +161,12 @@ class Settings(BaseSettings):
161
161
  """How loud the process log is. This is the process log; a run's own telemetry is the
162
162
  ``log_entries`` table, and a CLI ``-v`` or ``--debug`` flag wins over this."""
163
163
 
164
- log_format: Literal["console", "json"] = "console"
165
- """How a command spells a log line: ``console`` for a person, ``json`` for a collector.
164
+ log_format: Literal["console", "json"] | None = None
165
+ """How a command spells its output and its logs: ``console`` renders, ``json`` writes records.
166
166
 
167
- The four process commands write NDJSON whatever this says.
167
+ Unset, the terminal decides: a terminal gets the rendering, and a pipe, a container's log,
168
+ an agent's shell and CI get NDJSON, ``dg dev`` and ``dg server`` included. A command's
169
+ ``--json`` or ``-o console`` wins over it.
168
170
  """
169
171
 
170
172
  artifact_root: str = f"file://./{STATE_DIR}/artifacts"
@@ -248,7 +250,7 @@ class Settings(BaseSettings):
248
250
  """A running run with no attempt progress for this long is flagged as stuck."""
249
251
 
250
252
  stale_worker: Duration = Field(default=timedelta(minutes=15), gt=timedelta(0))
251
- """A worker whose registry row is older than this has that row reaped by the sweeper."""
253
+ """A worker whose last heartbeat is older than this has its registry row reaped by the sweeper."""
252
254
 
253
255
  docker_reap_interval: Duration = timedelta(minutes=5)
254
256
  """How often a docker-capable worker looks for compose stacks whose run has ended.
@@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable
6
6
  from contextlib import asynccontextmanager, suppress
7
7
  from typing import Any, Final
8
8
 
9
+ import aiosqlite
9
10
  import sqlalchemy as sa
10
11
  import structlog
11
12
  from sqlalchemy import event
@@ -78,11 +79,34 @@ def _configure_sqlite(engine: AsyncEngine) -> None:
78
79
  connection.exec_driver_sql("BEGIN IMMEDIATE")
79
80
 
80
81
 
82
+ async def open_sqlite(*args: Any, **kwargs: Any) -> aiosqlite.Connection:
83
+ """Open one SQLite connection, waiting for its thread when the file will not open.
84
+
85
+ aiosqlite drives every connection from a worker thread that hands results back through
86
+ ``call_soon_threadsafe``. A connection that fails to open queues that thread's stop and
87
+ raises without waiting for it, so the thread can call into an event loop that has closed
88
+ in the meantime and die with ``RuntimeError: Event loop is closed``.
89
+ """
90
+ connection = aiosqlite.connect(*args, **kwargs)
91
+ worker = connection._thread # pyright: ignore[reportPrivateUsage] - the driver's own thread
92
+ worker.daemon = True
93
+ try:
94
+ return await connection
95
+ except BaseException:
96
+ await asyncio.to_thread(worker.join)
97
+ raise
98
+
99
+
81
100
  def create_engine(settings: Settings) -> AsyncEngine:
82
101
  """Build the async engine for the configured database."""
83
102
  if settings.is_sqlite:
84
103
  ensure_sqlite_directory(settings)
85
- engine = create_async_engine(settings.database_url, echo=settings.database_echo, future=True)
104
+ engine = create_async_engine(
105
+ settings.database_url,
106
+ echo=settings.database_echo,
107
+ future=True,
108
+ connect_args={"async_creator_fn": open_sqlite},
109
+ )
86
110
  _configure_sqlite(engine)
87
111
  return engine
88
112
  return create_async_engine(
@@ -44,6 +44,7 @@ from dirigent_core.messages import (
44
44
  NOT_YAML,
45
45
  PARAMS_SCHEMA_INVALID,
46
46
  REFERENCE_GRID_IN_CONFIG,
47
+ REFERENCE_MALFORMED_ARTIFACTS,
47
48
  REFERENCE_MALFORMED_ITEM,
48
49
  REFERENCE_MALFORMED_RUN,
49
50
  REFERENCE_MALFORMED_STEP,
@@ -892,6 +893,10 @@ def _reference_problem(
892
893
  return None
893
894
  case ["run", *_]:
894
895
  return Issue.of(REFERENCE_MALFORMED_RUN, reference=reference)
896
+ case ["artifacts"]:
897
+ return None
898
+ case ["artifacts", *_]:
899
+ return Issue.of(REFERENCE_MALFORMED_ARTIFACTS, reference=reference)
895
900
  case [namespace, *_]:
896
901
  return Issue.of(REFERENCE_UNKNOWN_NAMESPACE, reference=reference, namespace=repr(namespace))
897
902
  case _: # pragma: no cover - every shape above is total over a list of strings
@@ -60,6 +60,9 @@ class ClaimedUnit(BaseModel):
60
60
  """The run's trace context, so an attempt's span joins the trace the run started in."""
61
61
 
62
62
  remote_handle: RemoteHandle | None = None
63
+ fetched_output: JsonMap | None = None
64
+ """The result a previous call already fetched and committed, which settles this claim."""
65
+
63
66
  poke_cursor: JsonMap | None = None
64
67
  """The cursor the last committed poke returned, handed to the next one."""
65
68
 
@@ -6,7 +6,8 @@ long step is visible working and its last lines still land with the outcome.
6
6
  """
7
7
 
8
8
  import asyncio
9
- from collections.abc import Mapping
9
+ from collections.abc import AsyncGenerator, Mapping
10
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
10
11
  from datetime import UTC, datetime, timedelta
11
12
  from fnmatch import fnmatch
12
13
  from pathlib import Path
@@ -26,7 +27,7 @@ from dirigent_core.messages import RUN_UNKNOWN_CONNECTION, RUN_UNKNOWN_SCHEMA, U
26
27
  from dirigent_core.models import Connection, Schema
27
28
  from dirigent_core.secrets import SecretBox
28
29
  from dirigent_core.storage import AttemptStorage, Storage, connection_binder, work_dir
29
- from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs
30
+ from dirigent_plugin import BlockFailure, ByteSink, Capture, ConnectionRef, ErrorClass, Logger, Runs
30
31
 
31
32
  #: What an HTTP connection is assumed to call its fields, so core can build a client for
32
33
  #: any connection kind without importing the package that contributed it.
@@ -230,6 +231,19 @@ class BufferedLogger(BaseModel):
230
231
  self.full.set()
231
232
 
232
233
 
234
+ class CapturedStream:
235
+ """A sink that carries the URI the engine opened it under, for a block to put in its output."""
236
+
237
+ def __init__(self, uri: str, sink: ByteSink) -> None:
238
+ """Bind the storage sink to the URI naming it."""
239
+ self.uri = uri
240
+ self._sink = sink
241
+
242
+ async def write(self, data: bytes) -> int:
243
+ """Append bytes to the stream and return how many were accepted."""
244
+ return await self._sink.write(data)
245
+
246
+
233
247
  class EngineStepContext:
234
248
  """The concrete StepContext the engine hands a block for exactly one call."""
235
249
 
@@ -297,6 +311,26 @@ class EngineStepContext:
297
311
  """Return the run-scoped URI prefix for intermediate artifacts."""
298
312
  return self._scratch
299
313
 
314
+ def capture(self, name: str, *, content_type: str = "text/plain") -> AbstractAsyncContextManager[Capture]:
315
+ """Open the storage object a block's named stream is written to, under the run's scratch."""
316
+ uri = f"{self._scratch.rstrip('/')}/{self._segment()}-{name}"
317
+
318
+ @asynccontextmanager
319
+ async def opened() -> AsyncGenerator[Capture]:
320
+ async with self.storage.open_write(uri, content_type=content_type) as sink:
321
+ yield CapturedStream(uri, sink)
322
+
323
+ return opened()
324
+
325
+ def _segment(self) -> str:
326
+ """The path this attempt's captures sit under: the step, the fan-out item, and the attempt.
327
+
328
+ ``{step}[/{item}]/attempt-{n}``. The step key is unique within the run and the item id
329
+ within a fan-out, so no two attempts of one run write over each other's streams.
330
+ """
331
+ item = f"/{self.run_item_id}" if self.run_item_id is not None else ""
332
+ return f"{self.step}{item}/attempt-{self.attempt}"
333
+
300
334
  @property
301
335
  def work(self) -> Path:
302
336
  """Return the run's directory on this worker's own filesystem, made on first read.
@@ -1,9 +1,10 @@
1
1
  """The engine proper: claim, call one block, record the outcome, ready the dependents.
2
2
 
3
3
  Two invariants carry everything here. One transaction per state transition, so there is no
4
- window in which a step is finished but its dependents have not been told. And a remote
5
- handle is committed on its own the moment ``execute`` returns it, so submit-then-crash
6
- recovers into polling rather than into a duplicate job.
4
+ window in which a step is finished but its dependents have not been told. And what a remote
5
+ call hands back is committed on its own the moment it arrives -- the handle ``execute``
6
+ returns, and the result ``fetch`` returns -- so a crash recovers into polling or settling
7
+ rather than into a duplicate job or a second fetch.
7
8
  """
8
9
 
9
10
  import asyncio
@@ -97,6 +98,8 @@ class Produced(BaseModel):
97
98
  model_config = ConfigDict(frozen=True)
98
99
 
99
100
  output: JsonMap
101
+ fetched: bool = False
102
+ """True when a fetch returned this output and nothing has committed it yet."""
100
103
 
101
104
 
102
105
  class Submitted(BaseModel):
@@ -252,6 +255,7 @@ class Engine:
252
255
  item_outputs=item_outputs,
253
256
  paired=frozenset(family),
254
257
  scratch=scratch,
258
+ artifacts=self.services.storage.artifact_root,
255
259
  run_id=run.id,
256
260
  window_start=run.window_start,
257
261
  window_end=run.window_end,
@@ -320,6 +324,7 @@ class Engine:
320
324
  schemas=await load_schemas(session),
321
325
  traceparent=run.traceparent,
322
326
  remote_handle=RemoteHandle.model_validate(attempt.remote_handle) if attempt.remote_handle else None,
327
+ fetched_output=attempt.fetched_output,
323
328
  poke_cursor=attempt.poke_cursor,
324
329
  deadline_at=attempt.deadline_at,
325
330
  gone_probes=attempt.gone_probes,
@@ -376,6 +381,8 @@ class Engine:
376
381
  telemetry.record_failure(span, result.failure.message)
377
382
  if isinstance(result, Submitted):
378
383
  await self.record_handle(unit, result.handle)
384
+ if isinstance(result, Produced) and result.fetched:
385
+ await self._record_fetched(unit, result.output)
379
386
  await self._record(
380
387
  unit,
381
388
  result,
@@ -440,6 +447,8 @@ class Engine:
440
447
  unit: ClaimedUnit,
441
448
  ) -> CallResult:
442
449
  """Drive an operator's half of the contract: execute once, then probe and fetch."""
450
+ if unit.fetched_output is not None:
451
+ return Produced(output=unit.fetched_output)
443
452
  if not unit.is_probe or unit.remote_handle is None:
444
453
  with self._timed(unit.block_id, "execute"):
445
454
  produced = await operator.execute(config, context)
@@ -460,8 +469,8 @@ class Engine:
460
469
  )
461
470
  case ProbeStatus.SUCCEEDED:
462
471
  with self._timed(unit.block_id, "fetch"):
463
- fetched = await operator.fetch(handle, config, context)
464
- return Produced(output=fetched.model_dump(mode="json"))
472
+ retrieved = await operator.fetch(handle, config, context)
473
+ return Produced(output=retrieved.model_dump(mode="json"), fetched=True)
465
474
  case ProbeStatus.FAILED:
466
475
  failed = (
467
476
  Failure.unknown(REMOTE_JOB_SAID, detail=probe.message)
@@ -532,6 +541,24 @@ class Engine:
532
541
  return
533
542
  attempt.remote_handle = handle.model_dump(mode="json")
534
543
 
544
+ async def _record_fetched(self, unit: ClaimedUnit, output: JsonMap) -> None:
545
+ """Commit a fetched result on its own, before the outcome that settles it."""
546
+ await with_deadlock_retry(lambda: self._record_fetched_once(unit, output))
547
+
548
+ async def _record_fetched_once(self, unit: ClaimedUnit, output: JsonMap) -> None:
549
+ """Run that transaction once; the caller runs it again after a deadlock.
550
+
551
+ It locks the run and then writes the attempt, the order every outcome uses and the
552
+ opposite of the claim's. Nothing of it survives a rollback, so it is safe to repeat.
553
+ """
554
+ async with session_scope(self.sessions) as session:
555
+ await lock_run(session, unit.run_id)
556
+ attempt = await session.get(StepAttempt, unit.attempt_id)
557
+ if attempt is None or not self._holds_lease(unit, attempt):
558
+ self._log_lost_lease(unit, attempt, "fetched result")
559
+ return
560
+ attempt.fetched_output = output
561
+
535
562
  async def _cancel_what_was_just_submitted(
536
563
  self,
537
564
  session: AsyncSession,
@@ -746,6 +773,7 @@ class Engine:
746
773
  )
747
774
  attempt.status = AttemptStatus.SUCCEEDED
748
775
  attempt.finished_at = now
776
+ attempt.fetched_output = None
749
777
  attempt.error = None
750
778
  attempt.error_code = None
751
779
  attempt.error_params = None
@@ -1,8 +1,12 @@
1
1
  """``${...}`` reference resolution: the whole reference language, and nothing more.
2
2
 
3
- There are no expressions, loops, or conditionals, and four namespaces: ``params.*``,
4
- ``steps.*``, ``item``, and ``run.scratch`` / ``run.id`` / ``run.window.start`` /
5
- ``run.window.end``.
3
+ There are no expressions, loops, or conditionals, and five namespaces: ``params.*``,
4
+ ``steps.*``, ``item``, ``run.scratch`` / ``run.id`` / ``run.window.start`` /
5
+ ``run.window.end``, and ``artifacts``.
6
+
7
+ ``run.scratch`` is the run's own prefix, which retention sweeps with the run; ``artifacts`` is
8
+ the instance's storage root, which nothing sweeps, so ``${artifacts}/kept/x.json`` is how a
9
+ document names an object meant to outlive the run that wrote it.
6
10
 
7
11
  ``steps`` has three forms: ``steps.<name>.output.*`` is a step's stored output,
8
12
  ``steps.<name>.items`` is the list a fan-out maps over and only ``for_each`` reads it, and
@@ -29,6 +33,7 @@ from dirigent_common import JsonMap, Message
29
33
  from dirigent_core.errors import DomainError
30
34
  from dirigent_core.messages import (
31
35
  ITEM_FAILED,
36
+ MALFORMED_ARTIFACTS,
32
37
  MALFORMED_RUN,
33
38
  MALFORMED_STEP,
34
39
  NAMES_NOTHING,
@@ -93,6 +98,9 @@ class ReferenceScope(BaseModel):
93
98
  """The steps whose grid this one shares, and whose matching item it may therefore read."""
94
99
 
95
100
  scratch: str = ""
101
+ artifacts: str = ""
102
+ """The instance's storage root, which outlives every run written under it."""
103
+
96
104
  run_id: UUID | None = None
97
105
 
98
106
  window_start: datetime | None = None
@@ -287,6 +295,10 @@ def lookup(reference: str, scope: ReferenceScope) -> JsonValue:
287
295
  return _step_output(reference, parts, scope)
288
296
  case "run":
289
297
  return _run_value(reference, parts, scope)
298
+ case "artifacts":
299
+ if parts[1:]:
300
+ raise UnknownReference(MALFORMED_ARTIFACTS, reference=reference)
301
+ return scope.artifacts
290
302
  case unknown:
291
303
  raise UnknownReference(UNKNOWN_NAMESPACE, reference=reference, namespace=repr(unknown))
292
304
 
@@ -247,6 +247,7 @@ async def create_run(
247
247
  scope = ReferenceScope(
248
248
  params=resolved_params,
249
249
  scratch=scratch_prefix(services.settings.artifact_root, run_id),
250
+ artifacts=services.storage.artifact_root,
250
251
  run_id=run_id,
251
252
  window_start=window.start if window is not None else None,
252
253
  window_end=window.end if window is not None else None,
@@ -21,9 +21,6 @@ if TYPE_CHECKING:
21
21
 
22
22
  PACKAGE_LOGGER = "dirigent"
23
23
 
24
- #: Where a container names the spelling once, for a process and for a command alike.
25
- LOG_FORMAT_ENV = "DIRIGENT_LOG_FORMAT"
26
-
27
24
  #: Third-party loggers that ship their own handlers and must be pointed at ours instead.
28
25
  BRIDGED_LOGGERS = (
29
26
  "uvicorn",
@@ -64,13 +64,24 @@ UNKNOWN_SCHEME = ARTIFACTS.define(
64
64
  "no storage backend registered for scheme {scheme}; registered schemes: {registered}",
65
65
  )
66
66
 
67
- OUTSIDE_ROOT = ARTIFACTS.define("outside_root", "{uri} resolves outside the artifact root {root}")
67
+ OUTSIDE_ROOT = ARTIFACTS.define(
68
+ "outside_root",
69
+ "{uri} resolves outside the artifact root {root}; write ${{artifacts}}/<path> for an object "
70
+ "kept under this instance's root, or ${{run.scratch}}/<path> for one swept with the run",
71
+ )
68
72
 
69
73
  UNKNOWN_STORAGE_CONNECTION = ARTIFACTS.define(
70
74
  "unknown_connection",
71
75
  "no connection coded {ref} to configure the {scheme} scheme from ({available})",
72
76
  )
73
77
 
78
+ OBJECT_MISSING = ARTIFACTS.define(
79
+ "object_missing",
80
+ "there is nothing at {uri}: run {run} (attempt {attempt}) has an artifact row and storage "
81
+ "has no object; restore the artifact root from the backup that matches this database, "
82
+ "or prune the run",
83
+ )
84
+
74
85
 
75
86
  HOST = Catalogue("host")
76
87
 
@@ -317,9 +328,15 @@ REFERENCE_MALFORMED_RUN = DOCUMENT.define(
317
328
  "${{{reference}}} is malformed: run exposes only run.scratch, run.id, run.window.start and run.window.end",
318
329
  )
319
330
 
331
+ REFERENCE_MALFORMED_ARTIFACTS = DOCUMENT.define(
332
+ "reference_malformed_artifacts",
333
+ "${{{reference}}} is malformed: artifacts is the storage root itself and has no fields; "
334
+ "write ${{artifacts}}/<path>",
335
+ )
336
+
320
337
  REFERENCE_UNKNOWN_NAMESPACE = DOCUMENT.define(
321
338
  "reference_unknown_namespace",
322
- "${{{reference}}} names {namespace}, which is not one of params, steps, item, run",
339
+ "${{{reference}}} names {namespace}, which is not one of params, steps, item, run, artifacts",
323
340
  )
324
341
 
325
342
  FOR_EACH_READS_OUTPUT = DOCUMENT.define(
@@ -509,7 +526,7 @@ NO_ITEM = _unresolved("no_item", "this step does not fan out, so there is no ite
509
526
 
510
527
  UNKNOWN_NAMESPACE = _unresolved(
511
528
  "unknown_namespace",
512
- "{namespace} is not a namespace; the reference language has params, steps, item, and run",
529
+ "{namespace} is not a namespace; the reference language has params, steps, item, run, and artifacts",
513
530
  )
514
531
 
515
532
  NO_OUTPUT = _unresolved("no_output", "step {step} has no stored output ({available})")
@@ -536,6 +553,11 @@ MALFORMED_RUN = _unresolved(
536
553
  "run exposes only run.scratch, run.id, run.window.start and run.window.end",
537
554
  )
538
555
 
556
+ MALFORMED_ARTIFACTS = _unresolved(
557
+ "malformed_artifacts",
558
+ "artifacts is the storage root itself and has no fields; write ${{artifacts}}/<path>",
559
+ )
560
+
539
561
  NO_WINDOW = _unresolved(
540
562
  "no_window",
541
563
  "this run carries no window; a schedule-fired or backfilled run has one, "
@@ -288,6 +288,10 @@ class StepAttempt(Entity):
288
288
 
289
289
  remote_handle: Mapped[JsonMap | None] = mapped_column(JsonDocument)
290
290
 
291
+ fetched_output: Mapped[JsonMap | None] = mapped_column(JsonDocument)
292
+ """The result a fetch returned, committed before the outcome so a worker that dies
293
+ between the two settles from it rather than fetching again."""
294
+
291
295
  poke_cursor: Mapped[JsonMap | None] = mapped_column(JsonDocument)
292
296
  """How far a sensor's poke has read, handed to the next poke as ``ctx.cursor``."""
293
297
 
File without changes
File without changes