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/cli.py ADDED
@@ -0,0 +1,1400 @@
1
+ """Command line interface.
2
+
3
+ Exit codes are contractual — see errors.py and docs/DESIGN.md.
4
+ Human-readable output follows the locale; `--json` is for machines.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sqlite3
11
+ import sys
12
+ import time
13
+ import webbrowser
14
+ from datetime import datetime
15
+
16
+ from . import (
17
+ __version__,
18
+ concurrency,
19
+ dispatch,
20
+ errors,
21
+ ledger,
22
+ registry,
23
+ storage,
24
+ )
25
+ from .errors import RegistryError, UsageError
26
+ from .models import STATUS_DONE, TERMINAL_STATUSES
27
+
28
+ DEFAULT_WORKSPACE = os.path.join(os.path.expanduser("~"), ".taskproof")
29
+
30
+
31
+ class _UsageErrorParser(argparse.ArgumentParser):
32
+ """Route usage errors to EXIT_USAGE (64) instead of argparse's default 2.
33
+
34
+ The exit-code table in errors.py is a contract for callers. argparse exits 2
35
+ on a bad invocation, which collides with EXIT_REGISTRY (also 2) — leaving a
36
+ caller unable to tell "you typed the command wrong" apart from "that project
37
+ is not registered". Overriding here restores the documented distinction.
38
+ """
39
+
40
+ def error(self, message):
41
+ self.print_usage(sys.stderr)
42
+ print(f"{self.prog}: {message}", file=sys.stderr)
43
+ sys.exit(errors.EXIT_USAGE)
44
+
45
+
46
+ def build_parser() -> argparse.ArgumentParser:
47
+ # Imported inside the function (not at module scope) so cli does not depend
48
+ # on api.server at import time; api.server pulls in package modules that cli
49
+ # also imports, and a module-level import risks a cycle. 8787 lives in one
50
+ # place: api/server.py:DEFAULT_PORT.
51
+ from .api.server import DEFAULT_PORT
52
+
53
+ parser = _UsageErrorParser(
54
+ prog="taskproof",
55
+ description="A conveyor belt for AI coding agents: dispatch, independently verify, record.",
56
+ )
57
+ parser.add_argument("--version", action="version", version=f"taskproof {__version__}")
58
+ parser.add_argument("--workspace", default=os.environ.get("TASKPROOF_HOME", DEFAULT_WORKSPACE),
59
+ help="state directory (default: %(default)s)")
60
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
61
+ parser.add_argument("-q", "--quiet", action="store_true")
62
+
63
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
64
+
65
+ sub.add_parser("init", help="initialise a workspace (database + sample registry)")
66
+
67
+ p = sub.add_parser("register", help="probe a repository and register it")
68
+ p.add_argument("path")
69
+ p.add_argument("--dry-run", action="store_true", help="probe only, write nothing")
70
+ p.add_argument("--id")
71
+ p.add_argument("--project", help="attach this lane to an existing project id")
72
+ # Deprecated and ignored: a new taskgroup's lock defaults to its own id,
73
+ # but the flag stays so older scripts keep running.
74
+ p.add_argument("--group", help=argparse.SUPPRESS)
75
+
76
+ sub.add_parser("projects", help="list registered projects (with their lanes)")
77
+
78
+ sub.add_parser("taskgroups", help="list registered taskgroups (lanes)")
79
+
80
+ sub.add_parser(
81
+ "workspaces", help="list lanes that declare a long-lived workspace"
82
+ )
83
+
84
+ p = sub.add_parser(
85
+ "workspace-rm",
86
+ help="delete a lane's long-lived workspace (prints evidence first)",
87
+ )
88
+ p.add_argument("lane", help="taskgroup id, alias, or path")
89
+ p.add_argument(
90
+ "--force",
91
+ action="store_true",
92
+ help="delete even with uncommitted changes or unmerged commits",
93
+ )
94
+
95
+ p = sub.add_parser("run", help="dispatch one task (primary command)")
96
+ p.add_argument("project", help="project id, alias, or path")
97
+ p.add_argument("brief", help="what the agent should do")
98
+ p.add_argument("--adapter", default="codex", help="codex | claude | gemini | opencode | custom:<cmd>")
99
+ p.add_argument("--model")
100
+ p.add_argument("--reasoning", choices=["none", "high"])
101
+ p.add_argument("--read-only", action="store_true", help="read-only sandbox; acceptance is skipped")
102
+ p.add_argument(
103
+ "--worktree",
104
+ action="store_true",
105
+ help=(
106
+ "run in a fresh git worktree beside the repo; removed after the "
107
+ "run unless it has changes, in which case it is kept at "
108
+ "<repo>-wt-<task-id>, printed and recorded (rm <id> deletes it)"
109
+ ),
110
+ )
111
+ p.add_argument("--no-verify", action="store_true", help="skip acceptance (recorded as SKIPPED, not passed)")
112
+ p.add_argument("--timeout", type=int, help="override the registry timeout (seconds)")
113
+ p.add_argument(
114
+ "--idle-timeout",
115
+ type=int,
116
+ metavar="N",
117
+ help=(
118
+ "stop a card whose log AND working tree are both quiet for N "
119
+ "seconds (0 = off, the default)"
120
+ ),
121
+ )
122
+ p.add_argument(
123
+ "--cap",
124
+ type=int,
125
+ metavar="N",
126
+ help=(
127
+ "one-off global concurrency cap for THIS dispatch only (not written "
128
+ "to the registry)"
129
+ ),
130
+ )
131
+ p = sub.add_parser("tasks", help="list tasks")
132
+ p.add_argument("--status")
133
+ p.add_argument("--project", help="project id, alias, or path (overrides cwd)")
134
+ p.add_argument("--all", dest="all_projects", action="store_true",
135
+ help="every project, ignoring the cwd scope (overrides cwd)")
136
+ p.add_argument("--id", dest="ids", metavar="ID[,ID...]",
137
+ help="only these task ids (comma-separated)")
138
+ p.add_argument("--lane", metavar="TASKGROUP_ID",
139
+ help="only tasks dispatched onto this lane (group_name)")
140
+ p.add_argument("--limit", type=int, default=20)
141
+
142
+ p = sub.add_parser("show", help="show one task")
143
+ p.add_argument("task_id")
144
+
145
+ p = sub.add_parser("wait", help="block until a task reaches a terminal state")
146
+ p.add_argument("task_id")
147
+ p.add_argument("--timeout", type=float, default=600.0,
148
+ help="give up after this many seconds (default: %(default)s)")
149
+ p.add_argument("--poll", type=float, default=2.0,
150
+ help="seconds between status reads (default: %(default)s)")
151
+
152
+ p = sub.add_parser("log", help="print a task's event stream")
153
+ p.add_argument("task_id")
154
+ p.add_argument("--follow", "-f", action="store_true")
155
+
156
+ p = sub.add_parser("verify", help="re-run acceptance for a task")
157
+ p.add_argument("task_id")
158
+
159
+ p = sub.add_parser(
160
+ "accept",
161
+ help=(
162
+ "clear a blocked or failed task: done if acceptance passed (a failed "
163
+ "card needs --note), else failed"
164
+ ),
165
+ )
166
+ p.add_argument("task_id")
167
+ p.add_argument(
168
+ "--note",
169
+ help=(
170
+ "why a failed card is being cleared; required for failed "
171
+ "(人工收尾必须留说明)"
172
+ ),
173
+ )
174
+
175
+ p = sub.add_parser(
176
+ "rerun",
177
+ help="start a fresh copy of a terminal task (same brief and run flags)",
178
+ )
179
+ p.add_argument("task_id")
180
+
181
+ p = sub.add_parser(
182
+ "cancel", help="stop a task (kill its process tree)"
183
+ )
184
+ p.add_argument("task_id")
185
+
186
+ p = sub.add_parser("rm", help="delete a terminal task's record and events")
187
+ p.add_argument("task_id")
188
+
189
+ p = sub.add_parser(
190
+ "board",
191
+ help="dashboard: serve the desktop web build over the local REST API",
192
+ )
193
+ p.add_argument("--open", action="store_true",
194
+ help="serve the board and open it in a browser (the default)")
195
+ p.add_argument("--serve", type=int, nargs="?", const=DEFAULT_PORT, metavar="PORT",
196
+ help="serve the board without opening a browser (blocks)")
197
+
198
+ p = sub.add_parser("api", help="serve the local read-only REST API (consumed by the desktop frontend)")
199
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
200
+
201
+ p = sub.add_parser(
202
+ "config",
203
+ help="show or set [defaults] (concurrency / timeout) without losing comments",
204
+ )
205
+ p.add_argument("--show", action="store_true", help="print the effective cap/timeout and where each came from")
206
+ p.add_argument("--concurrency", metavar="N", help="pin the global concurrency cap in [defaults] concurrency")
207
+ p.add_argument("--timeout", metavar="N", help="set [defaults] timeout (seconds)")
208
+
209
+ sub.add_parser("doctor", help="environment self-check")
210
+ sub.add_parser("gc", help="rotate the audit stream and prune old state")
211
+
212
+ return parser
213
+
214
+
215
+ def main(argv=None) -> int:
216
+ parser = build_parser()
217
+ args = parser.parse_args(argv)
218
+ if not args.command:
219
+ parser.print_help()
220
+ return 0
221
+
222
+ try:
223
+ handler = COMMANDS.get(args.command)
224
+ if handler is None:
225
+ parser.error(f"unknown command: {args.command}")
226
+ return handler(args) or 0
227
+ except errors.TaskproofError as exc:
228
+ print(f"taskproof: {exc}", file=sys.stderr)
229
+ if getattr(exc, "hint", None):
230
+ print(f" hint: {exc.hint}", file=sys.stderr)
231
+ return exc.exit_code
232
+ except KeyboardInterrupt:
233
+ print("taskproof: interrupted", file=sys.stderr)
234
+ return 130
235
+
236
+
237
+ # --------------------------------------------------------------------------
238
+ # Handlers are thin: argument parsing and output formatting only. All behaviour
239
+ # lives in the modules above, so it can be tested without the CLI.
240
+ # --------------------------------------------------------------------------
241
+
242
+
243
+ def cmd_init(args):
244
+ dispatch.prepare_workspace(args.workspace)
245
+ registry_path = registry.workspace_registry_path(args.workspace)
246
+ payload = {
247
+ "workspace": args.workspace,
248
+ "registry": registry_path,
249
+ "projects": 0,
250
+ "next": [
251
+ "taskproof register <path>",
252
+ 'taskproof run <project> "<task>"',
253
+ "taskproof board --open",
254
+ ],
255
+ }
256
+ human = (
257
+ f"initialised workspace: {args.workspace}\n"
258
+ f" registry: {registry_path} (no projects yet)\n"
259
+ "next:\n"
260
+ " taskproof register <path> # probe and register a repository\n"
261
+ ' taskproof run <project> "<task>" # dispatch, verify, record\n'
262
+ " taskproof board --open # live dashboard (serves + opens a browser)\n"
263
+ " taskproof board --serve # serve the board without a browser\n"
264
+ " registry format # examples/projects.example.toml"
265
+ )
266
+ emit(args, payload, human)
267
+ return 0
268
+
269
+
270
+ def _toml_string(value) -> str:
271
+ """A minimal TOML basic string (paths/ids are simple, but escape anyway)."""
272
+ text = str(value)
273
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
274
+
275
+
276
+ def _append_taskgroup_block(reg_path, *, taskgroup_id, path, project,
277
+ command, kind, probe, probe_exit):
278
+ """Append one `[[taskgroup]]` block through the surgical writer.
279
+
280
+ Every existing byte is preserved and the candidate is validated by `load`
281
+ before it replaces the file. A missing `project` means the lane stands up a
282
+ same-named project (the zero-migration compat rule).
283
+ """
284
+ entry = {
285
+ "id": taskgroup_id,
286
+ "project": project,
287
+ "path": path,
288
+ "verify": command,
289
+ "verify_kind": kind,
290
+ "probe": probe,
291
+ "probe_exit": probe_exit,
292
+ }
293
+ registry.append_taskgroup(reg_path, registry.registry_hash(reg_path), entry)
294
+
295
+
296
+ def _register_human(payload) -> str:
297
+ lines = [
298
+ f"project: {payload['project']} ({payload['path']})",
299
+ f"taskgroup: {payload['id']} (lock: {payload['group']})",
300
+ ]
301
+ if payload["verify"]:
302
+ lines.append(f"verify: {payload['verify']}")
303
+ lines.append(f"probe: {payload['probe']}")
304
+ else:
305
+ lines.append("verify: (none inferred — set one by hand)")
306
+ lines.append("hint: you may add an AGENTS.md to describe the repo to agents")
307
+ if payload["dry_run"]:
308
+ lines.append("(dry run: nothing written)")
309
+ return "\n".join(lines)
310
+
311
+
312
+ def cmd_register(args):
313
+ path = os.path.abspath(os.path.expanduser(args.path))
314
+ if not os.path.exists(path):
315
+ raise UsageError(f"path does not exist: {args.path}")
316
+ if not os.path.isdir(path):
317
+ raise UsageError(f"not a directory: {args.path}")
318
+
319
+ draft = registry.probe_repository(path)
320
+ taskgroup_id = args.id or draft["id"]
321
+ project = args.project or None
322
+ command = draft["verify"]
323
+ kind = draft["verify_kind"]
324
+ probe = draft["probe"]
325
+ probe_exit = draft["probe_exit"]
326
+
327
+ payload = {
328
+ "id": taskgroup_id,
329
+ "project": project or taskgroup_id,
330
+ "path": path,
331
+ # The lock defaults to the taskgroup id. `group` is kept in the payload
332
+ # so older callers that read it keep working.
333
+ "group": taskgroup_id,
334
+ "verify": command,
335
+ "verify_kind": kind,
336
+ "probe": probe,
337
+ "probe_exit": probe_exit,
338
+ "dry_run": bool(args.dry_run),
339
+ "registered": False,
340
+ }
341
+
342
+ if not args.dry_run:
343
+ dispatch.prepare_workspace(args.workspace)
344
+ reg_path = registry.workspace_registry_path(args.workspace)
345
+ reg = registry.load(reg_path)
346
+ if any(tg.id == taskgroup_id for tg in reg.taskgroups):
347
+ raise RegistryError(
348
+ f"taskgroup id already registered: {taskgroup_id}",
349
+ hint=f"edit {reg_path}, or choose a different --id",
350
+ )
351
+ _append_taskgroup_block(
352
+ reg_path,
353
+ taskgroup_id=taskgroup_id,
354
+ path=path,
355
+ project=project,
356
+ command=command,
357
+ kind=kind,
358
+ probe=probe,
359
+ probe_exit=probe_exit,
360
+ )
361
+ payload["registered"] = True
362
+ payload["registry"] = reg_path
363
+
364
+ emit(args, payload, _register_human(payload))
365
+ return 0
366
+
367
+
368
+ def _read_probe_flags(reg_path) -> dict:
369
+ """{id: {probe, probe_exit}} for entries that carry probe metadata.
370
+
371
+ Delegates to `registry.read_probe_flags`, which reads both `[[project]]`
372
+ (legacy) and `[[taskgroup]]` blocks from the raw TOML (`registry.load` drops
373
+ these human/API-only keys). Read-only.
374
+ """
375
+ return registry.read_probe_flags(reg_path)
376
+
377
+
378
+ def _lane_overview(reg, stats, probes):
379
+ """Project rows: one per project, each carrying its lanes and totals."""
380
+ projects = []
381
+ known_lane_ids = set()
382
+ for project in reg.projects:
383
+ lanes = reg.lanes_for(project.id)
384
+ lane_records = []
385
+ totals = {"tasks": 0, "in_progress": 0, "failed": 0}
386
+ last_activity = None
387
+ for lane in lanes:
388
+ known_lane_ids.add(lane.id)
389
+ stat = stats.get(lane.id) or {}
390
+ flags = probes.get(lane.id) or {}
391
+ probe = flags.get("probe")
392
+ if probe is None and lane.verify_kind == "none":
393
+ probe = "none" # no acceptance command -> the task is SKIPPED
394
+ lane_records.append(
395
+ {
396
+ "id": lane.id,
397
+ "path": lane.path,
398
+ "group": lane.group,
399
+ "aliases": list(lane.aliases),
400
+ "verify": lane.verify,
401
+ "verify_kind": lane.verify_kind,
402
+ "probe": probe,
403
+ "tasks": int(stat.get("total", 0)),
404
+ "in_progress": int(stat.get("in_progress", 0)),
405
+ "failed": int(stat.get("failed", 0)),
406
+ "last_activity": stat.get("last_activity"),
407
+ }
408
+ )
409
+ totals["tasks"] += int(stat.get("total", 0))
410
+ totals["in_progress"] += int(stat.get("in_progress", 0))
411
+ totals["failed"] += int(stat.get("failed", 0))
412
+ activity = stat.get("last_activity")
413
+ if activity and (last_activity is None or activity > last_activity):
414
+ last_activity = activity
415
+ single = lane_records[0] if len(lane_records) == 1 else None
416
+ projects.append(
417
+ {
418
+ "id": project.id,
419
+ "path": project.path,
420
+ "aliases": list(project.aliases),
421
+ "taskgroups": lane_records,
422
+ "tasks": totals["tasks"],
423
+ "in_progress": totals["in_progress"],
424
+ "failed": totals["failed"],
425
+ "last_activity": last_activity,
426
+ # Convenience mirror of the single lane's lane-level fields, so
427
+ # callers written against the old one-block-per-entry view keep
428
+ # reading `verify`/`group`/`probe` for single-lane projects.
429
+ "group": single["group"] if single else None,
430
+ "verify": single["verify"] if single else None,
431
+ "verify_kind": single["verify_kind"] if single else None,
432
+ "probe": single["probe"] if single else None,
433
+ }
434
+ )
435
+ # Tasks may name a lane that is no longer in the registry (hand-edited
436
+ # config or database). Keep it so the totals still match `tasks` exactly.
437
+ for row in stats.values():
438
+ if row["project"] in known_lane_ids:
439
+ continue
440
+ projects.append(
441
+ {
442
+ "id": row["project"],
443
+ "path": None,
444
+ "aliases": [],
445
+ "taskgroups": [],
446
+ "group": None,
447
+ "verify": None,
448
+ "verify_kind": None,
449
+ "probe": None,
450
+ "tasks": int(row["total"]),
451
+ "in_progress": int(row["in_progress"]),
452
+ "failed": int(row["failed"]),
453
+ "last_activity": row["last_activity"],
454
+ }
455
+ )
456
+ return projects
457
+
458
+
459
+ def cmd_projects(args):
460
+ """Project overview: registry configuration joined with task aggregates.
461
+
462
+ One row per project, carrying its taskgroups (lanes). The numbers come from
463
+ a single aggregate query (`storage.project_overview`) so they can never
464
+ disagree with `tasks`.
465
+ """
466
+ reg_path = registry.workspace_registry_path(args.workspace)
467
+ reg = registry.load(reg_path)
468
+ probes = _read_probe_flags(reg_path)
469
+
470
+ overview = []
471
+ db = storage.db_path(args.workspace)
472
+ if os.path.exists(db): # never create a database just to list projects
473
+ conn = storage.connect(db)
474
+ try:
475
+ storage.migrate(conn)
476
+ overview = storage.project_overview(conn)
477
+ finally:
478
+ conn.close()
479
+ stats = {row["project"]: row for row in overview}
480
+
481
+ projects = _lane_overview(reg, stats, probes)
482
+ payload = {"projects": projects, "registry": reg_path}
483
+
484
+ if not projects:
485
+ human = f"no projects registered in {reg_path}"
486
+ else:
487
+ lines = [
488
+ f"{'ID':<20} {'TASKGROUPS':<22} {'PROBE':<7} {'TASKS':>5} "
489
+ f"{'ACTV':>4} {'FAIL':>4} {'LAST ACTIVITY':<25} VERIFY"
490
+ ]
491
+ for project in projects:
492
+ lanes = ",".join(tg["id"] for tg in project["taskgroups"]) or "-"
493
+ lines.append(
494
+ f"{project['id']:<20} {lanes:<22} "
495
+ f"{str(project['probe'] or '-'):<7} {project['tasks']:>5} "
496
+ f"{project['in_progress']:>4} {project['failed']:>4} "
497
+ f"{str(project['last_activity'] or '—'):<25} {project['verify'] or '-'}"
498
+ )
499
+ lines.append(f" path: {project['path'] or '-'}")
500
+ for lane in project["taskgroups"]:
501
+ lines.append(
502
+ f" taskgroup: {lane['id']} -> {lane['path']} "
503
+ f"({lane['verify'] or 'no verify command'})"
504
+ )
505
+ human = "\n".join(lines)
506
+ emit(args, payload, human)
507
+ return 0
508
+
509
+
510
+ def cmd_taskgroups(args):
511
+ """List every taskgroup (lane): id, owning project, path and verify command."""
512
+ reg_path = registry.workspace_registry_path(args.workspace)
513
+ reg = registry.load(reg_path)
514
+ probes = _read_probe_flags(reg_path)
515
+
516
+ taskgroups = [registry.taskgroup_record(tg, probes) for tg in reg.taskgroups]
517
+ payload = {"taskgroups": taskgroups, "registry": reg_path}
518
+
519
+ if not taskgroups:
520
+ human = f"no taskgroups registered in {reg_path}"
521
+ else:
522
+ lines = [
523
+ f"{'ID':<20} {'PROJECT':<16} {'PATH':<40} VERIFY"
524
+ ]
525
+ for tg in taskgroups:
526
+ lines.append(
527
+ f"{tg['id']:<20} {tg['project']:<16} "
528
+ f"{tg['path']:<40} {tg['verify'] or '-'}"
529
+ )
530
+ human = "\n".join(lines)
531
+ emit(args, payload, human)
532
+ return 0
533
+
534
+
535
+ def cmd_workspaces(args):
536
+ """List every lane that opts into a long-lived workspace, with live status.
537
+
538
+ Read-only: it reports the workspace path, whether it has been built yet, how
539
+ many uncommitted changes and unmerged commits it holds, and which dependency
540
+ paths are symlinked from the main checkout.
541
+ """
542
+ rows = dispatch.list_workspaces(args.workspace)
543
+ payload = {
544
+ "workspaces": rows,
545
+ "registry": registry.workspace_registry_path(args.workspace),
546
+ }
547
+ if not rows:
548
+ human = "no lanes declare a workspace"
549
+ else:
550
+ lines = [
551
+ f"{'ID':<20} {'PROJECT':<16} {'BUILT':<6} {'CHANGES':>7} "
552
+ f"{'UNMERGED':>8} {'LINKS':<22} WORKSPACE"
553
+ ]
554
+ for row in rows:
555
+ links = ",".join(row["links"]) or "-"
556
+ lines.append(
557
+ f"{row['id']:<20} {row['project']:<16} "
558
+ f"{('yes' if row['exists'] else 'no'):<6} "
559
+ f"{row['changed']:>7} {row['unmerged']:>8} "
560
+ f"{links:<22} {row['workspace'] or '-'}"
561
+ )
562
+ human = "\n".join(lines)
563
+ emit(args, payload, human)
564
+ return 0
565
+
566
+
567
+ def _workspace_evidence(status: dict) -> str:
568
+ """The before-deletion evidence `workspace-rm` must always print."""
569
+ lines = [
570
+ f"workspace: {status['workspace'] or '-'}",
571
+ f"repository: {status['repo'] or '-'}",
572
+ f"uncommitted changes ({status['changed']}):",
573
+ ]
574
+ lines += [f" {line}" for line in status["changed_files"]] or [" (none)"]
575
+ lines.append(f"unmerged commits ({status['unmerged']}):")
576
+ lines += [f" {line}" for line in status["unmerged_commits"]] or [" (none)"]
577
+ lines.append("this unregisters the git worktree (git worktree remove --force)")
578
+ return "\n".join(lines)
579
+
580
+
581
+ def cmd_workspace_rm(args):
582
+ status = dispatch.lane_workspace_status(args.workspace, args.lane)
583
+ evidence = _workspace_evidence(status)
584
+
585
+ if not status["exists"]:
586
+ emit(
587
+ args,
588
+ {"workspace": status, "removed": False},
589
+ evidence + "\nno workspace directory; nothing to remove",
590
+ )
591
+ return 0
592
+
593
+ # The evidence goes out BEFORE anything is deleted, so an operator always
594
+ # sees what is at stake -- including on a refusal (the error below follows).
595
+ if not args.json:
596
+ print(evidence, flush=True)
597
+
598
+ result = dispatch.remove_workspace(args.workspace, args.lane, force=args.force)
599
+
600
+ if args.json:
601
+ emit(args, {"workspace": status, "result": result}, "")
602
+ return 0
603
+ note = ""
604
+ if result.get("forced"):
605
+ note = " (forced: uncommitted changes / unmerged commits present)"
606
+ print(f"removed workspace: {result['path']}{note}", flush=True)
607
+ return 0
608
+
609
+
610
+ def cmd_run(args):
611
+ cap = None
612
+ if args.cap is not None:
613
+ if args.cap < 1:
614
+ raise UsageError(
615
+ f"--cap must be >= 1 (got {args.cap})",
616
+ hint="可用范围:整数且 >= 1(例如 --cap 5)",
617
+ )
618
+ cap = args.cap
619
+ task_id = dispatch.dispatch(
620
+ args.workspace,
621
+ args.project,
622
+ args.brief,
623
+ adapter=args.adapter,
624
+ model=args.model,
625
+ reasoning=args.reasoning,
626
+ read_only=args.read_only,
627
+ worktree=args.worktree,
628
+ skip_verify=args.no_verify,
629
+ timeout=args.timeout,
630
+ idle_timeout=args.idle_timeout,
631
+ cap=cap,
632
+ )
633
+ task = dispatch.task_detail(args.workspace, task_id).get("task") or {}
634
+ status = task.get("status")
635
+ payload = {"task_id": task_id, "status": status, "task": task}
636
+ emit(args, payload, f"{task_id} {status or ''} {args.project}")
637
+ return 0
638
+
639
+
640
+ def _load_registry_best_effort(workspace):
641
+ """Load the workspace registry, or ``None`` when it is missing/broken.
642
+
643
+ `tasks` must still work against the database alone; a bad registry only
644
+ drops the cwd inference back to "no registered projects".
645
+ """
646
+ try:
647
+ return registry.load(registry.workspace_registry_path(workspace))
648
+ except errors.TaskproofError:
649
+ return None
650
+
651
+
652
+ def _scope_banner(project_id, source, n_projects) -> str:
653
+ """The mandatory scope label: ``--project`` > ``--all`` > cwd > all."""
654
+ if project_id is None:
655
+ return f"作用域: 全部 {n_projects} 个项目({source})"
656
+ return f"当前项目: {project_id}({source})"
657
+
658
+
659
+ def _row_brief(task) -> str:
660
+ brief = (task.get("brief") or "").replace("\n", " ")
661
+ return brief[:45] + "..." if len(brief) > 48 else brief
662
+
663
+
664
+ def _row_verify(task) -> str:
665
+ """One cell for the VERIFY column.
666
+
667
+ `SKIPPED` is deliberately rendered as an en dash, not a pass: a card whose
668
+ acceptance never ran must not look like one that did. Without a recorded
669
+ exit there is nothing to claim, so that is a `?`; otherwise `exit=N`.
670
+ """
671
+ if task.get("verify_cmd") == dispatch.VERIFY_SKIPPED:
672
+ return "–"
673
+ exit_code = task.get("verify_exit")
674
+ if exit_code is None:
675
+ return "?"
676
+ return f"exit={exit_code}"
677
+
678
+
679
+ def _row_files(task) -> str:
680
+ files_changed = task.get("files_changed")
681
+ return "-" if files_changed is None else str(files_changed)
682
+
683
+
684
+ def _tasks_header(*, with_project: bool) -> str:
685
+ # Column order is contractual: ID STATUS [PROJECT] VERIFY FILES BRIEF. The
686
+ # single-project view drops PROJECT (it is constant there) and keeps its
687
+ # compact shape; either way BRIEF stays last and keeps its 45-char cap
688
+ # (`_row_brief`), so the new columns never squeeze it out.
689
+ cells = [f"{'ID':<18}", f"{'STATUS':<10}"]
690
+ if with_project:
691
+ cells.append(f"{'PROJECT':<16}")
692
+ cells += [f"{'VERIFY':<8}", f"{'FILES':<6}", "BRIEF"]
693
+ return " " + " ".join(cells)
694
+
695
+
696
+ def _tasks_row(task, *, with_project: bool) -> str:
697
+ cells = [f"{str(task.get('id') or ''):<18}", f"{str(task.get('status') or ''):<10}"]
698
+ if with_project:
699
+ cells.append(f"{str(task.get('project') or ''):<16}")
700
+ cells += [f"{_row_verify(task):<8}", f"{_row_files(task):<6}", _row_brief(task)]
701
+ return " " + " ".join(cells)
702
+
703
+
704
+ def _tasks_human(tasks, *, project_id, source, n_projects) -> str:
705
+ """Human rendering of `tasks`, grouped by project for the "全部" scope."""
706
+ lines = [_scope_banner(project_id, source, n_projects)]
707
+ if project_id is None:
708
+ groups = storage.group_tasks_by_project(tasks)
709
+ if not groups:
710
+ lines.append("no tasks")
711
+ return "\n".join(lines)
712
+ for pid, items in groups:
713
+ lines.append("")
714
+ lines.append(f"# {pid} ({len(items)} 个任务)")
715
+ lines.append(_tasks_header(with_project=True))
716
+ for task in items:
717
+ lines.append(_tasks_row(task, with_project=True))
718
+ return "\n".join(lines)
719
+
720
+ if not tasks:
721
+ lines.append("no tasks")
722
+ return "\n".join(lines)
723
+ lines.append(_tasks_header(with_project=False))
724
+ for task in tasks:
725
+ lines.append(_tasks_row(task, with_project=False))
726
+ return "\n".join(lines)
727
+
728
+
729
+ def cmd_tasks(args):
730
+ reg = _load_registry_best_effort(args.workspace)
731
+ project_id, source = registry.resolve_scope(
732
+ reg, explicit_project=args.project, force_all=args.all_projects
733
+ )
734
+
735
+ # `--id` / `--lane` have no SQL seam (`list_tasks` only knows status /
736
+ # project), so they are applied here -- but AFTER the status/project
737
+ # filter and BEFORE `--limit`, so every filter composes. `limit=-1` asks
738
+ # SQLite for every row (its "no limit"), and the cap is applied below.
739
+ id_filter = {
740
+ tid.strip() for tid in (getattr(args, "ids", None) or "").split(",") if tid.strip()
741
+ }
742
+ lane = getattr(args, "lane", None)
743
+
744
+ conn = storage.connect(storage.db_path(args.workspace))
745
+ try:
746
+ storage.migrate(conn)
747
+ rows = storage.list_tasks(
748
+ conn, status=args.status, project=project_id, limit=-1
749
+ )
750
+ tasks = [dict(row) for row in rows]
751
+ finally:
752
+ conn.close()
753
+
754
+ if id_filter:
755
+ tasks = [task for task in tasks if task.get("id") in id_filter]
756
+ if lane:
757
+ tasks = [task for task in tasks if task.get("group_name") == lane]
758
+ tasks = tasks[: args.limit]
759
+
760
+ if project_id is None:
761
+ groups = storage.group_tasks_by_project(tasks)
762
+ tasks = [task for _pid, items in groups for task in items]
763
+ else:
764
+ tasks = sorted(tasks, key=lambda t: t.get("created_at") or "", reverse=True)
765
+
766
+ if reg is not None:
767
+ n_projects = len(reg.projects)
768
+ else:
769
+ n_projects = len({t.get("project") for t in tasks if t.get("project")})
770
+
771
+ payload = {
772
+ "tasks": tasks,
773
+ "count": len(tasks),
774
+ "project": project_id,
775
+ "scope": source,
776
+ }
777
+ emit(
778
+ args,
779
+ payload,
780
+ _tasks_human(
781
+ tasks, project_id=project_id, source=source, n_projects=n_projects
782
+ ),
783
+ )
784
+ return 0
785
+
786
+
787
+ def cmd_show(args):
788
+ detail = dispatch.task_detail(args.workspace, args.task_id)
789
+ if detail.get("task") is None:
790
+ raise UsageError(f"no such task: {args.task_id}")
791
+ task = detail["task"]
792
+ lines = [
793
+ f"id: {task.get('id')}",
794
+ f"project: {task.get('project')}",
795
+ f"status: {task.get('status')}",
796
+ f"adapter: {task.get('adapter')}",
797
+ f"brief: {task.get('brief')}",
798
+ f"verify: {task.get('verify_cmd')} (exit {task.get('verify_exit')})",
799
+ f"created: {task.get('created_at')}",
800
+ f"finished: {task.get('finished_at')}",
801
+ f"workdir: {task.get('workdir')}",
802
+ f"files: {task.get('files_changed')} (changed)",
803
+ ]
804
+ lines.extend(_change_lines(detail.get("events") or []))
805
+ emit(args, detail, "\n".join(lines))
806
+ return 0
807
+
808
+
809
+ def _change_lines(events) -> list:
810
+ """The `changes:` block of `show`, read from the `changes` event.
811
+
812
+ One indented line per recorded path; a path that was already dirty before
813
+ the run (the part of ``files_changed`` that is NOT this card's doing) gets a
814
+ trailing marker so the reader can tell the two apart at a glance.
815
+ """
816
+ payload = next(
817
+ (e.get("payload") for e in events if e.get("event") == "changes"),
818
+ None,
819
+ )
820
+ if not isinstance(payload, dict):
821
+ return []
822
+ preexisting = set(payload.get("preexisting") or [])
823
+ lines = ["changes:"]
824
+ for path in payload.get("paths") or []:
825
+ suffix = " [跑前已脏]" if path in preexisting else ""
826
+ lines.append(f" {path}{suffix}")
827
+ if payload.get("truncated"):
828
+ lines.append(" … (截断)")
829
+ return lines
830
+
831
+
832
+ def _wait_human(task) -> str:
833
+ """`wait`'s text form: the run-ending line, then the verdict fields."""
834
+ return "\n".join(
835
+ [
836
+ f"{task.get('id')} {task.get('status') or ''} {task.get('project') or ''}",
837
+ f"status: {task.get('status')}",
838
+ f"exit: {task.get('exit_code')}",
839
+ f"verify: {task.get('verify_cmd')} (exit {task.get('verify_exit')})",
840
+ f"files: {task.get('files_changed')}",
841
+ f"started: {task.get('started_at')}",
842
+ f"finished: {task.get('finished_at')}",
843
+ ]
844
+ )
845
+
846
+
847
+ def cmd_wait(args):
848
+ """Poll the ledger until a task is terminal, then report it.
849
+
850
+ Read-only by construction: it never forks, daemonises or spawns a thread,
851
+ it just re-reads the task row on `--poll` seconds like `log --follow`. A
852
+ wait that runs out returns 124 and leaves the task exactly where it was.
853
+ """
854
+ deadline = time.monotonic() + args.timeout
855
+ while True:
856
+ status = _task_status(args.workspace, args.task_id)
857
+ if status is None:
858
+ raise UsageError(f"no such task: {args.task_id}")
859
+ if status in TERMINAL_STATUSES:
860
+ break
861
+ remaining = deadline - time.monotonic()
862
+ if remaining <= 0:
863
+ break
864
+ time.sleep(min(args.poll, remaining))
865
+
866
+ detail = dispatch.task_detail(args.workspace, args.task_id)
867
+ task = detail.get("task") or {}
868
+ emit(args, detail, _wait_human(task))
869
+
870
+ status = task.get("status")
871
+ if status not in TERMINAL_STATUSES:
872
+ return 124 # the wait gave up; the task state was never touched
873
+ return 0 if status == STATUS_DONE else 1
874
+
875
+
876
+ #: `log --follow` polls the audit stream instead of blocking on a `tail -f`.
877
+ _LOG_POLL_SECONDS = 1.0
878
+
879
+
880
+ def _format_event(event) -> str:
881
+ payload = event.get("payload")
882
+ if payload is not None:
883
+ try:
884
+ payload = json.dumps(payload, ensure_ascii=False, default=str)
885
+ except (TypeError, ValueError):
886
+ payload = str(payload)
887
+ return (
888
+ f"{event.get('ts', '')} {str(event.get('task_id') or '-'):<18} "
889
+ f"{event.get('event', '')} {payload or ''}"
890
+ ).rstrip()
891
+
892
+
893
+ def _task_status(workspace, task_id):
894
+ conn = storage.connect(storage.db_path(workspace))
895
+ try:
896
+ storage.migrate(conn)
897
+ row = storage.get_task(conn, task_id)
898
+ return row["status"] if row is not None else None
899
+ finally:
900
+ conn.close()
901
+
902
+
903
+ def cmd_log(args):
904
+ if _task_status(args.workspace, args.task_id) is None:
905
+ raise UsageError(f"no such task: {args.task_id}")
906
+
907
+ if not args.follow:
908
+ events = list(ledger.iter_events(args.workspace, task_id=args.task_id))
909
+ payload = {"task_id": args.task_id, "events": events, "count": len(events)}
910
+ human = "\n".join(_format_event(event) for event in events) or "(no events)"
911
+ emit(args, payload, human)
912
+ return 0
913
+
914
+ seen = 0
915
+ while True:
916
+ events = list(ledger.iter_events(args.workspace, task_id=args.task_id))
917
+ for event in events[seen:]:
918
+ if args.json:
919
+ print(json.dumps(event, ensure_ascii=False, default=str), flush=True)
920
+ elif not args.quiet:
921
+ print(_format_event(event), flush=True)
922
+ seen = len(events)
923
+ if _task_status(args.workspace, args.task_id) in TERMINAL_STATUSES:
924
+ break
925
+ time.sleep(_LOG_POLL_SECONDS)
926
+ return 0
927
+
928
+
929
+ def cmd_verify(args):
930
+ outcome = dispatch.verify_task(args.workspace, args.task_id)
931
+ payload = {
932
+ "task_id": args.task_id,
933
+ "ran": outcome.ran,
934
+ "passed": outcome.passed,
935
+ "exit_code": outcome.exit_code,
936
+ "command": outcome.command,
937
+ "note": outcome.note,
938
+ "violations": outcome.violations,
939
+ "output_tail": outcome.output_tail,
940
+ }
941
+ status = "SKIPPED" if not outcome.ran else ("PASSED" if outcome.passed else "FAILED")
942
+ emit(args, payload, f"{args.task_id} verify: {status} exit={outcome.exit_code}")
943
+ return 0
944
+
945
+
946
+ def cmd_accept(args):
947
+ task = dispatch.accept_task(
948
+ args.workspace, args.task_id, note=getattr(args, "note", None)
949
+ )
950
+ payload = {"task_id": args.task_id, "status": task.get("status"), "task": task}
951
+ emit(args, payload, f"{args.task_id} {task.get('status', '')} accepted")
952
+ return 0
953
+
954
+
955
+ def cmd_rerun(args):
956
+ new_id = dispatch.rerun_task(args.workspace, args.task_id)
957
+ task = dispatch.task_detail(args.workspace, new_id).get("task") or {}
958
+ payload = {
959
+ "task_id": new_id,
960
+ "rerun_of": args.task_id,
961
+ "status": task.get("status"),
962
+ "task": task,
963
+ }
964
+ emit(args, payload, f"{new_id} {task.get('status', '')} rerun of {args.task_id}")
965
+ return 0
966
+
967
+
968
+ def cmd_cancel(args):
969
+ task = dispatch.cancel_task(args.workspace, args.task_id)
970
+ payload = {"task_id": args.task_id, "status": task.get("status"), "task": task}
971
+ emit(args, payload, f"{args.task_id} {task.get('status', '')} cancelled")
972
+ return 0
973
+
974
+
975
+ def cmd_rm(args):
976
+ removed = dispatch.remove_task(args.workspace, args.task_id)
977
+ emit(args, {"removed": removed}, f"removed {removed}")
978
+ return 0
979
+
980
+
981
+ def _board_static_root():
982
+ """Where the desktop renderer's web build lives inside the package.
983
+
984
+ A function, not a constant, so tests can point it at a temporary directory:
985
+ the real ``src/taskproof/web/`` is produced by the desktop build and is
986
+ absent from a source checkout until ``npm run build:web`` runs.
987
+ """
988
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
989
+
990
+
991
+ def _board_serve(workspace, port, *, open_browser=False, httpd_factory=None):
992
+ """Bind 127.0.0.1:port serving the real REST API + the desktop web build.
993
+
994
+ Binding happens *before* the browser is opened, so a busy port surfaces as a
995
+ UsageError instead of a tab that never loads. ``httpd_factory`` and
996
+ ``open_browser`` are seams for the tests: they let a test observe the URL
997
+ handed to ``webbrowser.open`` and stop the loop without a real socket or a
998
+ permanent block. The opened URL uses the port the socket actually bound
999
+ (so ``port=0`` still yields a reachable address).
1000
+ """
1001
+ import http.server
1002
+
1003
+ from .api import server as api_server
1004
+
1005
+ static_root = _board_static_root()
1006
+ if not os.path.isdir(static_root):
1007
+ raise UsageError(
1008
+ f"the board's web assets are missing: {static_root}",
1009
+ hint="run `npm run build:web` in desktop/",
1010
+ )
1011
+
1012
+ factory = httpd_factory or http.server.ThreadingHTTPServer
1013
+ try:
1014
+ httpd = factory(("127.0.0.1", port), api_server.build_handler(workspace, static_root))
1015
+ except OSError as exc:
1016
+ raise UsageError(
1017
+ f"cannot serve the board on 127.0.0.1:{port}: {exc}",
1018
+ hint="the port is already in use; pass another --serve PORT",
1019
+ )
1020
+ bound_port = httpd.server_address[1]
1021
+ try:
1022
+ if open_browser:
1023
+ webbrowser.open(f"http://127.0.0.1:{bound_port}/")
1024
+ httpd.serve_forever()
1025
+ finally:
1026
+ httpd.server_close()
1027
+ return 0
1028
+
1029
+
1030
+ def cmd_board(args):
1031
+ from .api.server import DEFAULT_PORT
1032
+
1033
+ # One mode now: serve the same REST API the desktop app uses, plus the
1034
+ # desktop renderer's web build. A bare `board` (and the explicit `--open`
1035
+ # synonym) serves and opens a browser; `--serve [PORT]` serves only. The
1036
+ # server blocks either way. The old snapshot flags are gone.
1037
+ open_browser = args.open or args.serve is None
1038
+ port = args.serve if args.serve is not None else DEFAULT_PORT
1039
+ return _board_serve(args.workspace, port, open_browser=open_browser)
1040
+
1041
+
1042
+ def cmd_api(args):
1043
+ from .api import server
1044
+
1045
+ return server.serve(args.workspace, args.port)
1046
+
1047
+
1048
+ def _check_workspace(workspace) -> dict:
1049
+ try:
1050
+ os.makedirs(workspace, exist_ok=True)
1051
+ probe = os.path.join(workspace, ".doctor-write-probe")
1052
+ with open(probe, "w", encoding="utf-8") as handle:
1053
+ handle.write("ok")
1054
+ os.remove(probe)
1055
+ except OSError as exc:
1056
+ return {"ok": False, "path": workspace, "detail": f"workspace not writable: {exc}"}
1057
+ return {"ok": True, "path": workspace, "detail": ""}
1058
+
1059
+
1060
+ def _check_registry(workspace) -> dict:
1061
+ reg_path = registry.workspace_registry_path(workspace)
1062
+ try:
1063
+ reg = registry.load(reg_path)
1064
+ except errors.TaskproofError as exc:
1065
+ return {"ok": False, "path": reg_path, "projects": 0, "detail": f"registry: {exc}"}
1066
+ return {"ok": True, "path": reg_path, "projects": len(reg.projects), "detail": ""}
1067
+
1068
+
1069
+ def _adapter_status() -> list:
1070
+ from . import adapters
1071
+
1072
+ results = []
1073
+ for name in adapters.KNOWN:
1074
+ if name.startswith("custom"):
1075
+ continue
1076
+ adapter_cls = None
1077
+ problem = None
1078
+ try:
1079
+ adapter_cls = adapters.load_class(name)
1080
+ problem = adapter_cls().preflight()
1081
+ except Exception as exc: # doctor must never crash on a broken adapter
1082
+ problem = str(exc)
1083
+ binary = getattr(adapter_cls, "binary", "") if adapter_cls is not None else ""
1084
+ installed = problem is None
1085
+ results.append(
1086
+ {
1087
+ "name": name,
1088
+ "installed": installed,
1089
+ "binary": binary,
1090
+ "detail": "" if installed else problem,
1091
+ }
1092
+ )
1093
+ return results
1094
+
1095
+
1096
+ def _cap_source_text(cap) -> str:
1097
+ """Doctor's source gloss for a concurrency dict (``auto``/``toml``/``cli``)."""
1098
+ source = cap.get("source")
1099
+ detail = cap.get("detail", "")
1100
+ if source == concurrency.SOURCE_AUTO:
1101
+ return f"自动探测 {detail}"
1102
+ if source == concurrency.SOURCE_CLI:
1103
+ return f"本次 {detail}"
1104
+ return detail or str(source)
1105
+
1106
+
1107
+ def _doctor_human(report) -> str:
1108
+ lines = ["taskproof doctor", ""]
1109
+ python = report["python"]
1110
+ lines.append(
1111
+ f" [{'ok' if python['ok'] else '!!'}] "
1112
+ f"python {python['version']} (needs {python['required']})"
1113
+ )
1114
+ sqlite = report["sqlite"]
1115
+ lines.append(
1116
+ f" [{'ok' if sqlite['ok'] else '!!'}] sqlite "
1117
+ f"{sqlite.get('version') or sqlite.get('detail', '')}"
1118
+ )
1119
+ workspace = report["workspace"]
1120
+ lines.append(
1121
+ f" [{'ok' if workspace['ok'] else '!!'}] workspace: {workspace['path']}"
1122
+ )
1123
+ reg = report["registry"]
1124
+ if reg["ok"]:
1125
+ lines.append(
1126
+ f" [ok] registry: {reg['path']} ({reg['projects']} project(s))"
1127
+ )
1128
+ else:
1129
+ lines.append(f" [!!] {reg['detail']}")
1130
+ cap = report.get("concurrency")
1131
+ if cap is not None:
1132
+ lines.append(
1133
+ f" [ok] concurrency: {cap['value']} "
1134
+ f"(来源:{_cap_source_text(cap)})"
1135
+ )
1136
+ lines.append(" adapters:")
1137
+ for item in report["adapters"]:
1138
+ mark = "ok" if item["installed"] else "!!"
1139
+ extra = "" if item["installed"] else f" ({item['detail']})"
1140
+ lines.append(f" [{mark}] {item['name']}{extra}")
1141
+ lines.append("")
1142
+ if report["problems"]:
1143
+ lines.append("problems:")
1144
+ lines.extend(f" - {problem}" for problem in report["problems"])
1145
+ else:
1146
+ lines.append("all checks passed")
1147
+ return "\n".join(lines)
1148
+
1149
+
1150
+ _DEFAULT_TIMEOUT = 1800
1151
+
1152
+
1153
+ def _load_defaults(workspace):
1154
+ """The `[defaults]` table of the workspace registry, or ``{}``.
1155
+
1156
+ Read-only and best-effort: a missing or broken registry simply means "no
1157
+ configured defaults", which is exactly the auto-detect case (card 42)."""
1158
+ try:
1159
+ reg = registry.load(registry.workspace_registry_path(workspace))
1160
+ except errors.TaskproofError:
1161
+ return {}
1162
+ return reg.defaults
1163
+
1164
+
1165
+ def _timeout_setting(defaults):
1166
+ """The effective timeout plus its source, mirroring :func:`concurrency.resolve`."""
1167
+ if "timeout" in (defaults or {}):
1168
+ return {"value": int(defaults["timeout"]), "source": "toml", "detail": "projects.toml"}
1169
+ return {"value": _DEFAULT_TIMEOUT, "source": "default", "detail": "内置默认"}
1170
+
1171
+
1172
+ def _positive_int(name, raw):
1173
+ """Parse ``raw`` as an integer >= 1, or raise a UsageError naming the range.
1174
+
1175
+ Deliberately not delegated to argparse's ``type=int``: a rejected value must
1176
+ produce a message that states the usable range (card 42) and, crucially, the
1177
+ caller must be able to validate *every* flag before any write happens."""
1178
+ try:
1179
+ value = int(raw)
1180
+ except (TypeError, ValueError):
1181
+ raise UsageError(
1182
+ f"{name} must be an integer >= 1 (got {raw!r})",
1183
+ hint="可用范围:整数且 >= 1(例如 --concurrency 4)",
1184
+ )
1185
+ if value < 1:
1186
+ raise UsageError(
1187
+ f"{name} must be >= 1 (got {value})",
1188
+ hint="可用范围:整数且 >= 1(例如 --concurrency 4)",
1189
+ )
1190
+ return value
1191
+
1192
+
1193
+ def _elapsed_seconds(iso_str):
1194
+ if not iso_str:
1195
+ return None
1196
+ try:
1197
+ then = datetime.fromisoformat(iso_str)
1198
+ except (TypeError, ValueError):
1199
+ return None
1200
+ now = datetime.now(then.tzinfo) if then.tzinfo else datetime.now()
1201
+ return max(0, int((now - then).total_seconds()))
1202
+
1203
+
1204
+ def _format_duration(seconds):
1205
+ if seconds is None:
1206
+ return "?"
1207
+ seconds = int(seconds)
1208
+ if seconds < 60:
1209
+ return f"{seconds}s"
1210
+ minutes, sec = divmod(seconds, 60)
1211
+ if minutes < 60:
1212
+ return f"{minutes}m{sec}s"
1213
+ hours, minutes = divmod(minutes, 60)
1214
+ return f"{hours}h{minutes}m"
1215
+
1216
+
1217
+ def _active_running_tasks(workspace):
1218
+ """Tasks currently holding a global slot: id / project / how long.
1219
+
1220
+ Reads the *claims* table, so it names exactly the cards the concurrency gate
1221
+ is counting — never a card that merely looks busy. Strictly read-only: it
1222
+ never creates the database, and opening an existing one touches only SQLite's
1223
+ own sidecars."""
1224
+ path = storage.db_path(workspace)
1225
+ if not os.path.exists(path):
1226
+ return []
1227
+ conn = storage.connect(path)
1228
+ try:
1229
+ rows = conn.execute(
1230
+ "SELECT t.id AS id, t.project AS project, "
1231
+ " COALESCE(t.started_at, c.claimed_at) AS started_at "
1232
+ "FROM claims c JOIN tasks t ON t.id = c.task_id "
1233
+ "WHERE c.scope != ? AND julianday(c.expires_at) > julianday('now') "
1234
+ "ORDER BY c.claimed_at",
1235
+ (concurrency.GLOBAL_SCOPE,),
1236
+ ).fetchall()
1237
+ finally:
1238
+ conn.close()
1239
+ running = []
1240
+ for row in rows:
1241
+ started_at = row["started_at"]
1242
+ running.append(
1243
+ {
1244
+ "id": row["id"],
1245
+ "project": row["project"],
1246
+ "started_at": started_at,
1247
+ "elapsed_seconds": _elapsed_seconds(started_at),
1248
+ }
1249
+ )
1250
+ return running
1251
+
1252
+
1253
+ def cmd_config(args):
1254
+ """Show the effective `[defaults]`, or set `concurrency` / `timeout` in place.
1255
+
1256
+ Setting a value edits only the one line (comment-preserving) — see
1257
+ `registry.set_default`. Every flag is validated before the first write, so a
1258
+ bad value leaves the registry byte-identical. `--show` (and any rejected
1259
+ input) is pure read."""
1260
+ # Validate every write target *before* touching disk.
1261
+ new_concurrency = None
1262
+ new_timeout = None
1263
+ if args.concurrency is not None:
1264
+ new_concurrency = _positive_int("--concurrency", args.concurrency)
1265
+ if args.timeout is not None:
1266
+ new_timeout = _positive_int("--timeout", args.timeout)
1267
+
1268
+ reg_path = registry.workspace_registry_path(args.workspace)
1269
+ if new_concurrency is not None:
1270
+ registry.set_default(reg_path, "concurrency", new_concurrency)
1271
+ if new_timeout is not None:
1272
+ registry.set_default(reg_path, "timeout", new_timeout)
1273
+
1274
+ defaults = _load_defaults(args.workspace)
1275
+ cap = concurrency.resolve(defaults)
1276
+ timeout = _timeout_setting(defaults)
1277
+ payload = {"concurrency": cap.as_dict(), "timeout": timeout}
1278
+ human_lines = [
1279
+ f"concurrency = {cap.value}({concurrency.source_gloss(cap)})",
1280
+ f"timeout = {timeout['value']}({timeout['detail']})",
1281
+ ]
1282
+
1283
+ # Non-retroactive (Q4): a *smaller* cap never touches a running card.
1284
+ if new_concurrency is not None:
1285
+ running = _active_running_tasks(args.workspace)
1286
+ if len(running) > new_concurrency:
1287
+ payload["running_over_cap"] = running
1288
+ human_lines.append("")
1289
+ human_lines.append(
1290
+ f"注意:生效上限已降到 {new_concurrency},但当前有 "
1291
+ f"{len(running)} 张卡在跑(超过新上限)。"
1292
+ )
1293
+ human_lines.append("不追溯:这些卡照常跑完,状态不改、进程不杀。")
1294
+ human_lines.append("在跑的卡:")
1295
+ for row in running:
1296
+ human_lines.append(
1297
+ f" {row['id']} {row['project']} "
1298
+ f"{_format_duration(row['elapsed_seconds'])}"
1299
+ )
1300
+
1301
+ emit(args, payload, "\n".join(human_lines))
1302
+ return 0
1303
+
1304
+
1305
+ def cmd_doctor(args):
1306
+ report = {}
1307
+
1308
+ version = sys.version.split()[0]
1309
+ python_ok = sys.version_info >= (3, 11)
1310
+ report["python"] = {"ok": python_ok, "version": version, "required": ">=3.11"}
1311
+
1312
+ try:
1313
+ probe = sqlite3.connect(":memory:")
1314
+ sqlite_version = probe.execute("SELECT sqlite_version()").fetchone()[0]
1315
+ probe.close()
1316
+ report["sqlite"] = {"ok": True, "version": sqlite_version}
1317
+ except sqlite3.Error as exc:
1318
+ report["sqlite"] = {"ok": False, "version": None, "detail": str(exc)}
1319
+
1320
+ report["workspace"] = _check_workspace(args.workspace)
1321
+ report["registry"] = _check_registry(args.workspace)
1322
+ report["concurrency"] = concurrency.resolve(_load_defaults(args.workspace)).as_dict()
1323
+ report["adapters"] = _adapter_status()
1324
+
1325
+ problems = []
1326
+ if not report["python"]["ok"]:
1327
+ problems.append(f"python {version} is older than 3.11")
1328
+ if not report["sqlite"]["ok"]:
1329
+ problems.append(f"sqlite unavailable: {report['sqlite'].get('detail', '')}")
1330
+ if not report["workspace"]["ok"]:
1331
+ problems.append(report["workspace"]["detail"])
1332
+ if not report["registry"]["ok"]:
1333
+ problems.append(report["registry"]["detail"])
1334
+ for item in report["adapters"]:
1335
+ if not item["installed"]:
1336
+ problems.append(f"adapter '{item['name']}' unavailable: {item['detail']}")
1337
+ report["problems"] = problems
1338
+ report["ok"] = not problems
1339
+
1340
+ emit(args, report, _doctor_human(report))
1341
+ return 0
1342
+
1343
+
1344
+ def cmd_gc(args):
1345
+ touched = ledger.rotate(args.workspace)
1346
+ conn = storage.connect(storage.db_path(args.workspace))
1347
+ try:
1348
+ storage.migrate(conn)
1349
+ reaped = concurrency.reap_expired(conn)
1350
+ finally:
1351
+ conn.close()
1352
+
1353
+ payload = {
1354
+ "rotated": touched,
1355
+ "rotated_count": len(touched),
1356
+ "reaped_claims": reaped,
1357
+ }
1358
+ human = (
1359
+ f"rotated {len(touched)} audit file(s)\n"
1360
+ f"reaped {reaped} expired claim(s)"
1361
+ )
1362
+ emit(args, payload, human)
1363
+ return 0
1364
+
1365
+
1366
+ COMMANDS = {
1367
+ "init": cmd_init,
1368
+ "register": cmd_register,
1369
+ "projects": cmd_projects,
1370
+ "taskgroups": cmd_taskgroups,
1371
+ "workspaces": cmd_workspaces,
1372
+ "workspace-rm": cmd_workspace_rm,
1373
+ "run": cmd_run,
1374
+ "tasks": cmd_tasks,
1375
+ "show": cmd_show,
1376
+ "wait": cmd_wait,
1377
+ "log": cmd_log,
1378
+ "verify": cmd_verify,
1379
+ "accept": cmd_accept,
1380
+ "rerun": cmd_rerun,
1381
+ "cancel": cmd_cancel,
1382
+ "rm": cmd_rm,
1383
+ "board": cmd_board,
1384
+ "api": cmd_api,
1385
+ "doctor": cmd_doctor,
1386
+ "gc": cmd_gc,
1387
+ "config": cmd_config,
1388
+ }
1389
+
1390
+
1391
+ def emit(args, payload, human: str) -> None:
1392
+ """One place where `--json` vs human output is decided."""
1393
+ if getattr(args, "json", False):
1394
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
1395
+ elif not getattr(args, "quiet", False):
1396
+ print(human)
1397
+
1398
+
1399
+ if __name__ == "__main__":
1400
+ sys.exit(main())