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.
Files changed (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. 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
+ """