taskproof 0.3.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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/registry.py
ADDED
|
@@ -0,0 +1,1585 @@
|
|
|
1
|
+
"""Project + taskgroup registry.
|
|
2
|
+
|
|
3
|
+
Format: TOML, parsed with the standard library (`tomllib`, Python 3.11+).
|
|
4
|
+
Not YAML — YAML would require PyYAML, and this package is standard-library only.
|
|
5
|
+
|
|
6
|
+
The registry is CONFIGURATION: human-editable, meant to be committed to the
|
|
7
|
+
user's own repository. Runtime state lives in SQLite (see storage.py).
|
|
8
|
+
|
|
9
|
+
Two layers
|
|
10
|
+
----------
|
|
11
|
+
* ``[[project]]`` — a repository / filter unit: ``id``, ``path``, ``aliases``.
|
|
12
|
+
Optional on its own: it is the shared identity a lane hangs off.
|
|
13
|
+
* ``[[taskgroup]]`` — one lane ("道") a task is dispatched onto: ``id``,
|
|
14
|
+
``project`` (owning project), ``path`` (defaults to the project path),
|
|
15
|
+
``aliases``, ``group`` (concurrency lock; defaults to the taskgroup id),
|
|
16
|
+
``verify``, ``verify_kind``, ``forbidden_paths``, ``result_schema``.
|
|
17
|
+
|
|
18
|
+
A legacy ``[[project]]`` block that carries any lane field (``verify``,
|
|
19
|
+
``verify_kind``, ``forbidden_paths`` or ``result_schema``) is read as a
|
|
20
|
+
same-named project *plus* a same-named lane, so older files keep working with
|
|
21
|
+
zero migration.
|
|
22
|
+
|
|
23
|
+
Example
|
|
24
|
+
-------
|
|
25
|
+
[defaults]
|
|
26
|
+
concurrency = 3
|
|
27
|
+
timeout = 1800
|
|
28
|
+
|
|
29
|
+
# A repo with a single lane: the lane may omit `path` and `project`.
|
|
30
|
+
[[taskgroup]]
|
|
31
|
+
id = "my-app"
|
|
32
|
+
path = "/home/me/code/my-app"
|
|
33
|
+
aliases = ["app"]
|
|
34
|
+
verify = "npm run build"
|
|
35
|
+
verify_kind = "build"
|
|
36
|
+
forbidden_paths = [".git/", "dist/"]
|
|
37
|
+
result_schema = "default" # "default" | "none" | "/abs/path.json"
|
|
38
|
+
|
|
39
|
+
# A repo with two lanes; the project owns the shared default path.
|
|
40
|
+
[[project]]
|
|
41
|
+
id = "api"
|
|
42
|
+
path = "/home/me/code/api"
|
|
43
|
+
|
|
44
|
+
[[taskgroup]]
|
|
45
|
+
id = "api-main"
|
|
46
|
+
project = "api"
|
|
47
|
+
verify = "make check"
|
|
48
|
+
verify_kind = "check"
|
|
49
|
+
|
|
50
|
+
[[taskgroup]]
|
|
51
|
+
id = "api-docs"
|
|
52
|
+
project = "api"
|
|
53
|
+
path = "/home/me/code/api/site"
|
|
54
|
+
verify = "npm test"
|
|
55
|
+
|
|
56
|
+
Resolution accepts a taskgroup id/alias/path, then a project id/alias/path
|
|
57
|
+
(only when the project has exactly one lane).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import hashlib
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import re
|
|
64
|
+
import tempfile
|
|
65
|
+
import threading
|
|
66
|
+
import tomllib
|
|
67
|
+
from typing import List, NamedTuple, Optional
|
|
68
|
+
|
|
69
|
+
from . import concurrency, verify
|
|
70
|
+
from .errors import RegistryError
|
|
71
|
+
from .models import Project, Taskgroup
|
|
72
|
+
|
|
73
|
+
DEFAULT_REGISTRY_NAME = "projects.toml"
|
|
74
|
+
|
|
75
|
+
#: Package-shipped default structured-result contract (`schemas/result.json`).
|
|
76
|
+
#: Resolved relative to this file so the path is correct whether the package is
|
|
77
|
+
#: run from a source checkout or installed from a wheel.
|
|
78
|
+
BUILTIN_RESULT_SCHEMA = os.path.join(
|
|
79
|
+
os.path.dirname(os.path.abspath(__file__)), "schemas", "result.json"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: `result_schema` values that are settings rather than filesystem paths.
|
|
83
|
+
RESULT_SCHEMA_DEFAULT = "default"
|
|
84
|
+
RESULT_SCHEMA_NONE = "none"
|
|
85
|
+
|
|
86
|
+
#: The only accepted values for a taskgroup's ``workspace`` field. The default
|
|
87
|
+
#: deliberately stays ``"none"``: existing acceptance commands commonly contain
|
|
88
|
+
#: absolute paths to the main checkout, and silently changing their cwd to a
|
|
89
|
+
#: worktree would make those checks pass against the wrong tree.
|
|
90
|
+
VALID_WORKSPACE_MODES = ("none", "worktree")
|
|
91
|
+
|
|
92
|
+
#: Build file -> (candidate acceptance command, verify_kind). Deliberately a
|
|
93
|
+
#: short, opinionated list, not a general-purpose project detector.
|
|
94
|
+
_BUILD_FILES = (
|
|
95
|
+
("package.json", "npm run build", "build"),
|
|
96
|
+
("pyproject.toml", "python -m compileall -q .", "build"),
|
|
97
|
+
("setup.py", "python -m compileall -q .", "build"),
|
|
98
|
+
("pom.xml", "mvn -q -DskipTests compile", "build"),
|
|
99
|
+
("Cargo.toml", "cargo check", "build"),
|
|
100
|
+
("go.mod", "go build ./...", "build"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
#: A probe may compile a real repository. Keep it bounded so neither the CLI
|
|
104
|
+
#: nor the API can hang forever on a broken acceptance command.
|
|
105
|
+
_PROBE_TIMEOUT = 600
|
|
106
|
+
|
|
107
|
+
_WRITE_LOCK = threading.Lock()
|
|
108
|
+
_PROJECT_HEADER_RE = re.compile(
|
|
109
|
+
r"^\s*\[\[\s*project\s*\]\]\s*(?:#.*)?(?:\r?\n)?$"
|
|
110
|
+
)
|
|
111
|
+
_TASKGROUP_HEADER_RE = re.compile(
|
|
112
|
+
r"^\s*\[\[\s*taskgroup\s*\]\]\s*(?:#.*)?(?:\r?\n)?$"
|
|
113
|
+
)
|
|
114
|
+
_TABLE_HEADER_RE = re.compile(r"^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?(?:\r?\n)?$")
|
|
115
|
+
_KEY_RE = re.compile(r"^\s*[A-Za-z0-9_-]+\s*=")
|
|
116
|
+
|
|
117
|
+
#: Any of these on a `[[project]]` block means it is really a lane: it is read
|
|
118
|
+
#: as a project + a same-named taskgroup (zero-migration compatibility).
|
|
119
|
+
_LANE_FIELDS = (
|
|
120
|
+
"verify",
|
|
121
|
+
"verify_kind",
|
|
122
|
+
"forbidden_paths",
|
|
123
|
+
"result_schema",
|
|
124
|
+
"workspace",
|
|
125
|
+
"link",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RegistryConflictError(RegistryError):
|
|
130
|
+
"""The caller's expected hash no longer matches the registry on disk."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, current_hash, content, projects):
|
|
133
|
+
super().__init__("registry changed on disk")
|
|
134
|
+
self.current_hash = current_hash
|
|
135
|
+
self.content = content
|
|
136
|
+
self.projects = projects
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RegistryNotFoundError(RegistryError):
|
|
140
|
+
"""The requested project id is not present in the registry."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def builtin_result_schema_path() -> str:
|
|
144
|
+
"""Absolute path to the package-shipped default result schema."""
|
|
145
|
+
return BUILTIN_RESULT_SCHEMA
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def result_schema_path(taskgroup: Taskgroup) -> Optional[str]:
|
|
149
|
+
"""Resolve a taskgroup's `result_schema` setting to an argv-ready path.
|
|
150
|
+
|
|
151
|
+
* omitted / ``"default"`` -> the package-shipped ``schemas/result.json``
|
|
152
|
+
* ``"none"`` -> ``None`` (structured result disabled)
|
|
153
|
+
* any other string -> the absolute path validated at load time
|
|
154
|
+
|
|
155
|
+
Returning ``None`` is the ONLY way structured output is turned off, and it
|
|
156
|
+
is always paired with an explicit event at dispatch time — a run never
|
|
157
|
+
silently falls back to free text.
|
|
158
|
+
"""
|
|
159
|
+
value = taskgroup.result_schema
|
|
160
|
+
if value == RESULT_SCHEMA_NONE:
|
|
161
|
+
return None
|
|
162
|
+
if not value or value == RESULT_SCHEMA_DEFAULT:
|
|
163
|
+
return BUILTIN_RESULT_SCHEMA
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def infer_verify(path: str):
|
|
168
|
+
"""Return (command, verify_kind) for the first recognised build file."""
|
|
169
|
+
for name, command, kind in _BUILD_FILES:
|
|
170
|
+
if os.path.isfile(os.path.join(path, name)):
|
|
171
|
+
return command, kind
|
|
172
|
+
return None, "none"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def probe_repository(path: str) -> dict:
|
|
176
|
+
"""Build the registration draft for one repository without writing state.
|
|
177
|
+
|
|
178
|
+
The CLI and the API both use this so "what register would infer" cannot
|
|
179
|
+
drift between the two entry points. The draft is a ``[[taskgroup]]`` block:
|
|
180
|
+
it carries no ``project``, so it stands up a same-named project (the compat
|
|
181
|
+
rule). The taskgroup's lock defaults to its own id.
|
|
182
|
+
"""
|
|
183
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
184
|
+
taskgroup_id = os.path.basename(os.path.normpath(path)) or path
|
|
185
|
+
command, kind = infer_verify(path)
|
|
186
|
+
probe = None
|
|
187
|
+
probe_exit = None
|
|
188
|
+
if command is not None:
|
|
189
|
+
outcome = verify.run_acceptance(path, command, timeout=_PROBE_TIMEOUT)
|
|
190
|
+
probe = "passed" if outcome.passed else "failed"
|
|
191
|
+
probe_exit = outcome.exit_code
|
|
192
|
+
return {
|
|
193
|
+
"id": taskgroup_id,
|
|
194
|
+
"path": path,
|
|
195
|
+
"verify": command,
|
|
196
|
+
"verify_kind": kind,
|
|
197
|
+
"probe": probe,
|
|
198
|
+
"probe_exit": probe_exit,
|
|
199
|
+
"aliases": [],
|
|
200
|
+
"forbidden_paths": [],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_bytes(path: str) -> bytes:
|
|
205
|
+
try:
|
|
206
|
+
with open(path, "rb") as handle:
|
|
207
|
+
return handle.read()
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
raise RegistryError(f"cannot read registry {path}: {exc}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def registry_hash(path: str) -> str:
|
|
213
|
+
"""SHA-256 of the registry's raw bytes, lowercase hexadecimal."""
|
|
214
|
+
return hashlib.sha256(_read_bytes(path)).hexdigest()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def registry_metadata(path: str) -> dict:
|
|
218
|
+
"""Path, content hash and mtime for the optimistic-write protocol."""
|
|
219
|
+
data = _read_bytes(path)
|
|
220
|
+
stat = os.stat(path)
|
|
221
|
+
return {
|
|
222
|
+
"path": path,
|
|
223
|
+
"hash": hashlib.sha256(data).hexdigest(),
|
|
224
|
+
"mtime": stat.st_mtime,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _probe_flags_from(data: dict) -> dict:
|
|
229
|
+
flags = {}
|
|
230
|
+
for table in ("project", "taskgroup"):
|
|
231
|
+
for entry in data.get(table, []) or []:
|
|
232
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
|
|
233
|
+
continue
|
|
234
|
+
values = {}
|
|
235
|
+
if "probe" in entry:
|
|
236
|
+
values["probe"] = entry["probe"]
|
|
237
|
+
if "probe_exit" in entry:
|
|
238
|
+
values["probe_exit"] = entry["probe_exit"]
|
|
239
|
+
if values:
|
|
240
|
+
flags[entry["id"]] = values
|
|
241
|
+
return flags
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def read_probe_flags(path: str) -> dict:
|
|
245
|
+
"""Return raw probe metadata keyed by block id.
|
|
246
|
+
|
|
247
|
+
``registry.load`` intentionally keeps only fields it understands; probe
|
|
248
|
+
results are human/API metadata, so they are read from the TOML source just
|
|
249
|
+
like ``cli._read_probe_flags`` does. Both ``[[project]]`` (legacy) and
|
|
250
|
+
``[[taskgroup]]`` blocks are consulted.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
with open(path, "rb") as handle:
|
|
254
|
+
data = tomllib.load(handle)
|
|
255
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
256
|
+
return {}
|
|
257
|
+
return _probe_flags_from(data)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class Registry:
|
|
261
|
+
"""Loaded registry: projects + taskgroups + global defaults."""
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self,
|
|
265
|
+
projects: List[Project],
|
|
266
|
+
taskgroups: List[Taskgroup],
|
|
267
|
+
defaults: dict,
|
|
268
|
+
source: Optional[str] = None,
|
|
269
|
+
):
|
|
270
|
+
self.projects = projects
|
|
271
|
+
self.taskgroups = taskgroups
|
|
272
|
+
self.defaults = defaults or {}
|
|
273
|
+
self.source = source
|
|
274
|
+
|
|
275
|
+
# -- lookup -----------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def lanes_for(self, project_id: str) -> List[Taskgroup]:
|
|
278
|
+
"""Every taskgroup that hangs off ``project_id``, in file order."""
|
|
279
|
+
return [tg for tg in self.taskgroups if tg.project == project_id]
|
|
280
|
+
|
|
281
|
+
def _lane_for_project(self, project: Project) -> Optional[Taskgroup]:
|
|
282
|
+
lanes = self.lanes_for(project.id)
|
|
283
|
+
if len(lanes) == 1:
|
|
284
|
+
return lanes[0]
|
|
285
|
+
if not lanes:
|
|
286
|
+
return None
|
|
287
|
+
ids = ", ".join(tg.id for tg in lanes)
|
|
288
|
+
raise RegistryError(
|
|
289
|
+
f"project '{project.id}' has multiple taskgroups: {ids}",
|
|
290
|
+
hint="pick one taskgroup id, alias or path",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _single_or_ambiguous(matches, label="taskgroups"):
|
|
295
|
+
if len(matches) == 1:
|
|
296
|
+
return matches[0]
|
|
297
|
+
if len(matches) > 1:
|
|
298
|
+
ids = ", ".join(item.id for item in matches)
|
|
299
|
+
raise RegistryError(
|
|
300
|
+
f"path matches multiple {label}: {ids}",
|
|
301
|
+
hint="use a taskgroup id, alias or path",
|
|
302
|
+
)
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
def by_id(self, key: str) -> Optional[Taskgroup]:
|
|
306
|
+
"""Resolve to a taskgroup: id, alias, path, then owning project.
|
|
307
|
+
|
|
308
|
+
Taskgroups win. A project key resolves only when the project has
|
|
309
|
+
exactly one lane; a project with several lanes is an error that lists
|
|
310
|
+
them, so a bare project id is never silently mis-dispatched.
|
|
311
|
+
"""
|
|
312
|
+
if not isinstance(key, str) or not key:
|
|
313
|
+
return None
|
|
314
|
+
for tg in self.taskgroups:
|
|
315
|
+
if tg.id == key:
|
|
316
|
+
return tg
|
|
317
|
+
for tg in self.taskgroups:
|
|
318
|
+
if key in tg.aliases:
|
|
319
|
+
return tg
|
|
320
|
+
found = self._single_or_ambiguous([tg for tg in self.taskgroups if tg.path == key])
|
|
321
|
+
if found is not None:
|
|
322
|
+
return found
|
|
323
|
+
for project in self.projects:
|
|
324
|
+
if project.id == key:
|
|
325
|
+
return self._lane_for_project(project)
|
|
326
|
+
for project in self.projects:
|
|
327
|
+
if key in project.aliases:
|
|
328
|
+
return self._lane_for_project(project)
|
|
329
|
+
project = self._single_or_ambiguous(
|
|
330
|
+
[p for p in self.projects if p.path == key], label="projects"
|
|
331
|
+
)
|
|
332
|
+
if project is not None:
|
|
333
|
+
return self._lane_for_project(project)
|
|
334
|
+
# A path may be spelled differently (trailing slash, `~`, `..`);
|
|
335
|
+
# compare normalised absolute forms as a fallback.
|
|
336
|
+
if os.path.isabs(key):
|
|
337
|
+
norm = os.path.normpath(key)
|
|
338
|
+
found = self._single_or_ambiguous(
|
|
339
|
+
[tg for tg in self.taskgroups if os.path.normpath(tg.path) == norm]
|
|
340
|
+
)
|
|
341
|
+
if found is not None:
|
|
342
|
+
return found
|
|
343
|
+
project = self._single_or_ambiguous(
|
|
344
|
+
[p for p in self.projects if os.path.normpath(p.path) == norm],
|
|
345
|
+
label="projects",
|
|
346
|
+
)
|
|
347
|
+
if project is not None:
|
|
348
|
+
return self._lane_for_project(project)
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def require(self, key: str) -> Taskgroup:
|
|
352
|
+
taskgroup = self.by_id(key)
|
|
353
|
+
if taskgroup is None:
|
|
354
|
+
known = sorted({p.id for p in self.projects} | {t.id for t in self.taskgroups})
|
|
355
|
+
raise RegistryError(
|
|
356
|
+
f"project not found: {key}",
|
|
357
|
+
hint=f"registered projects: {', '.join(known) or '(none)'}",
|
|
358
|
+
)
|
|
359
|
+
return taskgroup
|
|
360
|
+
|
|
361
|
+
# -- defaults ---------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def concurrency(self) -> int:
|
|
365
|
+
# The effective global cap: `[defaults] concurrency` when set, otherwise
|
|
366
|
+
# the machine fallback (card 42). Use `concurrency.resolve(...)` when the
|
|
367
|
+
# source has to be shown too.
|
|
368
|
+
return concurrency.resolve(self.defaults).value
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def timeout(self) -> int:
|
|
372
|
+
# The `[defaults]` timeout, or the built-in fallback when unset. Use
|
|
373
|
+
# `resolve_timeout(...)` for the full three-step chain *and* its source.
|
|
374
|
+
return int(self.defaults.get("timeout", DEFAULT_TIMEOUT))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# The effective per-run timeout: a value *and* where it came from.
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
#
|
|
381
|
+
# The timeout has four possible sources, in precedence order:
|
|
382
|
+
#
|
|
383
|
+
# 1. ``cli`` — a one-off ``run --timeout N`` for this dispatch only;
|
|
384
|
+
# 2. ``lane`` — the ``timeout`` on the ``[[taskgroup]]`` being dispatched;
|
|
385
|
+
# 3. ``toml`` — ``[defaults] timeout`` in the workspace registry;
|
|
386
|
+
# 4. ``default`` — the built-in fallback below, used when nothing names one.
|
|
387
|
+
#
|
|
388
|
+
# ``cli`` / ``toml`` reuse the exact words `concurrency` uses for the cap, so
|
|
389
|
+
# the event stream never grows a second spelling for the same provenance.
|
|
390
|
+
SOURCE_TIMEOUT_CLI = concurrency.SOURCE_CLI
|
|
391
|
+
SOURCE_TIMEOUT_LANE = "lane"
|
|
392
|
+
SOURCE_TIMEOUT_TOML = concurrency.SOURCE_TOML
|
|
393
|
+
SOURCE_TIMEOUT_DEFAULT = "default"
|
|
394
|
+
|
|
395
|
+
#: Seconds enforced when neither the command line, the lane, nor `[defaults]`
|
|
396
|
+
#: names a timeout.
|
|
397
|
+
DEFAULT_TIMEOUT = 1800
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class TimeoutSetting(NamedTuple):
|
|
401
|
+
"""An effective timeout together with its provenance.
|
|
402
|
+
|
|
403
|
+
``source`` is one of :data:`SOURCE_TIMEOUT_CLI` / :data:`SOURCE_TIMEOUT_LANE`
|
|
404
|
+
/ :data:`SOURCE_TIMEOUT_TOML` / :data:`SOURCE_TIMEOUT_DEFAULT`. ``as_dict``
|
|
405
|
+
is the wire shape a surface exposes when it wants value-plus-source.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
value: int
|
|
409
|
+
source: str
|
|
410
|
+
|
|
411
|
+
def as_dict(self) -> dict:
|
|
412
|
+
return {"value": self.value, "source": self.source}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def resolve_timeout(
|
|
416
|
+
taskgroup: Optional[Taskgroup], defaults, cli: Optional[int] = None
|
|
417
|
+
) -> TimeoutSetting:
|
|
418
|
+
"""The effective timeout for one dispatch, with its source.
|
|
419
|
+
|
|
420
|
+
``cli`` (the run's ``--timeout``) wins over ``taskgroup.timeout``, which
|
|
421
|
+
wins over ``[defaults] timeout`` in ``defaults``, which wins over the
|
|
422
|
+
built-in fallback. ``taskgroup`` and ``defaults`` may be ``None``.
|
|
423
|
+
"""
|
|
424
|
+
if cli is not None:
|
|
425
|
+
return TimeoutSetting(int(cli), SOURCE_TIMEOUT_CLI)
|
|
426
|
+
lane = getattr(taskgroup, "timeout", None)
|
|
427
|
+
if lane is not None:
|
|
428
|
+
return TimeoutSetting(int(lane), SOURCE_TIMEOUT_LANE)
|
|
429
|
+
configured = (defaults or {}).get("timeout")
|
|
430
|
+
if configured is not None:
|
|
431
|
+
return TimeoutSetting(int(configured), SOURCE_TIMEOUT_TOML)
|
|
432
|
+
return TimeoutSetting(DEFAULT_TIMEOUT, SOURCE_TIMEOUT_DEFAULT)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# The effective no-progress watchdog budget: a value *and* where it came from.
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
#
|
|
439
|
+
# Card 83. The idle watchdog stops a card whose log AND working tree have both
|
|
440
|
+
# been quiet for `idle_timeout` seconds. It is a *separate* signal from the hard
|
|
441
|
+
# wall clock above, and it is off by default so the shipped behaviour is
|
|
442
|
+
# unchanged while operators watch it for false positives.
|
|
443
|
+
#
|
|
444
|
+
# Three sources, in precedence order:
|
|
445
|
+
#
|
|
446
|
+
# 1. ``cli`` — a one-off ``run --idle-timeout N`` for this dispatch only;
|
|
447
|
+
# 2. ``lane`` — the ``idle_timeout`` on the ``[[taskgroup]]`` being run;
|
|
448
|
+
# 3. ``default`` — the built-in fallback below (``0`` == off).
|
|
449
|
+
#
|
|
450
|
+
# Unlike ``timeout`` there is deliberately no ``[defaults]`` rung: the budget
|
|
451
|
+
# is a per-lane opt-in, never a workspace-wide default someone inherits blindly.
|
|
452
|
+
SOURCE_IDLE_TIMEOUT_CLI = concurrency.SOURCE_CLI
|
|
453
|
+
SOURCE_IDLE_TIMEOUT_LANE = "lane"
|
|
454
|
+
SOURCE_IDLE_TIMEOUT_DEFAULT = "default"
|
|
455
|
+
|
|
456
|
+
#: Seconds of "log quiet AND tree quiet" before a run is judged stuck. ``0``
|
|
457
|
+
#: disables the watchdog; this is the shipped default (card 83).
|
|
458
|
+
DEFAULT_IDLE_TIMEOUT = 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _require_idle_int(value, label: str, source: str) -> int:
|
|
462
|
+
"""A non-negative integer, or a RegistryError naming where it came from.
|
|
463
|
+
|
|
464
|
+
``0`` is legal and means "off". Rejects bools (TOML ``true`` is an int in
|
|
465
|
+
Python) and floats so a truthy ``true`` can never masquerade as the budget.
|
|
466
|
+
"""
|
|
467
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
468
|
+
raise RegistryError(
|
|
469
|
+
f"{source}: {label} field 'idle_timeout' must be a non-negative "
|
|
470
|
+
f"integer (got {value!r})"
|
|
471
|
+
)
|
|
472
|
+
return value
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def resolve_idle_timeout(
|
|
476
|
+
taskgroup: Optional[Taskgroup], cli: Optional[int] = None
|
|
477
|
+
) -> TimeoutSetting:
|
|
478
|
+
"""The effective idle watchdog budget for one dispatch, with its source.
|
|
479
|
+
|
|
480
|
+
``cli`` (the run's ``--idle-timeout``) wins over ``taskgroup.idle_timeout``,
|
|
481
|
+
which wins over the built-in fallback (``0``, i.e. off). ``taskgroup`` may
|
|
482
|
+
be ``None``. The returned ``source`` is one of :data:`SOURCE_IDLE_TIMEOUT_CLI`
|
|
483
|
+
/ :data:`SOURCE_IDLE_TIMEOUT_LANE` / :data:`SOURCE_IDLE_TIMEOUT_DEFAULT`.
|
|
484
|
+
"""
|
|
485
|
+
if cli is not None:
|
|
486
|
+
return TimeoutSetting(
|
|
487
|
+
_require_idle_int(cli, "run --idle-timeout", "cli"),
|
|
488
|
+
SOURCE_IDLE_TIMEOUT_CLI,
|
|
489
|
+
)
|
|
490
|
+
lane = getattr(taskgroup, "idle_timeout", None)
|
|
491
|
+
if lane is not None:
|
|
492
|
+
return TimeoutSetting(int(lane), SOURCE_IDLE_TIMEOUT_LANE)
|
|
493
|
+
return TimeoutSetting(DEFAULT_IDLE_TIMEOUT, SOURCE_IDLE_TIMEOUT_DEFAULT)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
#: Scope-source labels. The CLI prints these verbatim, so they are part of the
|
|
497
|
+
#: human contract (see docs: the scope must always be visible).
|
|
498
|
+
SCOPE_FROM_PROJECT = "来自 --project"
|
|
499
|
+
SCOPE_FROM_ALL = "来自 --all"
|
|
500
|
+
SCOPE_FROM_CWD = "来自 cwd"
|
|
501
|
+
SCOPE_OUTSIDE = "cwd 不在任何已登记仓库内"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def resolve_scope(reg, *, explicit_project=None, force_all=False, cwd=None):
|
|
505
|
+
"""Map the caller's context to one taskgroup (or all), plus a source label.
|
|
506
|
+
|
|
507
|
+
Returns ``(taskgroup_id_or_None, source_label)``. ``None`` means "all
|
|
508
|
+
projects". Priority is fixed and deliberately boring:
|
|
509
|
+
|
|
510
|
+
``--project`` > ``--all`` > cwd inference > all (when cwd is outside)
|
|
511
|
+
|
|
512
|
+
cwd inference matches a registered taskgroup when the working directory is
|
|
513
|
+
that lane's path *or* a descendant. Symlinks are resolved first (macOS
|
|
514
|
+
spells ``/tmp`` and ``/private/tmp`` differently), and the *longest*
|
|
515
|
+
matching lane path wins so a nested checkout beats its parent repo. When
|
|
516
|
+
two lanes match at the same depth the choice is ambiguous and refused, with
|
|
517
|
+
the candidates named.
|
|
518
|
+
"""
|
|
519
|
+
if explicit_project:
|
|
520
|
+
taskgroup = reg.by_id(explicit_project) if reg is not None else None
|
|
521
|
+
return (
|
|
522
|
+
taskgroup.id if taskgroup is not None else explicit_project,
|
|
523
|
+
SCOPE_FROM_PROJECT,
|
|
524
|
+
)
|
|
525
|
+
if force_all:
|
|
526
|
+
return None, SCOPE_FROM_ALL
|
|
527
|
+
|
|
528
|
+
if cwd is None:
|
|
529
|
+
cwd = os.getcwd()
|
|
530
|
+
target = os.path.realpath(cwd)
|
|
531
|
+
candidates = []
|
|
532
|
+
best_len = -1
|
|
533
|
+
for taskgroup in (reg.taskgroups if reg is not None else []):
|
|
534
|
+
taskgroup_path = os.path.realpath(taskgroup.path)
|
|
535
|
+
if target == taskgroup_path or target.startswith(taskgroup_path + os.sep):
|
|
536
|
+
length = len(taskgroup_path)
|
|
537
|
+
if length > best_len:
|
|
538
|
+
best_len = length
|
|
539
|
+
candidates = [taskgroup]
|
|
540
|
+
elif length == best_len:
|
|
541
|
+
candidates.append(taskgroup)
|
|
542
|
+
if len(candidates) == 1:
|
|
543
|
+
return candidates[0].id, SCOPE_FROM_CWD
|
|
544
|
+
if len(candidates) > 1:
|
|
545
|
+
ids = ", ".join(tg.id for tg in candidates)
|
|
546
|
+
raise RegistryError(
|
|
547
|
+
f"cwd matches multiple taskgroups: {ids}",
|
|
548
|
+
hint="pass --project to pick one",
|
|
549
|
+
)
|
|
550
|
+
return None, SCOPE_OUTSIDE
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
VALID_VERIFY_KINDS = ("check", "build", "none")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _require_field(entry: dict, field: str, kind: str, index: int, source: str) -> str:
|
|
557
|
+
value = entry.get(field)
|
|
558
|
+
if not isinstance(value, str) or not value.strip():
|
|
559
|
+
raise RegistryError(
|
|
560
|
+
f"{source}: {kind} #{index + 1} is missing a non-empty '{field}'"
|
|
561
|
+
)
|
|
562
|
+
return value
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _load_string_array(value, field: str, label: str, source: str) -> list:
|
|
566
|
+
if value is None:
|
|
567
|
+
return []
|
|
568
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
569
|
+
raise RegistryError(
|
|
570
|
+
f"{source}: {label} field '{field}' must be an array of strings"
|
|
571
|
+
)
|
|
572
|
+
return list(value)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _load_group(entry: dict, default: str, label: str, source: str) -> str:
|
|
576
|
+
"""Read a lock name, treating missing/empty/blank as the lane's own id."""
|
|
577
|
+
group = entry.get("group")
|
|
578
|
+
if group is None:
|
|
579
|
+
return default
|
|
580
|
+
if not isinstance(group, str):
|
|
581
|
+
raise RegistryError(f"{source}: {label} field 'group' must be a string")
|
|
582
|
+
group = group.strip()
|
|
583
|
+
return group or default
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _validated_link_paths(value, field: str, label: str, source: str) -> list:
|
|
587
|
+
"""Validate a registry ``link`` array and return it unchanged.
|
|
588
|
+
|
|
589
|
+
Link paths are relative to the workspace root, never to the lane path.
|
|
590
|
+
They must remain inside that root: absolute paths and any ``..`` segment
|
|
591
|
+
are configuration errors rather than something to normalise silently.
|
|
592
|
+
"""
|
|
593
|
+
paths = _load_string_array(value, field, label, source)
|
|
594
|
+
for item in paths:
|
|
595
|
+
if not item.strip():
|
|
596
|
+
raise RegistryError(
|
|
597
|
+
f"{source}: {label} field '{field}' entries must be non-empty"
|
|
598
|
+
)
|
|
599
|
+
if os.path.isabs(item):
|
|
600
|
+
raise RegistryError(
|
|
601
|
+
f"{source}: {label} field '{field}' entries must be relative: "
|
|
602
|
+
f"{item!r}"
|
|
603
|
+
)
|
|
604
|
+
if ".." in item.split("/"):
|
|
605
|
+
raise RegistryError(
|
|
606
|
+
f"{source}: {label} field '{field}' entries must not contain "
|
|
607
|
+
f"'..' segments: {item!r}"
|
|
608
|
+
)
|
|
609
|
+
return paths
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _load_lane_fields(
|
|
613
|
+
entry: dict, label: str, source: str, default_group: str
|
|
614
|
+
) -> dict:
|
|
615
|
+
"""Validate and canonicalise the lane fields shared by both block kinds."""
|
|
616
|
+
result_schema = entry.get("result_schema", RESULT_SCHEMA_DEFAULT)
|
|
617
|
+
if not isinstance(result_schema, str):
|
|
618
|
+
raise RegistryError(
|
|
619
|
+
f"{source}: {label} result_schema must be a string "
|
|
620
|
+
f"(got {type(result_schema).__name__})"
|
|
621
|
+
)
|
|
622
|
+
result_schema = result_schema.strip()
|
|
623
|
+
if result_schema not in (RESULT_SCHEMA_DEFAULT, RESULT_SCHEMA_NONE):
|
|
624
|
+
# Anything else is an absolute path to a custom schema. A missing
|
|
625
|
+
# path is a hard error: silently falling back to the default would
|
|
626
|
+
# change the result contract behind the user's back.
|
|
627
|
+
if not os.path.isabs(result_schema):
|
|
628
|
+
raise RegistryError(
|
|
629
|
+
f"{source}: {label} result_schema must be "
|
|
630
|
+
f"'default', 'none', or an absolute path: {result_schema!r}"
|
|
631
|
+
)
|
|
632
|
+
if not os.path.isfile(result_schema):
|
|
633
|
+
raise RegistryError(
|
|
634
|
+
f"{source}: {label} result_schema file not found: {result_schema}"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
verify_value = entry.get("verify")
|
|
638
|
+
if verify_value is not None and not isinstance(verify_value, str):
|
|
639
|
+
raise RegistryError(f"{source}: {label} field 'verify' must be a string or null")
|
|
640
|
+
if isinstance(verify_value, str) and not verify_value.strip():
|
|
641
|
+
verify_value = None
|
|
642
|
+
|
|
643
|
+
verify_kind = entry.get("verify_kind", "none")
|
|
644
|
+
if verify_kind not in VALID_VERIFY_KINDS:
|
|
645
|
+
raise RegistryError(
|
|
646
|
+
f"{source}: {label} has invalid verify_kind "
|
|
647
|
+
f"{verify_kind!r} (expected one of {', '.join(VALID_VERIFY_KINDS)})"
|
|
648
|
+
)
|
|
649
|
+
if verify_value is None:
|
|
650
|
+
# No acceptance command => the task is SKIPPED, never "passed".
|
|
651
|
+
verify_kind = "none"
|
|
652
|
+
|
|
653
|
+
forbidden_paths = _load_string_array(
|
|
654
|
+
entry.get("forbidden_paths"), "forbidden_paths", label, source
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
workspace = entry.get("workspace", "none")
|
|
658
|
+
if not isinstance(workspace, str):
|
|
659
|
+
raise RegistryError(
|
|
660
|
+
f"{source}: {label} field 'workspace' must be a string "
|
|
661
|
+
f"(got {type(workspace).__name__})"
|
|
662
|
+
)
|
|
663
|
+
workspace = workspace.strip()
|
|
664
|
+
if workspace not in VALID_WORKSPACE_MODES:
|
|
665
|
+
raise RegistryError(
|
|
666
|
+
f"{source}: {label} has invalid workspace {workspace!r} "
|
|
667
|
+
f"(expected one of {', '.join(VALID_WORKSPACE_MODES)})"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
link = _validated_link_paths(entry.get("link"), "link", label, source)
|
|
671
|
+
if link and workspace != "worktree":
|
|
672
|
+
raise RegistryError(
|
|
673
|
+
f"{source}: {label} field 'link' is only valid when "
|
|
674
|
+
"workspace = 'worktree'"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# A lane timeout, when written, must be a positive integer. TOML gives us
|
|
678
|
+
# bool/int/float here, so reject every wrong type up front rather than
|
|
679
|
+
# letting a truthy `true` masquerade as 1 second. Missing stays `None`:
|
|
680
|
+
# the value falls through to `[defaults]` at dispatch time (see
|
|
681
|
+
# `resolve_timeout`), never silently here.
|
|
682
|
+
timeout = entry.get("timeout")
|
|
683
|
+
if timeout is not None:
|
|
684
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
|
|
685
|
+
raise RegistryError(
|
|
686
|
+
f"{source}: {label} field 'timeout' must be a positive "
|
|
687
|
+
f"integer (got {timeout!r})"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Card 83: a lane idle budget, when written, must be a non-negative integer.
|
|
691
|
+
# Unlike `timeout`, `0` is LEGAL and means "off" (the shipped default); a
|
|
692
|
+
# negative number or a non-integer is refused, naming the offending lane,
|
|
693
|
+
# rather than silently dropped.
|
|
694
|
+
idle_timeout = entry.get("idle_timeout")
|
|
695
|
+
if idle_timeout is not None:
|
|
696
|
+
idle_timeout = _require_idle_int(idle_timeout, label, source)
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
"group": _load_group(entry, default_group, label, source),
|
|
700
|
+
"verify": verify_value,
|
|
701
|
+
"verify_kind": verify_kind,
|
|
702
|
+
"forbidden_paths": forbidden_paths,
|
|
703
|
+
"auto_registered": bool(entry.get("auto_registered", False)),
|
|
704
|
+
"result_schema": result_schema,
|
|
705
|
+
"workspace": workspace,
|
|
706
|
+
"link": link,
|
|
707
|
+
"timeout": timeout,
|
|
708
|
+
"idle_timeout": idle_timeout,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def load(path: str) -> Registry:
|
|
713
|
+
"""Parse and validate a registry file.
|
|
714
|
+
|
|
715
|
+
Accepts both block kinds and applies the zero-migration compat rule: a
|
|
716
|
+
``[[project]]`` carrying any lane field becomes a project + same-named
|
|
717
|
+
taskgroup. A taskgroup that names an unknown project is a hard error.
|
|
718
|
+
"""
|
|
719
|
+
if not os.path.exists(path):
|
|
720
|
+
raise RegistryError(f"registry not found: {path}")
|
|
721
|
+
try:
|
|
722
|
+
with open(path, "rb") as fh:
|
|
723
|
+
data = tomllib.load(fh)
|
|
724
|
+
except tomllib.TOMLDecodeError as exc:
|
|
725
|
+
# tomllib appends "at line N, column M" to its message when it can.
|
|
726
|
+
raise RegistryError(f"invalid TOML in {path}: {exc}")
|
|
727
|
+
except OSError as exc:
|
|
728
|
+
raise RegistryError(f"cannot read registry {path}: {exc}")
|
|
729
|
+
|
|
730
|
+
raw_projects = data.get("project", [])
|
|
731
|
+
if not isinstance(raw_projects, list):
|
|
732
|
+
raise RegistryError(f"{path}: 'project' must be an array of tables")
|
|
733
|
+
raw_taskgroups = data.get("taskgroup", [])
|
|
734
|
+
if not isinstance(raw_taskgroups, list):
|
|
735
|
+
raise RegistryError(f"{path}: 'taskgroup' must be an array of tables")
|
|
736
|
+
|
|
737
|
+
projects: List[Project] = []
|
|
738
|
+
project_by_id = {}
|
|
739
|
+
legacy_lanes = []
|
|
740
|
+
for index, entry in enumerate(raw_projects):
|
|
741
|
+
if not isinstance(entry, dict):
|
|
742
|
+
raise RegistryError(f"{path}: project #{index + 1} is not a table")
|
|
743
|
+
project_id = _require_field(entry, "id", "project", index, path)
|
|
744
|
+
if project_id in project_by_id:
|
|
745
|
+
raise RegistryError(f"{path}: duplicate project id: {project_id}")
|
|
746
|
+
project_path = entry.get("path")
|
|
747
|
+
if not isinstance(project_path, str) or not project_path.strip():
|
|
748
|
+
raise RegistryError(
|
|
749
|
+
f"{path}: project #{index + 1} is missing a non-empty 'path'"
|
|
750
|
+
)
|
|
751
|
+
if not os.path.isabs(project_path):
|
|
752
|
+
raise RegistryError(
|
|
753
|
+
f"{path}: project '{project_id}' path must be absolute: {project_path}"
|
|
754
|
+
)
|
|
755
|
+
aliases = _load_string_array(
|
|
756
|
+
entry.get("aliases"), "aliases", f"project '{project_id}'", path
|
|
757
|
+
)
|
|
758
|
+
project = Project(id=project_id, path=project_path, aliases=aliases)
|
|
759
|
+
projects.append(project)
|
|
760
|
+
project_by_id[project_id] = project
|
|
761
|
+
if any(field in entry for field in _LANE_FIELDS):
|
|
762
|
+
# Zero-migration compat: this block is a project + a same-named lane.
|
|
763
|
+
lane = _load_lane_fields(
|
|
764
|
+
entry, f"project '{project_id}'", path, default_group=project_id
|
|
765
|
+
)
|
|
766
|
+
legacy_lanes.append((project_id, project_path, lane))
|
|
767
|
+
|
|
768
|
+
# Explicit taskgroups are validated against every project we have seen,
|
|
769
|
+
# including the implicit ones created by self-contained taskgroups.
|
|
770
|
+
taskgroups: List[Taskgroup] = []
|
|
771
|
+
taskgroup_ids = {}
|
|
772
|
+
for project_id, project_path, lane in legacy_lanes:
|
|
773
|
+
taskgroups.append(
|
|
774
|
+
Taskgroup(
|
|
775
|
+
id=project_id,
|
|
776
|
+
project=project_id,
|
|
777
|
+
path=project_path,
|
|
778
|
+
aliases=[],
|
|
779
|
+
**lane,
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
taskgroup_ids[project_id] = True
|
|
783
|
+
|
|
784
|
+
taskgroup_meta = []
|
|
785
|
+
for index, entry in enumerate(raw_taskgroups):
|
|
786
|
+
if not isinstance(entry, dict):
|
|
787
|
+
raise RegistryError(f"{path}: taskgroup #{index + 1} is not a table")
|
|
788
|
+
taskgroup_id = _require_field(entry, "id", "taskgroup", index, path)
|
|
789
|
+
if taskgroup_id in taskgroup_ids:
|
|
790
|
+
raise RegistryError(f"{path}: duplicate taskgroup id: {taskgroup_id}")
|
|
791
|
+
taskgroup_ids[taskgroup_id] = True
|
|
792
|
+
project_field = entry.get("project")
|
|
793
|
+
explicit = isinstance(project_field, str) and bool(project_field.strip())
|
|
794
|
+
owning = project_field.strip() if explicit else taskgroup_id
|
|
795
|
+
taskgroup_meta.append((entry, taskgroup_id, owning, explicit))
|
|
796
|
+
|
|
797
|
+
implicit_paths = {}
|
|
798
|
+
for entry, taskgroup_id, owning, explicit in taskgroup_meta:
|
|
799
|
+
if explicit or owning in project_by_id:
|
|
800
|
+
continue
|
|
801
|
+
own_path = entry.get("path")
|
|
802
|
+
if not isinstance(own_path, str) or not own_path.strip():
|
|
803
|
+
raise RegistryError(
|
|
804
|
+
f"{path}: taskgroup '{taskgroup_id}' is self-contained "
|
|
805
|
+
"(no 'project') and needs an absolute 'path'"
|
|
806
|
+
)
|
|
807
|
+
if not os.path.isabs(own_path):
|
|
808
|
+
raise RegistryError(
|
|
809
|
+
f"{path}: taskgroup '{taskgroup_id}' path must be absolute: {own_path}"
|
|
810
|
+
)
|
|
811
|
+
implicit_paths[owning] = own_path
|
|
812
|
+
|
|
813
|
+
for entry, taskgroup_id, owning, explicit in taskgroup_meta:
|
|
814
|
+
if explicit and owning not in project_by_id and owning not in implicit_paths:
|
|
815
|
+
raise RegistryError(
|
|
816
|
+
f"{path}: taskgroup '{taskgroup_id}' references unknown project '{owning}'"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
for implicit_id, implicit_path in implicit_paths.items():
|
|
820
|
+
project = Project(id=implicit_id, path=implicit_path, aliases=[])
|
|
821
|
+
projects.append(project)
|
|
822
|
+
project_by_id[implicit_id] = project
|
|
823
|
+
|
|
824
|
+
for entry, taskgroup_id, owning, explicit in taskgroup_meta:
|
|
825
|
+
lane = _load_lane_fields(
|
|
826
|
+
entry, f"taskgroup '{taskgroup_id}'", path, default_group=taskgroup_id
|
|
827
|
+
)
|
|
828
|
+
aliases = _load_string_array(
|
|
829
|
+
entry.get("aliases"), "aliases", f"taskgroup '{taskgroup_id}'", path
|
|
830
|
+
)
|
|
831
|
+
taskgroup_path = entry.get("path")
|
|
832
|
+
if isinstance(taskgroup_path, str) and taskgroup_path.strip():
|
|
833
|
+
if not os.path.isabs(taskgroup_path):
|
|
834
|
+
raise RegistryError(
|
|
835
|
+
f"{path}: taskgroup '{taskgroup_id}' path must be absolute: "
|
|
836
|
+
f"{taskgroup_path}"
|
|
837
|
+
)
|
|
838
|
+
resolved_path = taskgroup_path
|
|
839
|
+
else:
|
|
840
|
+
resolved_path = project_by_id[owning].path
|
|
841
|
+
taskgroups.append(
|
|
842
|
+
Taskgroup(
|
|
843
|
+
id=taskgroup_id,
|
|
844
|
+
project=owning,
|
|
845
|
+
path=resolved_path,
|
|
846
|
+
aliases=aliases,
|
|
847
|
+
**lane,
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
defaults = data.get("defaults", {})
|
|
852
|
+
if not isinstance(defaults, dict):
|
|
853
|
+
raise RegistryError(f"{path}: 'defaults' must be a table")
|
|
854
|
+
return Registry(projects, taskgroups, defaults, source=path)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def workspace_registry_path(workspace: str) -> str:
|
|
858
|
+
return os.path.join(workspace, DEFAULT_REGISTRY_NAME)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
# Surgical registry writes
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
#: Keys `update_project` may change on a `[[taskgroup]]` block. `id` is the
|
|
866
|
+
#: identity, so it is not mutable; `group` is an editable lock name.
|
|
867
|
+
_TASKGROUP_MUTABLE_FIELDS = (
|
|
868
|
+
"project",
|
|
869
|
+
"path",
|
|
870
|
+
"aliases",
|
|
871
|
+
"group",
|
|
872
|
+
"verify",
|
|
873
|
+
"verify_kind",
|
|
874
|
+
"forbidden_paths",
|
|
875
|
+
"result_schema",
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
#: Keys `update_project` may change on a `[[project]]` block. A legacy block can
|
|
879
|
+
#: still carry lane fields (that is exactly what makes it dispatchable), and a
|
|
880
|
+
#: surgical update must keep that ability; `id` stays immutable.
|
|
881
|
+
_PROJECT_MUTABLE_FIELDS = (
|
|
882
|
+
"path",
|
|
883
|
+
"aliases",
|
|
884
|
+
"group",
|
|
885
|
+
"verify",
|
|
886
|
+
"verify_kind",
|
|
887
|
+
"forbidden_paths",
|
|
888
|
+
"result_schema",
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def taskgroup_record(taskgroup: Taskgroup, probe_flags: Optional[dict] = None) -> dict:
|
|
893
|
+
"""JSON-shaped lane data shared by list/create/update responses.
|
|
894
|
+
|
|
895
|
+
Shape is the historical ``project`` record: every old key is present, plus
|
|
896
|
+
the new ``project`` (owning project id) field. The API keeps one row per
|
|
897
|
+
lane so the dashboard's ``?project=`` filter is unchanged.
|
|
898
|
+
"""
|
|
899
|
+
flags = (probe_flags or {}).get(taskgroup.id) or {}
|
|
900
|
+
probe = flags.get("probe")
|
|
901
|
+
probe_exit = flags.get("probe_exit")
|
|
902
|
+
if probe is None and taskgroup.verify_kind == "none":
|
|
903
|
+
probe = "none"
|
|
904
|
+
# Older CLI registrations wrote only `probe = "passed"`. That verdict can
|
|
905
|
+
# only mean exit 0, so the API can report the missing exit code truthfully.
|
|
906
|
+
if probe == "passed" and probe_exit is None:
|
|
907
|
+
probe_exit = 0
|
|
908
|
+
return {
|
|
909
|
+
"id": taskgroup.id,
|
|
910
|
+
"project": taskgroup.project,
|
|
911
|
+
"path": taskgroup.path,
|
|
912
|
+
"group": taskgroup.group,
|
|
913
|
+
"aliases": list(taskgroup.aliases),
|
|
914
|
+
"verify": taskgroup.verify,
|
|
915
|
+
"verify_kind": taskgroup.verify_kind,
|
|
916
|
+
"forbidden_paths": list(taskgroup.forbidden_paths),
|
|
917
|
+
"result_schema": taskgroup.result_schema,
|
|
918
|
+
"timeout": taskgroup.timeout,
|
|
919
|
+
"idle_timeout": taskgroup.idle_timeout,
|
|
920
|
+
"auto_registered": bool(taskgroup.auto_registered),
|
|
921
|
+
"probe": probe,
|
|
922
|
+
"probe_exit": probe_exit,
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
#: Kept for callers that still spell the record after the old model name.
|
|
927
|
+
project_record = taskgroup_record
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _toml_string(value) -> str:
|
|
931
|
+
# JSON strings are valid TOML basic strings for the values used here; using
|
|
932
|
+
# json.dumps also handles quotes, backslashes and control characters.
|
|
933
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _array_literal(values) -> str:
|
|
937
|
+
return "[" + ", ".join(_toml_string(value) for value in values) + "]"
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _as_nonempty_string(value, field: str) -> str:
|
|
941
|
+
if not isinstance(value, str) or not value.strip():
|
|
942
|
+
raise RegistryError(f"field '{field}' must be a non-empty string")
|
|
943
|
+
return value
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _string_list(value, field: str):
|
|
947
|
+
if value is None:
|
|
948
|
+
return []
|
|
949
|
+
if not isinstance(value, list):
|
|
950
|
+
raise RegistryError(f"field '{field}' must be an array of strings")
|
|
951
|
+
for item in value:
|
|
952
|
+
if not isinstance(item, str):
|
|
953
|
+
raise RegistryError(f"field '{field}' must be an array of strings")
|
|
954
|
+
return list(value)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _normalise_group(entry: dict, default: str) -> str:
|
|
958
|
+
group = entry.get("group")
|
|
959
|
+
if group is None or group == "":
|
|
960
|
+
return default
|
|
961
|
+
if not isinstance(group, str):
|
|
962
|
+
raise RegistryError("field 'group' must be a string")
|
|
963
|
+
group = group.strip()
|
|
964
|
+
return group or default
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _normalise_lane_fields(entry: dict, label: str, default_group: str) -> dict:
|
|
968
|
+
verify_value = entry.get("verify")
|
|
969
|
+
if verify_value is not None and not isinstance(verify_value, str):
|
|
970
|
+
raise RegistryError("field 'verify' must be a string or null")
|
|
971
|
+
verify_value = verify_value.strip() if isinstance(verify_value, str) else None
|
|
972
|
+
if not verify_value:
|
|
973
|
+
verify_value = None
|
|
974
|
+
|
|
975
|
+
verify_kind = entry.get("verify_kind", "none")
|
|
976
|
+
if verify_kind is None:
|
|
977
|
+
verify_kind = "none"
|
|
978
|
+
if verify_kind not in VALID_VERIFY_KINDS:
|
|
979
|
+
raise RegistryError(
|
|
980
|
+
f"'{label}' has invalid verify_kind {verify_kind!r} "
|
|
981
|
+
f"(expected one of {', '.join(VALID_VERIFY_KINDS)})"
|
|
982
|
+
)
|
|
983
|
+
if verify_value is None:
|
|
984
|
+
verify_kind = "none"
|
|
985
|
+
|
|
986
|
+
forbidden_paths = _string_list(entry.get("forbidden_paths"), "forbidden_paths")
|
|
987
|
+
|
|
988
|
+
result_schema = entry.get("result_schema", RESULT_SCHEMA_DEFAULT)
|
|
989
|
+
if result_schema is None or result_schema == "":
|
|
990
|
+
result_schema = RESULT_SCHEMA_DEFAULT
|
|
991
|
+
if not isinstance(result_schema, str):
|
|
992
|
+
raise RegistryError("field 'result_schema' must be a string")
|
|
993
|
+
if result_schema not in (RESULT_SCHEMA_DEFAULT, RESULT_SCHEMA_NONE):
|
|
994
|
+
if not os.path.isabs(result_schema):
|
|
995
|
+
raise RegistryError(
|
|
996
|
+
"field 'result_schema' must be 'default', 'none', "
|
|
997
|
+
"or an absolute path"
|
|
998
|
+
)
|
|
999
|
+
if not os.path.isfile(result_schema):
|
|
1000
|
+
raise RegistryError(f"result_schema file not found: {result_schema}")
|
|
1001
|
+
|
|
1002
|
+
probe = entry.get("probe")
|
|
1003
|
+
if probe is not None and not isinstance(probe, str):
|
|
1004
|
+
raise RegistryError("field 'probe' must be a string")
|
|
1005
|
+
probe_exit = entry.get("probe_exit")
|
|
1006
|
+
if probe_exit is not None:
|
|
1007
|
+
if isinstance(probe_exit, bool) or not isinstance(probe_exit, int):
|
|
1008
|
+
raise RegistryError("field 'probe_exit' must be an integer")
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
"group": _normalise_group(entry, default_group),
|
|
1012
|
+
"verify": verify_value,
|
|
1013
|
+
"verify_kind": verify_kind,
|
|
1014
|
+
"forbidden_paths": forbidden_paths,
|
|
1015
|
+
"result_schema": result_schema,
|
|
1016
|
+
"probe": probe,
|
|
1017
|
+
"probe_exit": probe_exit,
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _normalise_project(entry: dict) -> dict:
|
|
1022
|
+
"""Validate and canonicalise one `[[project]]` block before rendering."""
|
|
1023
|
+
if not isinstance(entry, dict):
|
|
1024
|
+
raise RegistryError("project entry must be a table")
|
|
1025
|
+
project_id = _as_nonempty_string(entry.get("id"), "id")
|
|
1026
|
+
path = _as_nonempty_string(entry.get("path"), "path")
|
|
1027
|
+
if not os.path.isabs(path):
|
|
1028
|
+
raise RegistryError(f"project '{project_id}' path must be absolute: {path}")
|
|
1029
|
+
aliases = _string_list(entry.get("aliases"), "aliases")
|
|
1030
|
+
lane = _normalise_lane_fields(entry, project_id, default_group=project_id)
|
|
1031
|
+
return {"id": project_id, "path": path, "aliases": aliases, **lane}
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _normalise_taskgroup(entry: dict, project_path: Optional[str] = None) -> dict:
|
|
1035
|
+
"""Validate and canonicalise one `[[taskgroup]]` block before rendering."""
|
|
1036
|
+
if not isinstance(entry, dict):
|
|
1037
|
+
raise RegistryError("taskgroup entry must be a table")
|
|
1038
|
+
taskgroup_id = _as_nonempty_string(entry.get("id"), "id")
|
|
1039
|
+
project = entry.get("project")
|
|
1040
|
+
if project is None or project == "":
|
|
1041
|
+
project = taskgroup_id
|
|
1042
|
+
project = _as_nonempty_string(project, "project")
|
|
1043
|
+
|
|
1044
|
+
path = entry.get("path")
|
|
1045
|
+
if path is None or path == "":
|
|
1046
|
+
path = project_path
|
|
1047
|
+
if path is not None:
|
|
1048
|
+
path = _as_nonempty_string(path, "path")
|
|
1049
|
+
if not os.path.isabs(path):
|
|
1050
|
+
raise RegistryError(
|
|
1051
|
+
f"taskgroup '{taskgroup_id}' path must be absolute: {path}"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
aliases = _string_list(entry.get("aliases"), "aliases")
|
|
1055
|
+
lane = _normalise_lane_fields(
|
|
1056
|
+
entry, taskgroup_id, default_group=taskgroup_id
|
|
1057
|
+
)
|
|
1058
|
+
return {
|
|
1059
|
+
"id": taskgroup_id,
|
|
1060
|
+
"project": project,
|
|
1061
|
+
"path": path,
|
|
1062
|
+
"aliases": aliases,
|
|
1063
|
+
**lane,
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _format_taskgroup_block(taskgroup: dict) -> str:
|
|
1068
|
+
lines = [
|
|
1069
|
+
"[[taskgroup]]",
|
|
1070
|
+
f"id = {_toml_string(taskgroup['id'])}",
|
|
1071
|
+
]
|
|
1072
|
+
if taskgroup["project"] != taskgroup["id"]:
|
|
1073
|
+
lines.append(f"project = {_toml_string(taskgroup['project'])}")
|
|
1074
|
+
if taskgroup["path"] is not None:
|
|
1075
|
+
lines.append(f"path = {_toml_string(taskgroup['path'])}")
|
|
1076
|
+
if taskgroup["aliases"]:
|
|
1077
|
+
lines.append(f"aliases = {_array_literal(taskgroup['aliases'])}")
|
|
1078
|
+
if taskgroup["group"] != taskgroup["id"]:
|
|
1079
|
+
lines.append(f"group = {_toml_string(taskgroup['group'])}")
|
|
1080
|
+
if taskgroup["verify"]:
|
|
1081
|
+
lines.append(f"verify = {_toml_string(taskgroup['verify'])}")
|
|
1082
|
+
lines.append(f"verify_kind = {_toml_string(taskgroup['verify_kind'])}")
|
|
1083
|
+
if taskgroup["forbidden_paths"]:
|
|
1084
|
+
lines.append(
|
|
1085
|
+
f"forbidden_paths = {_array_literal(taskgroup['forbidden_paths'])}"
|
|
1086
|
+
)
|
|
1087
|
+
if taskgroup["result_schema"] != RESULT_SCHEMA_DEFAULT:
|
|
1088
|
+
lines.append(f"result_schema = {_toml_string(taskgroup['result_schema'])}")
|
|
1089
|
+
if taskgroup["probe"] is not None:
|
|
1090
|
+
lines.append(f"probe = {_toml_string(taskgroup['probe'])}")
|
|
1091
|
+
if taskgroup["probe_exit"] is not None:
|
|
1092
|
+
lines.append(f"probe_exit = {taskgroup['probe_exit']}")
|
|
1093
|
+
return "\n".join(lines) + "\n"
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _render_value(key: str, value) -> str:
|
|
1097
|
+
if key in ("aliases", "forbidden_paths"):
|
|
1098
|
+
return _array_literal(value)
|
|
1099
|
+
if key == "probe_exit":
|
|
1100
|
+
return str(value)
|
|
1101
|
+
return _toml_string(value)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _block_kind(line: str) -> Optional[str]:
|
|
1105
|
+
if _TASKGROUP_HEADER_RE.match(line):
|
|
1106
|
+
return "taskgroup"
|
|
1107
|
+
if _PROJECT_HEADER_RE.match(line):
|
|
1108
|
+
return "project"
|
|
1109
|
+
return None
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _block_entry(lines, start: int, end: int) -> Optional[dict]:
|
|
1113
|
+
text = "".join(lines[start:end])
|
|
1114
|
+
try:
|
|
1115
|
+
data = tomllib.loads(text)
|
|
1116
|
+
except tomllib.TOMLDecodeError:
|
|
1117
|
+
return None
|
|
1118
|
+
for table in ("taskgroup", "project"):
|
|
1119
|
+
entries = data.get(table, []) or []
|
|
1120
|
+
if entries and isinstance(entries[0], dict):
|
|
1121
|
+
return entries[0]
|
|
1122
|
+
return None
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _block_spans(lines):
|
|
1126
|
+
"""Every ``[[project]]`` / ``[[taskgroup]]`` block, in file order.
|
|
1127
|
+
|
|
1128
|
+
Each span carries its kind, its parsed ``id`` and the parsed entry. Both
|
|
1129
|
+
kinds live in one list so an id can be located without relying on the
|
|
1130
|
+
per-table ordering tomllib loses.
|
|
1131
|
+
"""
|
|
1132
|
+
spans = []
|
|
1133
|
+
for index, line in enumerate(lines):
|
|
1134
|
+
kind = _block_kind(line)
|
|
1135
|
+
if kind is None:
|
|
1136
|
+
continue
|
|
1137
|
+
end = len(lines)
|
|
1138
|
+
for probe in range(index + 1, len(lines)):
|
|
1139
|
+
if _TABLE_HEADER_RE.match(lines[probe]):
|
|
1140
|
+
end = probe
|
|
1141
|
+
break
|
|
1142
|
+
entry = _block_entry(lines, index, end)
|
|
1143
|
+
entry_id = entry.get("id") if isinstance(entry, dict) else None
|
|
1144
|
+
spans.append(
|
|
1145
|
+
{
|
|
1146
|
+
"start": index,
|
|
1147
|
+
"end": end,
|
|
1148
|
+
"kind": kind,
|
|
1149
|
+
"id": entry_id,
|
|
1150
|
+
"data": entry if isinstance(entry, dict) else {},
|
|
1151
|
+
}
|
|
1152
|
+
)
|
|
1153
|
+
return spans
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _find_block(spans, entry_id: str):
|
|
1157
|
+
"""Locate a block by id, preferring a ``[[taskgroup]]`` over a project."""
|
|
1158
|
+
for span in spans:
|
|
1159
|
+
if span["kind"] == "taskgroup" and span["id"] == entry_id:
|
|
1160
|
+
return span
|
|
1161
|
+
for span in spans:
|
|
1162
|
+
if span["kind"] == "project" and span["id"] == entry_id:
|
|
1163
|
+
return span
|
|
1164
|
+
return None
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _parsed_data(text: str) -> dict:
|
|
1168
|
+
try:
|
|
1169
|
+
data = tomllib.loads(text)
|
|
1170
|
+
except tomllib.TOMLDecodeError as exc:
|
|
1171
|
+
raise RegistryError(f"invalid TOML: {exc}")
|
|
1172
|
+
for table in ("project", "taskgroup"):
|
|
1173
|
+
entries = data.get(table, [])
|
|
1174
|
+
if not isinstance(entries, list):
|
|
1175
|
+
raise RegistryError(f"'{table}' must be an array of tables")
|
|
1176
|
+
return data
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _raw_block_count(data: dict) -> int:
|
|
1180
|
+
return len(data.get("project", []) or []) + len(data.get("taskgroup", []) or [])
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _raw_block_ids(data: dict) -> list:
|
|
1184
|
+
ids = []
|
|
1185
|
+
for table in ("project", "taskgroup"):
|
|
1186
|
+
for entry in data.get(table, []) or []:
|
|
1187
|
+
if isinstance(entry, dict):
|
|
1188
|
+
ids.append(entry.get("id"))
|
|
1189
|
+
return ids
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _set_key(lines, start: int, end: int, key: str, rendered, *, remove=False):
|
|
1193
|
+
"""Replace/add/remove one top-level key within a block.
|
|
1194
|
+
|
|
1195
|
+
Returns the updated block end. Only lines in the target block are touched.
|
|
1196
|
+
"""
|
|
1197
|
+
key_index = None
|
|
1198
|
+
insertion_at = start + 1
|
|
1199
|
+
for index in range(start + 1, end):
|
|
1200
|
+
line = lines[index]
|
|
1201
|
+
if _TABLE_HEADER_RE.match(line):
|
|
1202
|
+
break
|
|
1203
|
+
if _KEY_RE.match(line):
|
|
1204
|
+
insertion_at = index + 1
|
|
1205
|
+
stripped = line.lstrip()
|
|
1206
|
+
if stripped.split("=", 1)[0].strip() == key:
|
|
1207
|
+
key_index = index
|
|
1208
|
+
break
|
|
1209
|
+
|
|
1210
|
+
if remove:
|
|
1211
|
+
if key_index is None:
|
|
1212
|
+
return end
|
|
1213
|
+
del lines[key_index]
|
|
1214
|
+
return end - 1
|
|
1215
|
+
|
|
1216
|
+
if key_index is not None:
|
|
1217
|
+
line = lines[key_index]
|
|
1218
|
+
newline = "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else "")
|
|
1219
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
1220
|
+
lines[key_index] = f"{indent}{key} = {rendered}{newline}"
|
|
1221
|
+
return end
|
|
1222
|
+
|
|
1223
|
+
newline = "\r\n" if any(line.endswith("\r\n") for line in lines) else "\n"
|
|
1224
|
+
lines.insert(insertion_at, f"{key} = {rendered}{newline}")
|
|
1225
|
+
return end + 1
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _strip_owned_comments(lines, start: int) -> int:
|
|
1229
|
+
"""Move a block start upwards over comments not separated by a blank line."""
|
|
1230
|
+
while start > 0:
|
|
1231
|
+
line = lines[start - 1]
|
|
1232
|
+
if line.strip().startswith("#"):
|
|
1233
|
+
start -= 1
|
|
1234
|
+
continue
|
|
1235
|
+
break
|
|
1236
|
+
return start
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _block_content_end(lines, start: int, end: int) -> int:
|
|
1240
|
+
"""Index just past the last key line of a block.
|
|
1241
|
+
|
|
1242
|
+
Lines after that are left alone. A comment run touching the *next* block's
|
|
1243
|
+
header belongs to that block; a blank-separated run is its upstream note.
|
|
1244
|
+
Deleting this block must not take either with it.
|
|
1245
|
+
"""
|
|
1246
|
+
last = start
|
|
1247
|
+
for index in range(start + 1, end):
|
|
1248
|
+
line = lines[index]
|
|
1249
|
+
if _TABLE_HEADER_RE.match(line):
|
|
1250
|
+
break
|
|
1251
|
+
if _KEY_RE.match(line):
|
|
1252
|
+
last = index
|
|
1253
|
+
return last + 1
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _conflict(path: str, data: bytes) -> RegistryConflictError:
|
|
1257
|
+
try:
|
|
1258
|
+
lanes = [taskgroup_record(tg) for tg in load(path).taskgroups]
|
|
1259
|
+
except RegistryError:
|
|
1260
|
+
lanes = []
|
|
1261
|
+
return RegistryConflictError(
|
|
1262
|
+
hashlib.sha256(data).hexdigest(),
|
|
1263
|
+
data.decode("utf-8", errors="replace"),
|
|
1264
|
+
lanes,
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _check_expected(path: str, data: bytes, expected_hash: str):
|
|
1269
|
+
if not isinstance(expected_hash, str) or not expected_hash:
|
|
1270
|
+
raise RegistryError("expected_hash is required")
|
|
1271
|
+
current_hash = hashlib.sha256(data).hexdigest()
|
|
1272
|
+
if expected_hash != current_hash:
|
|
1273
|
+
raise _conflict(path, data)
|
|
1274
|
+
return current_hash
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _atomic_replace(path: str, text: str, *, expected_blocks: int, expected_id=None):
|
|
1278
|
+
# Parse before creating a temp file so obvious failures never touch disk.
|
|
1279
|
+
data = _parsed_data(text)
|
|
1280
|
+
ids = _raw_block_ids(data)
|
|
1281
|
+
if len(ids) != expected_blocks:
|
|
1282
|
+
raise RegistryError(
|
|
1283
|
+
f"registry block count changed: expected {expected_blocks}, got {len(ids)}"
|
|
1284
|
+
)
|
|
1285
|
+
if expected_id is not None and expected_id not in ids:
|
|
1286
|
+
raise RegistryError(f"block id missing after write: {expected_id}")
|
|
1287
|
+
|
|
1288
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
1289
|
+
os.makedirs(directory, exist_ok=True)
|
|
1290
|
+
file_descriptor, temp_path = tempfile.mkstemp(
|
|
1291
|
+
prefix=".projects.", suffix=".tmp", dir=directory
|
|
1292
|
+
)
|
|
1293
|
+
os.close(file_descriptor)
|
|
1294
|
+
try:
|
|
1295
|
+
with open(temp_path, "wb") as handle:
|
|
1296
|
+
handle.write(text.encode("utf-8"))
|
|
1297
|
+
handle.flush()
|
|
1298
|
+
os.fsync(handle.fileno())
|
|
1299
|
+
|
|
1300
|
+
# `load` is the same validator used by every read path, so a write can
|
|
1301
|
+
# never leave a file that the rest of taskproof refuses to parse.
|
|
1302
|
+
load(temp_path)
|
|
1303
|
+
|
|
1304
|
+
try:
|
|
1305
|
+
os.chmod(temp_path, os.stat(path).st_mode & 0o7777)
|
|
1306
|
+
except OSError:
|
|
1307
|
+
pass
|
|
1308
|
+
os.replace(temp_path, path)
|
|
1309
|
+
finally:
|
|
1310
|
+
if os.path.exists(temp_path):
|
|
1311
|
+
os.unlink(temp_path)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def append_taskgroup(path: str, expected_hash: str, entry: dict):
|
|
1315
|
+
"""Append one ``[[taskgroup]]`` block, preserving every existing byte.
|
|
1316
|
+
|
|
1317
|
+
A taskgroup with no ``project`` stands up a same-named project (the compat
|
|
1318
|
+
rule), so ``taskproof register`` stays a one-block write. The candidate is
|
|
1319
|
+
still validated by ``load`` before it replaces the file.
|
|
1320
|
+
"""
|
|
1321
|
+
with _WRITE_LOCK:
|
|
1322
|
+
data = _read_bytes(path)
|
|
1323
|
+
_check_expected(path, data, expected_hash)
|
|
1324
|
+
text = data.decode("utf-8")
|
|
1325
|
+
parsed = _parsed_data(text)
|
|
1326
|
+
count = _raw_block_count(parsed)
|
|
1327
|
+
normalised = _normalise_taskgroup(entry)
|
|
1328
|
+
if text and not text.endswith("\n"):
|
|
1329
|
+
text += "\n"
|
|
1330
|
+
candidate = text + ("\n" if text else "") + _format_taskgroup_block(normalised)
|
|
1331
|
+
_atomic_replace(
|
|
1332
|
+
path,
|
|
1333
|
+
candidate,
|
|
1334
|
+
expected_blocks=count + 1,
|
|
1335
|
+
expected_id=normalised["id"],
|
|
1336
|
+
)
|
|
1337
|
+
return normalised
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def update_project(path: str, entry_id: str, expected_hash: str, changes: dict):
|
|
1341
|
+
"""Update only the named keys in one block (``[[taskgroup]]`` or ``[[project]]``).
|
|
1342
|
+
|
|
1343
|
+
Surgical: only the lines for the named keys move, so hand-written comments,
|
|
1344
|
+
unknown keys and every other block survive byte-for-byte. The candidate is
|
|
1345
|
+
validated (and the expected hash re-checked) before an atomic replace.
|
|
1346
|
+
"""
|
|
1347
|
+
if not isinstance(entry_id, str) or not entry_id:
|
|
1348
|
+
raise RegistryError("project id is required")
|
|
1349
|
+
everywhere = set(_TASKGROUP_MUTABLE_FIELDS) | set(_PROJECT_MUTABLE_FIELDS)
|
|
1350
|
+
unknown = set(changes) - everywhere
|
|
1351
|
+
if unknown:
|
|
1352
|
+
raise RegistryError(f"unsupported project field(s): {', '.join(sorted(unknown))}")
|
|
1353
|
+
|
|
1354
|
+
with _WRITE_LOCK:
|
|
1355
|
+
data = _read_bytes(path)
|
|
1356
|
+
_check_expected(path, data, expected_hash)
|
|
1357
|
+
text = data.decode("utf-8")
|
|
1358
|
+
lines = text.splitlines(keepends=True)
|
|
1359
|
+
parsed = _parsed_data(text)
|
|
1360
|
+
count = _raw_block_count(parsed)
|
|
1361
|
+
spans = _block_spans(lines)
|
|
1362
|
+
target = _find_block(spans, entry_id)
|
|
1363
|
+
if target is None:
|
|
1364
|
+
raise RegistryNotFoundError(f"project not found: {entry_id}")
|
|
1365
|
+
|
|
1366
|
+
allowed = (
|
|
1367
|
+
_TASKGROUP_MUTABLE_FIELDS
|
|
1368
|
+
if target["kind"] == "taskgroup"
|
|
1369
|
+
else _PROJECT_MUTABLE_FIELDS
|
|
1370
|
+
)
|
|
1371
|
+
unknown = set(changes) - set(allowed)
|
|
1372
|
+
if unknown:
|
|
1373
|
+
raise RegistryError(
|
|
1374
|
+
f"unsupported {target['kind']} field(s): {', '.join(sorted(unknown))}"
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
merged = dict(target["data"])
|
|
1378
|
+
merged.update(changes)
|
|
1379
|
+
if target["kind"] == "taskgroup":
|
|
1380
|
+
normalised = _normalise_taskgroup(merged)
|
|
1381
|
+
else:
|
|
1382
|
+
normalised = _normalise_project(merged)
|
|
1383
|
+
|
|
1384
|
+
start, end = target["start"], target["end"]
|
|
1385
|
+
for key in changes:
|
|
1386
|
+
value = changes[key]
|
|
1387
|
+
if value is None:
|
|
1388
|
+
end = _set_key(lines, start, end, key, None, remove=True)
|
|
1389
|
+
continue
|
|
1390
|
+
end = _set_key(lines, start, end, key, _render_value(key, normalised[key]))
|
|
1391
|
+
|
|
1392
|
+
if (
|
|
1393
|
+
"verify" in changes
|
|
1394
|
+
and changes["verify"] is None
|
|
1395
|
+
and "verify_kind" not in changes
|
|
1396
|
+
):
|
|
1397
|
+
end = _set_key(
|
|
1398
|
+
lines, start, end, "verify_kind", _render_value("verify_kind", "none")
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
candidate = "".join(lines)
|
|
1402
|
+
_atomic_replace(
|
|
1403
|
+
path,
|
|
1404
|
+
candidate,
|
|
1405
|
+
expected_blocks=count,
|
|
1406
|
+
expected_id=entry_id,
|
|
1407
|
+
)
|
|
1408
|
+
return normalised
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def delete_project(path: str, entry_id: str, expected_hash: str):
|
|
1412
|
+
"""Delete one block (``[[taskgroup]]`` or ``[[project]]``) and its comments."""
|
|
1413
|
+
with _WRITE_LOCK:
|
|
1414
|
+
data = _read_bytes(path)
|
|
1415
|
+
_check_expected(path, data, expected_hash)
|
|
1416
|
+
text = data.decode("utf-8")
|
|
1417
|
+
lines = text.splitlines(keepends=True)
|
|
1418
|
+
parsed = _parsed_data(text)
|
|
1419
|
+
count = _raw_block_count(parsed)
|
|
1420
|
+
spans = _block_spans(lines)
|
|
1421
|
+
target = _find_block(spans, entry_id)
|
|
1422
|
+
if target is None:
|
|
1423
|
+
raise RegistryNotFoundError(f"project not found: {entry_id}")
|
|
1424
|
+
|
|
1425
|
+
start, end = target["start"], target["end"]
|
|
1426
|
+
owned_start = _strip_owned_comments(lines, start)
|
|
1427
|
+
body_end = _block_content_end(lines, start, end)
|
|
1428
|
+
del lines[owned_start:body_end]
|
|
1429
|
+
candidate = "".join(lines)
|
|
1430
|
+
_atomic_replace(
|
|
1431
|
+
path,
|
|
1432
|
+
candidate,
|
|
1433
|
+
expected_blocks=count - 1,
|
|
1434
|
+
)
|
|
1435
|
+
return entry_id
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
# ---------------------------------------------------------------------------
|
|
1439
|
+
# `[defaults]` writer (card 42) — surgical, comment-preserving.
|
|
1440
|
+
# ---------------------------------------------------------------------------
|
|
1441
|
+
#
|
|
1442
|
+
# `taskproof config --concurrency N` / `--timeout N` must NOT round-trip the
|
|
1443
|
+
# registry through a TOML renderer: that drops trailing comments such as
|
|
1444
|
+
# `concurrency = 3 # global cap on simultaneous tasks`
|
|
1445
|
+
# Instead we locate the one assignment for the key inside `[defaults]` and swap
|
|
1446
|
+
# only its numeric value, keeping the key, the `=` spacing, the trailing comment
|
|
1447
|
+
# and the line ending byte-for-byte. Adding a key the table lacks inserts a
|
|
1448
|
+
# single line; everything else — blank lines, `[[project]]` / `[[taskgroup]]`
|
|
1449
|
+
# blocks, every comment and every other key — is carried through verbatim.
|
|
1450
|
+
#
|
|
1451
|
+
# The final write rides the same `tempfile.mkstemp` + `os.replace` path as the
|
|
1452
|
+
# block CRUD (`_atomic_replace`), so a crash mid-write can never truncate the
|
|
1453
|
+
# registry and a candidate the loader rejects never reaches disk.
|
|
1454
|
+
|
|
1455
|
+
_DEFAULTS_HEADER_RE = re.compile(r"^\s*\[defaults\]\s*(?:#.*)?(?:\r?\n)?$")
|
|
1456
|
+
_DEFAULTS_KEYS = ("concurrency", "timeout")
|
|
1457
|
+
_ASSIGN_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<key>[A-Za-z0-9_-]+)\s*=")
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _defaults_span(lines):
|
|
1461
|
+
"""``(start, end)`` line indices of the `[defaults]` table, or ``None``.
|
|
1462
|
+
|
|
1463
|
+
``end`` is the next table header (any `[...]` / `[[...]]`) or EOF, so the
|
|
1464
|
+
scan never strays into a `[[project]]` / `[[taskgroup]]` block.
|
|
1465
|
+
"""
|
|
1466
|
+
for index, line in enumerate(lines):
|
|
1467
|
+
if _DEFAULTS_HEADER_RE.match(line):
|
|
1468
|
+
end = len(lines)
|
|
1469
|
+
for probe in range(index + 1, len(lines)):
|
|
1470
|
+
if _TABLE_HEADER_RE.match(lines[probe]):
|
|
1471
|
+
end = probe
|
|
1472
|
+
break
|
|
1473
|
+
return index, end
|
|
1474
|
+
return None
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _swap_default_value(line: str, value: int) -> str:
|
|
1478
|
+
"""Return ``line`` with only the numeric value swapped for ``value``.
|
|
1479
|
+
|
|
1480
|
+
Keeps the key, the ``=`` spacing, any trailing comment and the line ending.
|
|
1481
|
+
"""
|
|
1482
|
+
body, eol = line, ""
|
|
1483
|
+
if body.endswith("\r\n"):
|
|
1484
|
+
body, eol = body[:-2], "\r\n"
|
|
1485
|
+
elif body.endswith("\n"):
|
|
1486
|
+
body, eol = body[:-1], "\n"
|
|
1487
|
+
head, sep, tail = body.partition("=")
|
|
1488
|
+
comment = ""
|
|
1489
|
+
if "#" in tail:
|
|
1490
|
+
tail, _, comment = tail.partition("#")
|
|
1491
|
+
comment = "#" + comment
|
|
1492
|
+
lead = tail[: len(tail) - len(tail.lstrip())]
|
|
1493
|
+
gap = tail[len(tail.rstrip()):]
|
|
1494
|
+
return f"{head}{sep}{lead}{value}{gap}{comment}{eol}"
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _upsert_default(lines, key: str, value: int) -> None:
|
|
1498
|
+
"""Replace, or else add, ``key``'s assignment inside `[defaults]`."""
|
|
1499
|
+
span = _defaults_span(lines)
|
|
1500
|
+
if span is None:
|
|
1501
|
+
newline = "\r\n" if any(line.endswith("\r\n") for line in lines) else "\n"
|
|
1502
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
1503
|
+
lines[-1] += newline
|
|
1504
|
+
if lines and lines[-1].strip():
|
|
1505
|
+
lines.append(newline)
|
|
1506
|
+
lines.append(f"[defaults]{newline}")
|
|
1507
|
+
lines.append(f"{key} = {value}{newline}")
|
|
1508
|
+
return
|
|
1509
|
+
|
|
1510
|
+
start, end = span
|
|
1511
|
+
insert_at = start + 1
|
|
1512
|
+
indent = ""
|
|
1513
|
+
for index in range(start + 1, end):
|
|
1514
|
+
match = _ASSIGN_RE.match(lines[index])
|
|
1515
|
+
if match is None:
|
|
1516
|
+
continue
|
|
1517
|
+
if match.group("key") == key:
|
|
1518
|
+
lines[index] = _swap_default_value(lines[index], value)
|
|
1519
|
+
return
|
|
1520
|
+
insert_at = index + 1
|
|
1521
|
+
indent = match.group("indent")
|
|
1522
|
+
newline = "\r\n" if any(line.endswith("\r\n") for line in lines) else "\n"
|
|
1523
|
+
lines.insert(insert_at, f"{indent}{key} = {value}{newline}")
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def set_default(path: str, key: str, value, *, expected_hash: Optional[str] = None) -> int:
|
|
1527
|
+
"""Set one `[defaults]` key in place, preserving every other byte.
|
|
1528
|
+
|
|
1529
|
+
Returns the integer written. Validates the key and the value here (the CLI
|
|
1530
|
+
rejects out-of-range values first, so the error a caller actually sees names
|
|
1531
|
+
the usable range). Creates the sample registry when the file is missing.
|
|
1532
|
+
"""
|
|
1533
|
+
if key not in _DEFAULTS_KEYS:
|
|
1534
|
+
raise RegistryError(f"unknown default key: {key}")
|
|
1535
|
+
try:
|
|
1536
|
+
number = int(value)
|
|
1537
|
+
except (TypeError, ValueError):
|
|
1538
|
+
raise RegistryError(f"{key} must be an integer")
|
|
1539
|
+
if number < 1:
|
|
1540
|
+
raise RegistryError(f"{key} must be >= 1")
|
|
1541
|
+
with _WRITE_LOCK:
|
|
1542
|
+
if os.path.exists(path):
|
|
1543
|
+
data = _read_bytes(path)
|
|
1544
|
+
if expected_hash is not None:
|
|
1545
|
+
_check_expected(path, data, expected_hash)
|
|
1546
|
+
text = data.decode("utf-8")
|
|
1547
|
+
parsed = _parsed_data(text)
|
|
1548
|
+
count = _raw_block_count(parsed)
|
|
1549
|
+
else:
|
|
1550
|
+
text = SAMPLE_REGISTRY
|
|
1551
|
+
count = 0
|
|
1552
|
+
lines = text.splitlines(keepends=True)
|
|
1553
|
+
_upsert_default(lines, key, number)
|
|
1554
|
+
candidate = "".join(lines)
|
|
1555
|
+
_atomic_replace(path, candidate, expected_blocks=count)
|
|
1556
|
+
return number
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
#: What a brand-new workspace starts from. Deliberately holds no blocks.
|
|
1560
|
+
#:
|
|
1561
|
+
#: An example entry used to be seeded here, pointing at `/absolute/path/to/my-app`.
|
|
1562
|
+
#: Its path was a placeholder, so `taskproof projects` -- and the dashboard --
|
|
1563
|
+
#: listed a repository that did not exist until someone deleted the block. A
|
|
1564
|
+
#: fresh registry that is honestly empty is better than one that lies.
|
|
1565
|
+
#: The format lives in `examples/projects.example.toml` and `docs/REGISTRY.md`.
|
|
1566
|
+
SAMPLE_REGISTRY = """\
|
|
1567
|
+
# taskproof registry
|
|
1568
|
+
#
|
|
1569
|
+
# Two layers: a [[project]] names a repository, and one or more [[taskgroup]]
|
|
1570
|
+
# lanes hang off it with their own acceptance command. `taskproof register`
|
|
1571
|
+
# writes a [[taskgroup]]; a legacy [[project]] block that carries verify /
|
|
1572
|
+
# verify_kind / forbidden_paths / result_schema is still read as a same-named
|
|
1573
|
+
# project + lane (zero migration).
|
|
1574
|
+
#
|
|
1575
|
+
# No projects yet: `taskproof register <path>` appends one.
|
|
1576
|
+
# A commented, complete example: examples/projects.example.toml
|
|
1577
|
+
# Field reference: docs/REGISTRY.md
|
|
1578
|
+
|
|
1579
|
+
[defaults]
|
|
1580
|
+
# Global cap on simultaneous tasks. Left unset, taskproof auto-detects it from
|
|
1581
|
+
# this machine (clamp(2, cores // 4, 6); 2 when RAM < 8 GB). Uncomment and edit
|
|
1582
|
+
# to pin an explicit value, or use `taskproof config --concurrency N`.
|
|
1583
|
+
# concurrency = 3
|
|
1584
|
+
timeout = 1800 # seconds before a task is judged stuck
|
|
1585
|
+
"""
|