ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
work.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Repository workflow client for plans and canonical agent workspaces.
5
6The only creating path delegates to ``scripts/dev/agent_workspace.sh``. This
7module owns no worktree lock, metadata store, branch creator, cleanup path, or
8gate implementation. It reads the shared monitor's cached verdict without
9polling. GitHub mutations are emitted for human review and never executed here.
10"""
11
12from __future__ import annotations
13
14import argparse
15import contextlib
16import os
17import shutil
18import stat
19import sys
20import tempfile
21import unittest
22from dataclasses import dataclass
23from pathlib import Path
24
25sys.path.insert(0, str(Path(__file__).resolve().parent))
26sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "scripts/dev"))
27
28from work_emit import render_commands
29from work_gh import STATE_DEGRADED, STATE_UNAVAILABLE, Probe, probe_auth, probe_version
30from work_git import (
31 GitCommandError,
32 RepoPaths,
33 ToolMissingError,
34 WorkError,
35 branch_exists,
36 diff_stat,
37 discover_repo,
38 git_child_environment,
39 git_executable,
40 porcelain_status,
41 printable,
42 redact,
43 reject_executable_attributes,
44 resolve_commit,
45 resolve_tree,
46 run_process,
47)
48from work_plan import PlanError, load_plan, plan_to_json, render_summary
49from work_workspace import (
50 FOREIGN,
51 FORGED,
52 OWNER,
53 READY,
54 STALE,
55 Claim,
56 ClaimError,
57 branch_name,
58 classify,
59 is_identifier,
60 list_claims,
61 load_claim,
62 metadata_dir,
63 metadata_path,
64 recovery_command,
65 workspace_name,
66)
67
68EXIT_OK = 0
69EXIT_FAIL = 1
70EXIT_CONFIG = 2
71MIN_PYTHON = (3, 11)
72CHECK_OK = "OK"
73CHECK_FAIL = "FAIL"
74WS_ROOT_ENV = "RA8_WS_ROOT"
75SELFTEST_MINIMUM = 101
76
77
78@dataclass(frozen=True)
79class Check:
80 """One doctor result."""
81
82 name: str
83 state: str
84 detail: str
85
86
87@dataclass(frozen=True)
88class StartPlan:
89 """One dry-run description passed to the canonical workspace creator."""
90
91 identifier: str
92 name: str
93 branch: str
94 target: Path
95 ws_root: Path
96 ref: str
97 base_commit: str | None
98 refusals: tuple[str, ...]
99
100
101def _print(text: str) -> None:
102 """Print one sanitized logical line."""
103 print(printable(text))
104
105
106def _fail(text: str) -> None:
107 """Print one sanitized logical error line."""
108 print(printable(text), file=sys.stderr)
109
110
111def _notice(text: str) -> None:
112 """Print one sanitized non-error diagnostic without polluting stdout."""
113 print(printable(text), file=sys.stderr)
114
115
116def _print_block(text: str, *, error: bool = False) -> None:
117 """Print captured multiline output one sanitized line at a time."""
118 writer = _fail if error else _print
119 for line in text.splitlines():
120 writer(line)
121
122
123def _ws_root(value: str | None = None) -> Path:
124 """Return the absolute lexical canonical workspace root."""
125 selected = value or os.environ.get(WS_ROOT_ENV) or str(Path.home() / "ra8-ws")
126 if not selected.isascii() or not selected.isprintable():
127 message = "workspace root must contain only printable single-line ASCII"
128 raise WorkError(message)
129 # ``resolve`` would follow a hostile symlink before the claim validator can reject it.
130 return Path(
131 os.path.abspath( # noqa: PTH100 -- preserve symlink evidence for claim validation
132 os.fspath(Path(selected).expanduser())
133 )
134 )
135
136
137def _workspace_script(paths: RepoPaths) -> Path:
138 """Return the canonical workspace lifecycle implementation."""
139 return paths.toplevel / "scripts/dev/agent_workspace.sh"
140
141
142def _writability(path: Path) -> tuple[bool, Path]:
143 """Return whether the nearest existing ancestor is writable."""
144 probe = path
145 while not probe.exists() and probe.parent != probe:
146 probe = probe.parent
147 return os.access(probe, os.W_OK), probe
148
149
150def _probe_to_check(probe: Probe) -> Check:
151 """Adapt one GitHub probe."""
152 return Check(probe.name, probe.state, probe.detail)
153
154
155def _doctor_checks(paths: RepoPaths, cwd: Path) -> list[Check]:
156 """Build read-only readiness checks, including emitted-script dependencies."""
157 version = ".".join(str(part) for part in sys.version_info[:3])
158 python_state = CHECK_OK if sys.version_info[:2] >= MIN_PYTHON else CHECK_FAIL
159 git_version = run_process([git_executable(), "--version"], cwd=cwd).stdout.strip()
160 root = _ws_root()
161 writable, ancestor = _writability(root)
162 script = _workspace_script(paths)
163 jq = shutil.which("jq")
164 jq_state = CHECK_OK if jq is not None else CHECK_FAIL
165 jq_detail = run_process([jq, "--version"], cwd=cwd).stdout.strip() if jq else "missing"
166 kind = "linked worktree" if paths.is_linked_worktree else "main worktree"
167 return [
168 Check("python", python_state, f"{version} (minimum {MIN_PYTHON[0]}.{MIN_PYTHON[1]})"),
169 Check("git", CHECK_OK, git_version),
170 Check("jq", jq_state, jq_detail),
171 Check("repository", CHECK_OK, f"{paths.toplevel} ({kind})"),
172 Check(
173 "canonical workspace tool",
174 CHECK_OK if script.is_file() else CHECK_FAIL,
175 str(script),
176 ),
177 Check(
178 "workspace root",
179 CHECK_OK if writable else CHECK_FAIL,
180 f"{root} (nearest ancestor {ancestor} is {'writable' if writable else 'NOT writable'})",
181 ),
182 _probe_to_check(probe_version()),
183 _probe_to_check(probe_auth()),
184 ]
185
186
187def cmd_doctor(_options: argparse.Namespace) -> int:
188 """Report local readiness; ``gh auth status`` may perform a read-only API probe."""
189 cwd = Path.cwd()
190 paths = discover_repo(cwd)
191 checks = _doctor_checks(paths, cwd)
192 width = max(len(check.name) for check in checks)
193 for check in checks:
194 _print(f" {check.state:<12} {check.name:<{width}} {check.detail}")
195 for check in checks:
196 if check.state in (STATE_DEGRADED, STATE_UNAVAILABLE):
197 _print(f"notice: {check.name} is {check.state.lower()} -- {check.detail}")
198 failed = [check for check in checks if check.state == CHECK_FAIL]
199 if failed:
200 _fail(f"work doctor: {len(failed)} check(s) FAILED")
201 return EXIT_FAIL
202 return EXIT_OK
203
204
205def _reject_nonregular_destination(path: Path) -> None:
206 """Reject an existing destination unless ``lstat`` proves it is regular."""
207 try:
208 mode = path.lstat().st_mode
209 except FileNotFoundError:
210 return
211 if not stat.S_ISREG(mode):
212 message = f"refusing to replace destination that is not a regular file: {path}"
213 raise WorkError(message)
214
215
216def _write_text_atomic(path: Path, text: str) -> None:
217 """Write derived output atomically without opening a nonregular destination."""
218 _reject_nonregular_destination(path)
219 path.parent.mkdir(parents=True, exist_ok=True)
220 descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
221 try:
222 with os.fdopen(descriptor, "w", encoding="ascii") as handle:
223 handle.write(text)
224 handle.flush()
225 os.fsync(handle.fileno())
226 _reject_nonregular_destination(path)
227 Path(temporary).replace(path)
228 finally:
229 with contextlib.suppress(FileNotFoundError):
230 Path(temporary).unlink()
231
232
233def _plan_output_problem(options: argparse.Namespace) -> str:
234 """Return why the requested stdout artifacts are ambiguous, if they are."""
235 stdout_modes = sum(
236 (
237 options.json == "-",
238 options.emit_commands,
239 options.summary,
240 )
241 )
242 if stdout_modes > 1:
243 return "--json -, --emit-commands, and --summary are mutually exclusive stdout modes"
244 return ""
245
246
247def cmd_plan(options: argparse.Namespace) -> int:
248 """Validate notes and emit deterministic artifacts."""
249 if problem := _plan_output_problem(options):
250 _fail(f"work plan: {problem}")
251 return EXIT_CONFIG
252 notes = Path(options.notes).expanduser()
253 try:
254 plan = load_plan(notes)
255 except PlanError as exc:
256 for problem in exc.problems:
257 _fail(f"{notes}: {problem}")
258 _fail(f"{notes}: {len(exc.problems)} problem(s); nothing was emitted")
259 return EXIT_FAIL
260 emitted = False
261 if options.json is not None:
262 text = plan_to_json(plan)
263 if options.json == "-":
264 sys.stdout.write(text)
265 else:
266 _write_text_atomic(Path(options.json), text)
267 _notice(f"wrote {options.json}")
268 emitted = True
269 if options.emit_commands:
270 sys.stdout.write(render_commands(plan))
271 emitted = True
272 if options.summary or not emitted:
273 sys.stdout.write(render_summary(plan))
274 return EXIT_OK
275
276
277def _build_start_plan(paths: RepoPaths, cwd: Path, options: argparse.Namespace) -> StartPlan:
278 """Build a read-only preview; canonical creation repeats checks under its lock."""
279 root = _ws_root(options.ws_root)
280 identifier = options.identifier
281 name = workspace_name(identifier)
282 branch = branch_name(identifier)
283 target = root / name
284 base = resolve_commit(options.ref, cwd=cwd)
285 if base is not None:
286 reject_executable_attributes(cwd, base)
287 refusals: list[str] = []
288 if base is None:
289 refusals.append(f"start ref does not resolve locally: {options.ref}")
290 if target.exists() or target.is_symlink():
291 refusals.append(f"workspace path already exists: {target}")
292 claim_path = metadata_path(root, identifier)
293 if claim_path.exists() or claim_path.is_symlink():
294 refusals.append(f"canonical workspace metadata already exists for {name}")
295 if branch_exists(branch, cwd=cwd):
296 refusals.append(f"branch already exists: {branch}")
297 if target.parent != root:
298 refusals.append("derived workspace is not a direct child of the canonical root")
299 if not _workspace_script(paths).is_file():
300 refusals.append("canonical workspace script is absent")
301 return StartPlan(identifier, name, branch, target, root, options.ref, base, tuple(refusals))
302
303
304def _describe_start(paths: RepoPaths, start: StartPlan) -> None:
305 """Print one mutation-free canonical-workspace preview."""
306 _print("work start: DRY RUN -- nothing has been created.")
307 _print(f" identifier {start.identifier}")
308 _print(f" canonical {start.name}")
309 _print(f" branch {start.branch}")
310 _print(f" worktree {start.target}")
311 _print(f" start ref {start.ref} -> {start.base_commit or 'unresolved'}")
312 _print(f" metadata {metadata_path(start.ws_root, start.identifier)}")
313 _print(f" creator {_workspace_script(paths)}")
314 _print("Re-run with --execute to ask the canonical workspace lifecycle to create it.")
315
316
317def _execute_start(paths: RepoPaths, start: StartPlan) -> int:
318 """Delegate creation to the sole workspace lock/state authority."""
319 if start.base_commit is None:
320 return EXIT_FAIL
321 argv = [
322 "/bin/bash",
323 "-p",
324 str(_workspace_script(paths)),
325 "create",
326 start.name,
327 start.base_commit,
328 "--branch",
329 start.branch,
330 "--owner",
331 OWNER,
332 ]
333 environment = git_child_environment()
334 environment[WS_ROOT_ENV] = str(start.ws_root)
335 environment["RA8_WS_UPSTREAM"] = str(paths.toplevel)
336 done = run_process(argv, cwd=paths.toplevel, timeout=300, env=environment)
337 _print_block(done.stdout)
338 if not done.ok:
339 _print_block(done.stderr, error=True)
340 return EXIT_FAIL
341 return EXIT_OK
342
343
344def cmd_start(options: argparse.Namespace) -> int:
345 """Preview or canonically create one identifier-owned workspace."""
346 if not is_identifier(options.identifier):
347 _fail(f"work start: refusing invalid identifier {options.identifier!r}")
348 return EXIT_CONFIG
349 cwd = Path.cwd()
350 paths = discover_repo(cwd)
351 start = _build_start_plan(paths, cwd, options)
352 _describe_start(paths, start)
353 if start.refusals:
354 for refusal in start.refusals:
355 _fail(f"REFUSE: {refusal}")
356 return EXIT_FAIL
357 return _execute_start(paths, start) if options.execute else EXIT_OK
358
359
360def _claim_for(identifier: str, paths: RepoPaths, root: Path) -> Claim:
361 """Load one canonical claim and require exact branch-to-worktree binding."""
362 claim = load_claim(metadata_path(root, identifier), root)
363 verdict = classify(claim, paths.toplevel)
364 if verdict != READY:
365 message = f"canonical claim is {verdict}"
366 raise ClaimError(message)
367 return claim
368
369
370def cmd_status(options: argparse.Namespace) -> int:
371 """Report only work-owned records from canonical workspace metadata."""
372 paths = discover_repo(Path.cwd())
373 root = _ws_root(options.ws_root)
374 records = list_claims(root)
375 if not records:
376 _print(f"work status: no work-owned canonical metadata under {metadata_dir(root)}")
377 return EXIT_OK
378 forged = False
379 for path, value in records:
380 if isinstance(value, ClaimError):
381 _print(f" {FORGED:<9} {path.name:<24} {value}")
382 forged = True
383 continue
384 verdict = classify(value, paths.toplevel)
385 _print(f" {verdict:<9} {value.identifier:<24} {value.branch:<28} {value.worktree}")
386 forged = forged or verdict == FORGED
387 if verdict in (STALE, FOREIGN):
388 command = recovery_command(value, paths.toplevel, stale=verdict == STALE)
389 _print(f" after human review: {command}")
390 return EXIT_FAIL if forged else EXIT_OK
391
392
393def _remote_ci(paths: RepoPaths, workspace: Path, head: str) -> tuple[str, str]:
394 """Read the shared monitor's cached verdict without polling GitHub."""
395 monitor = paths.toplevel / "scripts/ci/monitor.sh"
396 if not monitor.is_file():
397 return "UNKNOWN", "shared CI monitor script is absent"
398 result = run_process(
399 ["/bin/bash", "-p", str(monitor), "status", "--sha", head],
400 cwd=workspace,
401 timeout=30,
402 )
403 state = {0: "PASS", 1: "FAIL", 3: "UNKNOWN"}.get(result.returncode, "UNKNOWN")
404 detail = next(
405 (line for line in result.stdout.splitlines() if line.strip()), "no monitor detail"
406 )
407 return state, detail
408
409
410def _workspace_report(claim: Claim, phase: str) -> tuple[str | None, list[str]]:
411 """Print shared workspace facts and return its HEAD plus dirty paths."""
412 dirty = porcelain_status(claim.worktree)
413 head = resolve_commit("HEAD", cwd=claim.worktree)
414 if head is None:
415 _fail(f"work {phase}: workspace HEAD does not resolve")
416 return None, dirty
417 _print(f"work {phase}: {claim.identifier}")
418 _print(f" branch {claim.branch}")
419 _print(f" worktree {claim.worktree}")
420 _print(f" base {claim.base_ref} ({claim.base_commit})")
421 _print(f" claimed {claim.created} by {claim.creator}")
422 _print(f" HEAD {head}")
423 _print(f" working set {len(dirty)} changed path(s)")
424 _print(" diffstat vs base:")
425 for line in diff_stat(claim.worktree, claim.base_commit).splitlines() or ["(empty)"]:
426 _print(f" {line}")
427 return head, dirty
428
429
430def _clean_claim(options: argparse.Namespace, phase: str) -> tuple[RepoPaths, Claim] | None:
431 """Load one exact claim and require a clean committed working tree."""
432 if not is_identifier(options.identifier):
433 _fail(f"work {phase}: refusing invalid identifier {options.identifier!r}")
434 return None
435 paths = discover_repo(Path.cwd())
436 root = _ws_root(options.ws_root)
437 try:
438 claim = _claim_for(options.identifier, paths, root)
439 except ClaimError as exc:
440 _fail(f"work {phase}: {exc}")
441 return None
442 _head, dirty = _workspace_report(claim, phase)
443 if dirty:
444 _fail(f"work {phase}: REFUSE -- commit or intentionally discard the working set first")
445 _fail("just ci validates committed HEAD, not these uncommitted bytes")
446 return None
447 return paths, claim
448
449
450def cmd_ready(options: argparse.Namespace) -> int:
451 """Run exact local CI for a clean committed work claim before the sole push."""
452 loaded = _clean_claim(options, "ready")
453 if loaded is None:
454 return EXIT_FAIL
455 _paths, claim = loaded
456 if not options.run_ci:
457 _fail("work ready: REFUSE -- pass --run-ci to record exact local gate evidence")
458 return EXIT_FAIL
459 just = shutil.which("just")
460 if just is None:
461 _fail("work ready: just is not installed")
462 return EXIT_FAIL
463 _print(" local CI RUNNING: just ci")
464 result = run_process([just, "ci"], cwd=claim.worktree, timeout=7200)
465 _print_block(result.stdout)
466 if not result.ok:
467 _print_block(result.stderr, error=True)
468 _fail(f"work ready: local CI did not PASS (exit {result.returncode})")
469 return EXIT_FAIL
470 head = resolve_commit("HEAD", cwd=claim.worktree)
471 _print(f" local CI PASS: just ci at {head}")
472 _print("work ready: review/squash this tree, then perform the one authorized push")
473 return EXIT_OK
474
475
476def cmd_landed(options: argparse.Namespace) -> int:
477 """Require content-equivalent pushed dev and cached PASS after the push."""
478 loaded = _clean_claim(options, "landed")
479 if loaded is None:
480 return EXIT_FAIL
481 paths, claim = loaded
482 claim_head = resolve_commit("HEAD", cwd=claim.worktree)
483 dev_head = resolve_commit("origin/dev", cwd=claim.worktree)
484 claim_tree = resolve_tree(claim_head, cwd=claim.worktree) if claim_head else None
485 dev_tree = resolve_tree(dev_head, cwd=claim.worktree) if dev_head else None
486 if dev_head is None or claim_tree is None or claim_tree != dev_tree:
487 _fail("work landed: origin/dev is absent or not content-equivalent to this work claim")
488 return EXIT_FAIL
489 ci_state, ci_detail = _remote_ci(paths, claim.worktree, dev_head)
490 _print(f" remote CI {ci_state}: {ci_detail}")
491 if ci_state != "PASS":
492 _fail("work landed: remote CI is not PASS; Landed would be false")
493 return EXIT_FAIL
494 _print(f"work landed: origin/dev {dev_head} is content-equivalent and remotely green")
495 _print(
496 " now set the board card to Landed, close citing the squash SHA, "
497 "then review branch cleanup"
498 )
499 return EXIT_OK
500
501
502HANDLERS = {
503 "doctor": cmd_doctor,
504 "plan": cmd_plan,
505 "start": cmd_start,
506 "status": cmd_status,
507 "ready": cmd_ready,
508 "landed": cmd_landed,
509}
510
511
512def build_parser() -> argparse.ArgumentParser:
513 """Build the command-line parser."""
514 parser = argparse.ArgumentParser(prog="work", description=__doc__.splitlines()[0])
515 parser.add_argument("--selftest", action="store_true", help="run the offline tests")
516 sub = parser.add_subparsers(dest="command", metavar="<command>")
517 sub.add_parser("doctor", help="report local and emitted-script readiness")
518 plan = sub.add_parser("plan", help="validate notes and emit an ordered plan")
519 plan.add_argument("notes")
520 plan.add_argument("--json", metavar="PATH")
521 stdout = plan.add_mutually_exclusive_group()
522 stdout.add_argument("--emit-commands", action="store_true")
523 stdout.add_argument("--summary", action="store_true")
524 start = sub.add_parser("start", help="preview or canonically create one workspace")
525 start.add_argument("identifier")
526 start.add_argument("--ref", default="HEAD")
527 start.add_argument("--execute", action="store_true")
528 start.add_argument("--ws-root", metavar="DIR")
529 status = sub.add_parser("status", help="list canonical work-owned workspaces")
530 status.add_argument("--ws-root", metavar="DIR")
531 ready = sub.add_parser("ready", help="run exact local CI before the sole push")
532 ready.add_argument("identifier")
533 ready.add_argument("--ws-root", metavar="DIR")
534 ready.add_argument("--run-ci", action="store_true")
535 landed = sub.add_parser("landed", help="require content-equivalent green origin/dev")
536 landed.add_argument("identifier")
537 landed.add_argument("--ws-root", metavar="DIR")
538 return parser
539
540
541def run_selftest() -> int:
542 """Run tests and fail if discovery silently collapses below its floor."""
543 tests = Path(__file__).resolve().parents[1] / "tests"
544 suite = unittest.TestLoader().discover(str(tests), top_level_dir=str(tests))
545 result = unittest.TextTestRunner(verbosity=2).run(suite)
546 if result.testsRun < SELFTEST_MINIMUM:
547 _fail(f"work selftest: discovered {result.testsRun} tests, below floor {SELFTEST_MINIMUM}")
548 return EXIT_FAIL
549 return EXIT_OK if result.wasSuccessful() else EXIT_FAIL
550
551
552def main(argv: list[str]) -> int:
553 """Dispatch one command with redacted failures."""
554 parser = build_parser()
555 options = parser.parse_args(argv)
556 if options.selftest:
557 return run_selftest()
558 if options.command is None:
559 parser.print_help()
560 return EXIT_CONFIG
561 try:
562 return HANDLERS[options.command](options)
563 except (ToolMissingError, GitCommandError, ClaimError, WorkError, OSError) as exc:
564 _fail(f"work: {redact(str(exc))}")
565 return EXIT_CONFIG
566
567
568if __name__ == "__main__":
569 raise SystemExit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298