groundskeeping 0.1.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.
- groundskeeping/__init__.py +17 -0
- groundskeeping/app.py +233 -0
- groundskeeping/configurator/__init__.py +18 -0
- groundskeeping/configurator/adapter.py +227 -0
- groundskeeping/configurator/models.py +110 -0
- groundskeeping/contracts/__init__.py +57 -0
- groundskeeping/contracts/actions.py +360 -0
- groundskeeping/contracts/jobs.py +245 -0
- groundskeeping/contracts/pages.py +112 -0
- groundskeeping/contracts/views.py +111 -0
- groundskeeping/demo.py +255 -0
- groundskeeping/navigation.py +21 -0
- groundskeeping/telemetry/__init__.py +10 -0
- groundskeeping/telemetry/contracts.py +17 -0
- groundskeeping/telemetry/models.py +55 -0
- groundskeeping/telemetry/providers/__init__.py +3 -0
- groundskeeping/telemetry/providers/fake.py +80 -0
- groundskeeping/telemetry/runtime.py +72 -0
- groundskeeping/theme.py +67 -0
- groundskeeping/themes/groundskeeping.tcss +148 -0
- groundskeeping/widgets/__init__.py +4 -0
- groundskeeping/widgets/configurator.py +39 -0
- groundskeeping/widgets/primitives.py +50 -0
- groundskeeping/widgets/telemetry.py +26 -0
- groundskeeping/widgets/workbench.py +236 -0
- groundskeeping-0.1.0.dist-info/METADATA +133 -0
- groundskeeping-0.1.0.dist-info/RECORD +29 -0
- groundskeeping-0.1.0.dist-info/WHEEL +4 -0
- groundskeeping-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Generic action, field, and result contracts.
|
|
2
|
+
|
|
3
|
+
Consumers own the verbs and the effects. Groundskeeping owns the repeatable operator
|
|
4
|
+
sequence around those verbs: describe fields, parse input, ask policy whether to proceed,
|
|
5
|
+
run with progress/cancellation context, and render a generic outcome.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from decimal import Decimal, InvalidOperation
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from groundskeeping.contracts.jobs import (
|
|
18
|
+
CancellationMode,
|
|
19
|
+
CancellationToken,
|
|
20
|
+
ProgressEvent,
|
|
21
|
+
ProgressSink,
|
|
22
|
+
RecordingProgressSink,
|
|
23
|
+
)
|
|
24
|
+
from groundskeeping.contracts.views import EmptyView, SemanticStatus, SurfaceView
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FieldKind(StrEnum):
|
|
28
|
+
TEXT = "text"
|
|
29
|
+
INTEGER = "integer"
|
|
30
|
+
DECIMAL = "decimal"
|
|
31
|
+
BOOLEAN = "boolean"
|
|
32
|
+
CHOICE = "choice"
|
|
33
|
+
EXISTING_PATH = "existing_path"
|
|
34
|
+
OUTPUT_PATH = "output_path"
|
|
35
|
+
SECRET = "secret"
|
|
36
|
+
MULTILINE = "multiline"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExecutionKind(StrEnum):
|
|
40
|
+
QUICK = "quick"
|
|
41
|
+
BACKGROUND = "background"
|
|
42
|
+
LONG_RUNNING = "long_running"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ChoiceOption:
|
|
47
|
+
value: str
|
|
48
|
+
label: str
|
|
49
|
+
description: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ValidationIssue:
|
|
54
|
+
message: str
|
|
55
|
+
field_key: str | None = None
|
|
56
|
+
status: SemanticStatus = SemanticStatus.ERROR
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ParsedField:
|
|
61
|
+
key: str
|
|
62
|
+
value: object
|
|
63
|
+
redacted: object
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Validator = Callable[[object], ValidationIssue | None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class FieldSpec:
|
|
71
|
+
"""Declarative input field used by forms and headless tests."""
|
|
72
|
+
|
|
73
|
+
key: str
|
|
74
|
+
label: str
|
|
75
|
+
kind: FieldKind = FieldKind.TEXT
|
|
76
|
+
required: bool = True
|
|
77
|
+
default: object | None = None
|
|
78
|
+
help: str | None = None
|
|
79
|
+
choices: tuple[ChoiceOption, ...] = ()
|
|
80
|
+
minimum: Decimal | int | None = None
|
|
81
|
+
maximum: Decimal | int | None = None
|
|
82
|
+
sensitive: bool = False
|
|
83
|
+
validator: Validator | None = field(default=None, compare=False, repr=False)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def masks_value(self) -> bool:
|
|
87
|
+
return self.sensitive or self.kind is FieldKind.SECRET
|
|
88
|
+
|
|
89
|
+
def parse(self, raw: object) -> ParsedField:
|
|
90
|
+
"""Parse one field value and return both real and presentation-safe values."""
|
|
91
|
+
value = self.default if raw in (None, "") else raw
|
|
92
|
+
if value in (None, ""):
|
|
93
|
+
if self.required:
|
|
94
|
+
raise ValueError(f"{self.label} is required.")
|
|
95
|
+
return ParsedField(self.key, None, "<redacted>" if self.masks_value else None)
|
|
96
|
+
|
|
97
|
+
parsed = self._parse_value(value)
|
|
98
|
+
issue = self.validator(parsed) if self.validator is not None else None
|
|
99
|
+
if issue is not None:
|
|
100
|
+
raise ValueError(issue.message)
|
|
101
|
+
return ParsedField(
|
|
102
|
+
key=self.key,
|
|
103
|
+
value=parsed,
|
|
104
|
+
redacted="<redacted>" if self.masks_value and parsed is not None else parsed,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _parse_value(self, value: object) -> object:
|
|
108
|
+
if self.kind in {FieldKind.TEXT, FieldKind.SECRET, FieldKind.MULTILINE}:
|
|
109
|
+
return str(value)
|
|
110
|
+
if self.kind is FieldKind.INTEGER:
|
|
111
|
+
if not isinstance(value, str | int | float | Decimal):
|
|
112
|
+
raise ValueError(f"{self.label} must be a whole number.")
|
|
113
|
+
try:
|
|
114
|
+
parsed = int(value)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
raise ValueError(f"{self.label} must be a whole number.") from exc
|
|
117
|
+
self._check_bounds(parsed)
|
|
118
|
+
return parsed
|
|
119
|
+
if self.kind is FieldKind.DECIMAL:
|
|
120
|
+
try:
|
|
121
|
+
parsed = Decimal(str(value))
|
|
122
|
+
except InvalidOperation as exc:
|
|
123
|
+
raise ValueError(f"{self.label} must be a decimal number.") from exc
|
|
124
|
+
self._check_bounds(parsed)
|
|
125
|
+
return parsed
|
|
126
|
+
if self.kind is FieldKind.BOOLEAN:
|
|
127
|
+
if isinstance(value, bool):
|
|
128
|
+
return value
|
|
129
|
+
normalized = str(value).strip().lower()
|
|
130
|
+
if normalized in {"1", "true", "t", "yes", "y", "on"}:
|
|
131
|
+
return True
|
|
132
|
+
if normalized in {"0", "false", "f", "no", "n", "off"}:
|
|
133
|
+
return False
|
|
134
|
+
raise ValueError(f"{self.label} must be true or false.")
|
|
135
|
+
if self.kind is FieldKind.CHOICE:
|
|
136
|
+
parsed = str(value)
|
|
137
|
+
allowed = {choice.value for choice in self.choices}
|
|
138
|
+
if allowed and parsed not in allowed:
|
|
139
|
+
raise ValueError(f"{self.label} must be one of: {', '.join(sorted(allowed))}.")
|
|
140
|
+
return parsed
|
|
141
|
+
if self.kind is FieldKind.EXISTING_PATH:
|
|
142
|
+
path = Path(str(value)).expanduser()
|
|
143
|
+
if not path.exists():
|
|
144
|
+
raise ValueError(f"{self.label} does not exist: {path}")
|
|
145
|
+
return path
|
|
146
|
+
if self.kind is FieldKind.OUTPUT_PATH:
|
|
147
|
+
return Path(str(value)).expanduser()
|
|
148
|
+
raise ValueError(f"Unsupported field kind: {self.kind}")
|
|
149
|
+
|
|
150
|
+
def _check_bounds(self, value: Decimal | int) -> None:
|
|
151
|
+
if self.minimum is not None and value < self.minimum:
|
|
152
|
+
raise ValueError(f"{self.label} must be at least {self.minimum}.")
|
|
153
|
+
if self.maximum is not None and value > self.maximum:
|
|
154
|
+
raise ValueError(f"{self.label} must be at most {self.maximum}.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Confirmation:
|
|
159
|
+
title: str
|
|
160
|
+
message: str
|
|
161
|
+
confirm_label: str = "Run"
|
|
162
|
+
dangerous: bool = False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class ActionOutcome:
|
|
167
|
+
status: SemanticStatus
|
|
168
|
+
summary: str
|
|
169
|
+
view: SurfaceView
|
|
170
|
+
detail: object | None = None
|
|
171
|
+
refresh_pages: frozenset[str] = frozenset()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class ActionContext:
|
|
176
|
+
"""Everything a runner may touch while the action is in flight.
|
|
177
|
+
|
|
178
|
+
Deliberately narrow: a runner reports progress and checks for cancellation through this
|
|
179
|
+
object, so long-running domain work never needs to import a widget or reach the app.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
progress: ProgressSink
|
|
183
|
+
cancellation: CancellationToken
|
|
184
|
+
action_id: str
|
|
185
|
+
|
|
186
|
+
def emit(
|
|
187
|
+
self,
|
|
188
|
+
event: str,
|
|
189
|
+
*,
|
|
190
|
+
phase: str | None = None,
|
|
191
|
+
completed: int = 0,
|
|
192
|
+
total: int | None = None,
|
|
193
|
+
unit: str | None = None,
|
|
194
|
+
message: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.progress.emit(
|
|
197
|
+
ProgressEvent(
|
|
198
|
+
event=event,
|
|
199
|
+
phase=phase,
|
|
200
|
+
completed=completed,
|
|
201
|
+
total=total,
|
|
202
|
+
unit=unit,
|
|
203
|
+
message=message,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ActionRunner(Protocol):
|
|
209
|
+
def __call__(self, params: Mapping[str, object], context: ActionContext) -> object: ...
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
Preflight = Callable[[Mapping[str, object]], Sequence[ValidationIssue]]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class ActionSpec:
|
|
217
|
+
"""Declaration of one operator-facing command and the runner that performs it.
|
|
218
|
+
|
|
219
|
+
The spec carries everything the shell needs to present, gate, and execute the command
|
|
220
|
+
without knowing what it does: fields to parse, resources and effects for the operation
|
|
221
|
+
policy to reason about, and the cancellation mode the runner honours.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
key: str
|
|
225
|
+
page_key: str
|
|
226
|
+
label: str
|
|
227
|
+
summary: str
|
|
228
|
+
runner: ActionRunner
|
|
229
|
+
command_hint: str | None = None
|
|
230
|
+
fields: tuple[FieldSpec, ...] = ()
|
|
231
|
+
execution: ExecutionKind = ExecutionKind.QUICK
|
|
232
|
+
cancellation: CancellationMode = CancellationMode.UNSUPPORTED
|
|
233
|
+
effect_refs: frozenset[str] = frozenset()
|
|
234
|
+
resource_refs: frozenset[str] = frozenset()
|
|
235
|
+
preflight: Preflight | None = field(default=None, compare=False, repr=False)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def needs_params(self) -> bool:
|
|
239
|
+
return bool(self.fields)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def long_running(self) -> bool:
|
|
243
|
+
return self.execution is ExecutionKind.LONG_RUNNING
|
|
244
|
+
|
|
245
|
+
def parse_params(self, raw: Mapping[str, object] | None = None) -> tuple[dict[str, object], dict[str, object]]:
|
|
246
|
+
raw = raw or {}
|
|
247
|
+
params: dict[str, object] = {}
|
|
248
|
+
redacted: dict[str, object] = {}
|
|
249
|
+
for spec in self.fields:
|
|
250
|
+
parsed = spec.parse(raw.get(spec.key))
|
|
251
|
+
params[spec.key] = parsed.value
|
|
252
|
+
redacted[spec.key] = parsed.redacted
|
|
253
|
+
return params, redacted
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class ResultPresenter(Protocol):
|
|
257
|
+
def present(self, action: ActionSpec, result: object) -> ActionOutcome: ...
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DefaultResultPresenter:
|
|
261
|
+
"""Present plain runner results without imposing a consumer report schema."""
|
|
262
|
+
|
|
263
|
+
def present(self, action: ActionSpec, result: object) -> ActionOutcome:
|
|
264
|
+
if isinstance(result, ActionOutcome):
|
|
265
|
+
return result
|
|
266
|
+
return ActionOutcome(
|
|
267
|
+
status=SemanticStatus.OK,
|
|
268
|
+
summary=f"{action.label} completed.",
|
|
269
|
+
view=EmptyView(title=action.label, message=str(result) if result is not None else "Completed."),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class OperationPolicy(Protocol):
|
|
274
|
+
def describe_effects(self, action: ActionSpec, params: Mapping[str, object]) -> str: ...
|
|
275
|
+
|
|
276
|
+
def confirmation(self, action: ActionSpec, params: Mapping[str, object]) -> Confirmation | None: ...
|
|
277
|
+
|
|
278
|
+
def blocked_reason(
|
|
279
|
+
self,
|
|
280
|
+
action: ActionSpec,
|
|
281
|
+
params: Mapping[str, object],
|
|
282
|
+
active_jobs: Sequence[object],
|
|
283
|
+
) -> str | None: ...
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class AllowAllOperationPolicy:
|
|
287
|
+
"""Default policy for demos and tests; production consumers should be explicit."""
|
|
288
|
+
|
|
289
|
+
def describe_effects(self, action: ActionSpec, params: Mapping[str, object]) -> str:
|
|
290
|
+
if not action.effect_refs:
|
|
291
|
+
return "read-only"
|
|
292
|
+
return ", ".join(sorted(action.effect_refs))
|
|
293
|
+
|
|
294
|
+
def confirmation(self, action: ActionSpec, params: Mapping[str, object]) -> Confirmation | None:
|
|
295
|
+
if not action.effect_refs:
|
|
296
|
+
return None
|
|
297
|
+
return Confirmation(
|
|
298
|
+
title=action.label,
|
|
299
|
+
message=f"Effects: {self.describe_effects(action, params)}.",
|
|
300
|
+
dangerous=False,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def blocked_reason(
|
|
304
|
+
self,
|
|
305
|
+
action: ActionSpec,
|
|
306
|
+
params: Mapping[str, object],
|
|
307
|
+
active_jobs: Sequence[object],
|
|
308
|
+
) -> str | None:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ActionRegistry:
|
|
313
|
+
"""Exact-key lookup for actions with startup validation."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, actions: Sequence[ActionSpec], *, page_keys: Sequence[str] | None = None) -> None:
|
|
316
|
+
self._actions = tuple(actions)
|
|
317
|
+
self._by_key = {action.key: action for action in self._actions}
|
|
318
|
+
if len(self._actions) != len(self._by_key):
|
|
319
|
+
raise ValueError("Action keys must be unique.")
|
|
320
|
+
if page_keys is not None:
|
|
321
|
+
allowed = set(page_keys)
|
|
322
|
+
missing = sorted({action.page_key for action in self._actions} - allowed)
|
|
323
|
+
if missing:
|
|
324
|
+
raise ValueError(f"Actions reference unknown pages: {', '.join(missing)}")
|
|
325
|
+
|
|
326
|
+
def get(self, key: str) -> ActionSpec:
|
|
327
|
+
try:
|
|
328
|
+
return self._by_key[key]
|
|
329
|
+
except KeyError as exc:
|
|
330
|
+
raise KeyError(f"Unknown action: {key}") from exc
|
|
331
|
+
|
|
332
|
+
def for_page(self, page_key: str) -> tuple[ActionSpec, ...]:
|
|
333
|
+
return tuple(action for action in self._actions if action.page_key == page_key)
|
|
334
|
+
|
|
335
|
+
def __iter__(self) -> Iterator[ActionSpec]:
|
|
336
|
+
return iter(self._actions)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def run_action_sync(
|
|
340
|
+
action: ActionSpec,
|
|
341
|
+
raw_params: Mapping[str, object] | None,
|
|
342
|
+
cancellation: CancellationToken,
|
|
343
|
+
*,
|
|
344
|
+
presenter: ResultPresenter | None = None,
|
|
345
|
+
action_id: str | None = None,
|
|
346
|
+
) -> ActionOutcome:
|
|
347
|
+
"""Run an action synchronously for tests, demos, and simple quick actions."""
|
|
348
|
+
params, _redacted = action.parse_params(raw_params)
|
|
349
|
+
if action.preflight is not None:
|
|
350
|
+
issues = tuple(action.preflight(params))
|
|
351
|
+
errors = [issue.message for issue in issues if issue.status is SemanticStatus.ERROR]
|
|
352
|
+
if errors:
|
|
353
|
+
raise ValueError("; ".join(errors))
|
|
354
|
+
context = ActionContext(
|
|
355
|
+
progress=RecordingProgressSink(),
|
|
356
|
+
cancellation=cancellation,
|
|
357
|
+
action_id=action_id or action.key,
|
|
358
|
+
)
|
|
359
|
+
result = action.runner(params, context)
|
|
360
|
+
return (presenter or DefaultResultPresenter()).present(action, result)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Portable progress, cancellation, and shell-job contracts.
|
|
2
|
+
|
|
3
|
+
Shell jobs are deliberately smaller than consumer domain queues. A job here is work that
|
|
4
|
+
this TUI process started and can present, gate, or cancel. Durable processing queues,
|
|
5
|
+
leases, retries, and run records stay with the consuming application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from threading import Event
|
|
15
|
+
from time import monotonic
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CancellationRequested(RuntimeError):
|
|
21
|
+
"""Raised by cooperative runners when the operator has requested cancellation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CancellationMode(StrEnum):
|
|
25
|
+
"""How the shell should present and route cancellation for an action."""
|
|
26
|
+
|
|
27
|
+
UNSUPPORTED = "unsupported"
|
|
28
|
+
COOPERATIVE = "cooperative"
|
|
29
|
+
IMMEDIATE = "immediate"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CancellationToken(Protocol):
|
|
33
|
+
"""Structural cancellation contract passed into consumer runners."""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def requested(self) -> bool: ...
|
|
37
|
+
|
|
38
|
+
def raise_if_requested(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ThreadCancellationToken:
|
|
42
|
+
"""Thread-safe default cancellation token for ordinary shell workers."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._event = Event()
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def requested(self) -> bool:
|
|
49
|
+
return self._event.is_set()
|
|
50
|
+
|
|
51
|
+
def request(self) -> None:
|
|
52
|
+
self._event.set()
|
|
53
|
+
|
|
54
|
+
def raise_if_requested(self) -> None:
|
|
55
|
+
if self.requested:
|
|
56
|
+
raise CancellationRequested("Operation cancelled by the operator.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ProgressEvent:
|
|
61
|
+
"""One portable progress update emitted by a runner."""
|
|
62
|
+
|
|
63
|
+
event: str
|
|
64
|
+
phase: str | None = None
|
|
65
|
+
completed: int = 0
|
|
66
|
+
total: int | None = None
|
|
67
|
+
unit: str | None = None
|
|
68
|
+
message: str | None = None
|
|
69
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def fraction(self) -> float | None:
|
|
73
|
+
if self.total in (None, 0):
|
|
74
|
+
return None
|
|
75
|
+
return max(0.0, min(1.0, self.completed / self.total))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProgressSink(Protocol):
|
|
79
|
+
"""Consumer runners call this; widgets never need to know the runner type."""
|
|
80
|
+
|
|
81
|
+
def emit(self, event: ProgressEvent) -> None: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RecordingProgressSink:
|
|
85
|
+
"""Small sink used by tests, demos, and synchronous action execution."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.events: list[ProgressEvent] = []
|
|
89
|
+
|
|
90
|
+
def emit(self, event: ProgressEvent) -> None:
|
|
91
|
+
self.events.append(event)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class JobSpec:
|
|
96
|
+
"""What the shell needs to decide whether work may start."""
|
|
97
|
+
|
|
98
|
+
key: str
|
|
99
|
+
label: str
|
|
100
|
+
resources: frozenset[str] = frozenset()
|
|
101
|
+
effects: frozenset[str] = frozenset()
|
|
102
|
+
cancellation: CancellationMode = CancellationMode.UNSUPPORTED
|
|
103
|
+
foreground: bool = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class JobSnapshot:
|
|
108
|
+
"""Immutable view of a shell job for policy, bars, and tests."""
|
|
109
|
+
|
|
110
|
+
job_id: str
|
|
111
|
+
spec: JobSpec
|
|
112
|
+
started_at: float
|
|
113
|
+
progress: ProgressEvent | None = None
|
|
114
|
+
cancelling: bool = False
|
|
115
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def fraction(self) -> float | None:
|
|
119
|
+
return None if self.progress is None else self.progress.fraction
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def cancellable(self) -> bool:
|
|
123
|
+
return self.spec.cancellation is not CancellationMode.UNSUPPORTED
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def elapsed(self) -> float:
|
|
127
|
+
return max(0.0, monotonic() - self.started_at)
|
|
128
|
+
|
|
129
|
+
def describe(self) -> str:
|
|
130
|
+
if self.progress and self.progress.message:
|
|
131
|
+
return f"{self.spec.label}: {self.progress.message}"
|
|
132
|
+
if self.progress and self.progress.phase:
|
|
133
|
+
return f"{self.spec.label}: {self.progress.phase}"
|
|
134
|
+
return self.spec.label
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class BlockDecision:
|
|
139
|
+
"""Policy answer for whether a candidate job can start."""
|
|
140
|
+
|
|
141
|
+
allowed: bool
|
|
142
|
+
reason: str | None = None
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def allow(cls) -> BlockDecision:
|
|
146
|
+
return cls(True)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def block(cls, reason: str) -> BlockDecision:
|
|
150
|
+
return cls(False, reason)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class JobPolicy(Protocol):
|
|
154
|
+
"""In-process gate for shell jobs."""
|
|
155
|
+
|
|
156
|
+
def can_start(self, candidate: JobSpec, active: Sequence[JobSnapshot]) -> BlockDecision: ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SingleForegroundJobPolicy:
|
|
160
|
+
"""Default policy: one foreground job, plus no overlapping explicit resources."""
|
|
161
|
+
|
|
162
|
+
def can_start(self, candidate: JobSpec, active: Sequence[JobSnapshot]) -> BlockDecision:
|
|
163
|
+
if candidate.foreground and any(job.spec.foreground for job in active):
|
|
164
|
+
return BlockDecision.block("another foreground job is already running")
|
|
165
|
+
for job in active:
|
|
166
|
+
overlap = candidate.resources & job.spec.resources
|
|
167
|
+
if overlap:
|
|
168
|
+
resources = ", ".join(sorted(overlap))
|
|
169
|
+
return BlockDecision.block(f"resource already in use: {resources}")
|
|
170
|
+
return BlockDecision.allow()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class JobManager:
|
|
174
|
+
"""Multi-job-capable manager with a conservative default policy.
|
|
175
|
+
|
|
176
|
+
The manager does not execute work itself. Textual workers, threads, or synchronous test
|
|
177
|
+
harnesses own execution and report lifecycle changes back here.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, policy: JobPolicy | None = None) -> None:
|
|
181
|
+
self.policy = policy or SingleForegroundJobPolicy()
|
|
182
|
+
self._active: dict[str, tuple[JobSnapshot, ThreadCancellationToken]] = {}
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def active(self) -> tuple[JobSnapshot, ...]:
|
|
186
|
+
return tuple(snapshot for snapshot, _token in self._active.values())
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def foreground(self) -> JobSnapshot | None:
|
|
190
|
+
return next((job for job in self.active if job.spec.foreground), None)
|
|
191
|
+
|
|
192
|
+
def can_start(self, spec: JobSpec) -> BlockDecision:
|
|
193
|
+
return self.policy.can_start(spec, self.active)
|
|
194
|
+
|
|
195
|
+
def start(
|
|
196
|
+
self,
|
|
197
|
+
spec: JobSpec,
|
|
198
|
+
*,
|
|
199
|
+
job_id: str | None = None,
|
|
200
|
+
metadata: Mapping[str, object] | None = None,
|
|
201
|
+
) -> tuple[JobSnapshot, ThreadCancellationToken]:
|
|
202
|
+
decision = self.can_start(spec)
|
|
203
|
+
if not decision.allowed:
|
|
204
|
+
raise RuntimeError(decision.reason or "Job cannot start.")
|
|
205
|
+
resolved_id = job_id or f"{spec.key}:{uuid4().hex}"
|
|
206
|
+
token = ThreadCancellationToken()
|
|
207
|
+
snapshot = JobSnapshot(
|
|
208
|
+
job_id=resolved_id,
|
|
209
|
+
spec=spec,
|
|
210
|
+
started_at=monotonic(),
|
|
211
|
+
metadata=dict(metadata or {}),
|
|
212
|
+
)
|
|
213
|
+
self._active[resolved_id] = (snapshot, token)
|
|
214
|
+
return snapshot, token
|
|
215
|
+
|
|
216
|
+
def update(self, job_id: str, progress: ProgressEvent) -> JobSnapshot:
|
|
217
|
+
snapshot, token = self._active[job_id]
|
|
218
|
+
updated = JobSnapshot(
|
|
219
|
+
job_id=snapshot.job_id,
|
|
220
|
+
spec=snapshot.spec,
|
|
221
|
+
started_at=snapshot.started_at,
|
|
222
|
+
progress=progress,
|
|
223
|
+
cancelling=snapshot.cancelling,
|
|
224
|
+
metadata=snapshot.metadata,
|
|
225
|
+
)
|
|
226
|
+
self._active[job_id] = (updated, token)
|
|
227
|
+
return updated
|
|
228
|
+
|
|
229
|
+
def request_cancel(self, job_id: str) -> JobSnapshot:
|
|
230
|
+
snapshot, token = self._active[job_id]
|
|
231
|
+
token.request()
|
|
232
|
+
updated = JobSnapshot(
|
|
233
|
+
job_id=snapshot.job_id,
|
|
234
|
+
spec=snapshot.spec,
|
|
235
|
+
started_at=snapshot.started_at,
|
|
236
|
+
progress=snapshot.progress,
|
|
237
|
+
cancelling=True,
|
|
238
|
+
metadata=snapshot.metadata,
|
|
239
|
+
)
|
|
240
|
+
self._active[job_id] = (updated, token)
|
|
241
|
+
return updated
|
|
242
|
+
|
|
243
|
+
def complete(self, job_id: str) -> JobSnapshot:
|
|
244
|
+
snapshot, _token = self._active.pop(job_id)
|
|
245
|
+
return snapshot
|