taskproof 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/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())
|