mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/telegramdesktop/tdesktop
synced 2026-09-20 08:03:45 +08:00
2478 lines
71 KiB
Python
Executable File
2478 lines
71 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
TASK_ID_PATTERN = re.compile(
|
|
r"[0-9]{4}/[0-9]{2}/[0-9]{2}/[a-z0-9][a-z0-9-]*"
|
|
)
|
|
VALID_STATUSES = {"todo", "in-progress", "approved", "blocked"}
|
|
DEFAULT_TASK_TYPE = "implement"
|
|
VALID_TASK_TYPES = {DEFAULT_TASK_TYPE, "verify"}
|
|
VALID_FINDINGS = {"confirmed", "deviation", "inconclusive"}
|
|
COMMIT_HASH_PATTERN = re.compile(
|
|
r"(?i)\b(?:commit|revision|sha(?:-1)?)\b[^\r\n]{0,32}(?<!#)\b[0-9a-f]{7,64}\b"
|
|
)
|
|
LEGACY_COMMIT_FIELDS = ("Task-Base-SHA:", "Implementation-SHA:")
|
|
PROJECT_ARCHIVE_DIR = "archive"
|
|
PROJECT_LINK_PATTERN = re.compile(r"\]\(\.\./\.\./(?!\.\./)")
|
|
ARCHIVED_PROJECT_LINK_PATTERN = re.compile(r"\]\(\.\./\.\./\.\./")
|
|
STATE_FIELD_ORDER = [
|
|
"status",
|
|
"type",
|
|
"created",
|
|
"project",
|
|
"depends_on",
|
|
"claimed_by",
|
|
"claimed_at",
|
|
"claim_order",
|
|
"lease_until",
|
|
"phase",
|
|
"inbox_receipt",
|
|
]
|
|
PORTABLE_GOLDEN = "test_TelegramForcePortable"
|
|
PORTABLE_LIVE = "TelegramForcePortable"
|
|
PORTABLE_REAL = "real_TelegramForcePortable"
|
|
PORTABLE_MARKER = "testing"
|
|
OVERLAY_PATHS_FILE = "test-overlay.paths"
|
|
OVERLAY_PATCH_FILE = "test-overlay.patch"
|
|
TEST_LOG_FILE = "test_log.txt"
|
|
TEST_COMPLETE_MARKER = "TEST_COMPLETE"
|
|
STALE_CRASH_DIR = "stale-crash"
|
|
|
|
|
|
class WorkspaceError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def run_git(path, *args, check=True):
|
|
result = subprocess.run(
|
|
["git", "-C", str(path), *args],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
if check and result.returncode:
|
|
raise WorkspaceError(result.stderr.strip() or result.stdout.strip())
|
|
return result
|
|
|
|
|
|
def git_root(path):
|
|
result = run_git(path, "rev-parse", "--show-toplevel")
|
|
return Path(result.stdout.strip()).resolve()
|
|
|
|
|
|
def absolute_git_dir(path):
|
|
result = run_git(path, "rev-parse", "--git-common-dir")
|
|
value = Path(result.stdout.strip())
|
|
if not value.is_absolute():
|
|
value = Path(path) / value
|
|
return value.resolve()
|
|
|
|
|
|
def source_root(value):
|
|
start = Path(value).expanduser().resolve() if value else Path.cwd()
|
|
root = git_root(start)
|
|
if not (root / "Telegram" / "build").is_dir():
|
|
raise WorkspaceError(f"Not a Telegram Desktop checkout: {root}")
|
|
return root
|
|
|
|
|
|
def read_machine_tag(root):
|
|
path = root / "Telegram" / "build" / "ai-machine-tag"
|
|
if not path.is_file():
|
|
raise WorkspaceError(
|
|
f"Missing {path}. Create it with a stable lowercase machine tag."
|
|
)
|
|
value = path.read_text(encoding="utf-8").strip()
|
|
if not TAG_PATTERN.fullmatch(value):
|
|
raise WorkspaceError(
|
|
f"Invalid machine tag {value!r}; use lowercase letters, digits, and hyphens."
|
|
)
|
|
return value
|
|
|
|
|
|
def repository_config(args, create=False):
|
|
root = source_root(args.source_root)
|
|
machine = read_machine_tag(root)
|
|
checkout = root.name
|
|
if not TAG_PATTERN.fullmatch(checkout):
|
|
raise WorkspaceError(
|
|
f"Invalid checkout folder {checkout!r}; use lowercase letters, digits, and hyphens."
|
|
)
|
|
|
|
main_value = args.ai_main or os.environ.get("AI_TDESKTOP_ROOT")
|
|
main = (
|
|
Path(main_value).expanduser().resolve()
|
|
if main_value
|
|
else (root.parent / "ai-tdesktop").resolve()
|
|
)
|
|
worktrees_value = args.worktrees_root or os.environ.get(
|
|
"AI_TDESKTOP_WORKTREES_ROOT"
|
|
)
|
|
worktrees = (
|
|
Path(worktrees_value).expanduser().resolve()
|
|
if worktrees_value
|
|
else (root.parent / "ai-tdesktop-worktrees").resolve()
|
|
)
|
|
|
|
if not (main / ".git").exists():
|
|
raise WorkspaceError(f"Missing ai-tdesktop repository: {main}")
|
|
if git_root(main) != main:
|
|
raise WorkspaceError(f"ai-tdesktop is not a worktree root: {main}")
|
|
|
|
main_branch = run_git(main, "branch", "--show-current").stdout.strip()
|
|
if main_branch != "master":
|
|
raise WorkspaceError(
|
|
f"The main ai-tdesktop worktree must be on master, not {main_branch!r}."
|
|
)
|
|
|
|
tag = f"{machine}-{checkout}"
|
|
if create:
|
|
inbox = main / "inbox"
|
|
(inbox / "backup").mkdir(parents=True, exist_ok=True)
|
|
(inbox / "inbox.md").touch(exist_ok=True)
|
|
worktrees.mkdir(parents=True, exist_ok=True)
|
|
return {
|
|
"source_root": str(root),
|
|
"machine_tag": machine,
|
|
"checkout_folder": checkout,
|
|
"checkout_tag": tag,
|
|
"ai_main": str(main),
|
|
"worktrees_root": str(worktrees),
|
|
"inbox": str(main / "inbox"),
|
|
}
|
|
|
|
|
|
def linked_worktree(config, path, branch, create, label):
|
|
main = Path(config["ai_main"])
|
|
if create and not path.exists():
|
|
branch_exists = run_git(
|
|
main,
|
|
"show-ref",
|
|
"--verify",
|
|
"--quiet",
|
|
f"refs/heads/{branch}",
|
|
check=False,
|
|
).returncode == 0
|
|
arguments = ["worktree", "add"]
|
|
if not branch_exists:
|
|
arguments.extend(["-b", branch])
|
|
arguments.extend([str(path), branch if branch_exists else "master"])
|
|
run_git(main, *arguments)
|
|
if not path.is_dir():
|
|
raise WorkspaceError(f"Missing {label}: {path}")
|
|
if absolute_git_dir(main) != absolute_git_dir(path):
|
|
raise WorkspaceError(
|
|
f"The {label} does not belong to {main}: {path}"
|
|
)
|
|
actual_branch = run_git(path, "branch", "--show-current").stdout.strip()
|
|
if actual_branch != branch:
|
|
raise WorkspaceError(
|
|
f"Expected {path} on {branch}, found {actual_branch!r}."
|
|
)
|
|
|
|
|
|
def worktree_config(args, create=False):
|
|
config = repository_config(args, create=create)
|
|
tag = config["checkout_tag"]
|
|
branch = f"slot/{tag}"
|
|
slot = Path(config["worktrees_root"]) / tag
|
|
linked_worktree(config, slot, branch, create, "checkout AI worktree")
|
|
return {
|
|
**config,
|
|
"slot_worktree": str(slot),
|
|
"slot_branch": branch,
|
|
}
|
|
|
|
|
|
def inbox_worktree_config(args, create=False):
|
|
config = repository_config(args, create=create)
|
|
tag = config["checkout_tag"]
|
|
branch = f"inbox/{tag}"
|
|
worktree = Path(config["worktrees_root"]) / f"{tag}-inbox"
|
|
linked_worktree(config, worktree, branch, create, "inbox AI worktree")
|
|
return {
|
|
**config,
|
|
"inbox_worktree": str(worktree),
|
|
"inbox_branch": branch,
|
|
}
|
|
|
|
|
|
def ensure_clean(path, label):
|
|
status = run_git(path, "status", "--porcelain", "--untracked-files=all").stdout
|
|
if status.strip():
|
|
raise WorkspaceError(f"{label} is not clean:\n{status.rstrip()}")
|
|
|
|
|
|
def sync_local_slot(config):
|
|
sync_branch_worktree(
|
|
config,
|
|
"slot_worktree",
|
|
"slot_branch",
|
|
"ai-tdesktop slot",
|
|
)
|
|
|
|
|
|
def sync_inbox_worktree(config):
|
|
sync_branch_worktree(
|
|
config,
|
|
"inbox_worktree",
|
|
"inbox_branch",
|
|
"ai-tdesktop inbox worktree",
|
|
)
|
|
|
|
|
|
def sync_branch_worktree(config, worktree_key, branch_key, label):
|
|
main = Path(config["ai_main"])
|
|
worktree = Path(config[worktree_key])
|
|
branch = config[branch_key]
|
|
ensure_clean(main, "ai-tdesktop master")
|
|
ensure_clean(worktree, label)
|
|
counts = run_git(
|
|
worktree,
|
|
"rev-list",
|
|
"--left-right",
|
|
"--count",
|
|
f"master...{branch}",
|
|
).stdout.strip().split()
|
|
master_only, worktree_only = (int(value) for value in counts)
|
|
if worktree_only:
|
|
raise WorkspaceError(
|
|
f"{branch} has {worktree_only} unpublished commit(s)."
|
|
)
|
|
if master_only:
|
|
run_git(worktree, "merge", "--ff-only", "master")
|
|
|
|
|
|
def has_origin(path):
|
|
return run_git(path, "remote", "get-url", "origin", check=False).returncode == 0
|
|
|
|
|
|
def origin_master_exists(path):
|
|
return run_git(
|
|
path,
|
|
"show-ref",
|
|
"--verify",
|
|
"--quiet",
|
|
"refs/remotes/origin/master",
|
|
check=False,
|
|
).returncode == 0
|
|
|
|
|
|
def retryable_push_failure(message):
|
|
lowered = message.lower()
|
|
return "non-fast-forward" in lowered or "fetch first" in lowered
|
|
|
|
|
|
def sync_canonical(config):
|
|
main = Path(config["ai_main"])
|
|
ensure_clean(main, "ai-tdesktop master")
|
|
ensure_clean(Path(config["slot_worktree"]), "ai-tdesktop slot")
|
|
if has_origin(main):
|
|
run_git(main, "fetch", "origin")
|
|
if not origin_master_exists(main):
|
|
raise WorkspaceError("origin/master does not exist")
|
|
run_git(main, "merge", "--ff-only", "origin/master")
|
|
sync_local_slot(config)
|
|
|
|
|
|
def state_paths(root):
|
|
tasks = root / "tasks"
|
|
if not tasks.is_dir():
|
|
return []
|
|
return sorted(tasks.glob("*/*/*/*/state.yaml"))
|
|
|
|
|
|
def task_id_for_state(root, path):
|
|
relative = path.relative_to(root)
|
|
parts = relative.parts
|
|
if len(parts) != 6 or parts[0] != "tasks" or parts[-1] != "state.yaml":
|
|
raise WorkspaceError(f"Invalid task state path: {path}")
|
|
value = "/".join(parts[1:5])
|
|
if not TASK_ID_PATTERN.fullmatch(value):
|
|
raise WorkspaceError(f"Invalid task identifier: {value}")
|
|
return value
|
|
|
|
|
|
def state_path(root, task_id):
|
|
if not TASK_ID_PATTERN.fullmatch(task_id):
|
|
raise WorkspaceError(f"Invalid task identifier: {task_id!r}")
|
|
path = root / "tasks" / task_id / "state.yaml"
|
|
if not path.is_file():
|
|
raise WorkspaceError(f"Task does not exist: {task_id}")
|
|
return path
|
|
|
|
|
|
def parse_scalar(value):
|
|
value = value.strip()
|
|
if value == "null":
|
|
return None
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def parse_dependencies(value):
|
|
value = value.strip()
|
|
if value == "[]":
|
|
return []
|
|
if not value.startswith("[") or not value.endswith("]"):
|
|
raise WorkspaceError(f"depends_on must be an inline YAML list, found {value!r}")
|
|
return [
|
|
str(parse_scalar(part))
|
|
for part in value[1:-1].split(",")
|
|
if part.strip()
|
|
]
|
|
|
|
|
|
def load_state(root, path):
|
|
values = {}
|
|
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
if not line or line[0].isspace() or ":" not in line:
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
values[key] = value.strip()
|
|
for required in ("status", "created", "project", "depends_on", "claimed_by"):
|
|
if required not in values:
|
|
raise WorkspaceError(f"Missing {required} in {path}")
|
|
status = str(parse_scalar(values["status"]))
|
|
if status not in VALID_STATUSES:
|
|
raise WorkspaceError(f"Invalid status {status!r} in {path}")
|
|
kind = parse_scalar(values.get("type", DEFAULT_TASK_TYPE))
|
|
kind = DEFAULT_TASK_TYPE if kind is None else str(kind)
|
|
if kind not in VALID_TASK_TYPES:
|
|
raise WorkspaceError(f"Invalid task type {kind!r} in {path}")
|
|
project = parse_scalar(values["project"])
|
|
if project is not None and not TAG_PATTERN.fullmatch(str(project)):
|
|
raise WorkspaceError(f"Invalid project slug {project!r} in {path}")
|
|
order = parse_scalar(values.get("claim_order", "null"))
|
|
if order is not None:
|
|
try:
|
|
order = int(order)
|
|
except ValueError as error:
|
|
raise WorkspaceError(f"Invalid claim_order in {path}: {order!r}") from error
|
|
task_id = task_id_for_state(root, path)
|
|
task_file = path.with_name("task.md")
|
|
title = task_id.rsplit("/", 1)[-1]
|
|
if task_file.is_file():
|
|
for line in task_file.read_text(encoding="utf-8-sig").splitlines():
|
|
if line.startswith("# "):
|
|
title = line[2:].strip()
|
|
break
|
|
return {
|
|
"id": task_id,
|
|
"title": title,
|
|
"status": status,
|
|
"type": kind,
|
|
"created": str(parse_scalar(values["created"])),
|
|
"project": project,
|
|
"depends_on": parse_dependencies(values["depends_on"]),
|
|
"claimed_by": parse_scalar(values["claimed_by"]),
|
|
"claimed_at": parse_scalar(values.get("claimed_at", "null")),
|
|
"claim_order": order,
|
|
"lease_until": parse_scalar(values.get("lease_until", "null")),
|
|
"phase": parse_scalar(values.get("phase", "null")),
|
|
"state_path": str(path),
|
|
}
|
|
|
|
|
|
def load_states(root):
|
|
return {
|
|
state["id"]: state
|
|
for state in (load_state(root, path) for path in state_paths(root))
|
|
}
|
|
|
|
|
|
def format_scalar(value):
|
|
if value is None:
|
|
return "null"
|
|
return str(value)
|
|
|
|
|
|
def update_state(path, changes):
|
|
data = path.read_bytes()
|
|
if data.startswith(b"\xef\xbb\xbf"):
|
|
raise WorkspaceError(f"Refusing to preserve a UTF-8 BOM in {path}")
|
|
newline = "\r\n" if b"\r\n" in data else "\n"
|
|
lines = data.decode("utf-8").splitlines()
|
|
positions = {}
|
|
for index, line in enumerate(lines):
|
|
if line and not line[0].isspace() and ":" in line:
|
|
positions[line.split(":", 1)[0]] = index
|
|
for key, value in changes.items():
|
|
line = f"{key}: {format_scalar(value)}"
|
|
if key in positions:
|
|
lines[positions[key]] = line
|
|
continue
|
|
if key not in STATE_FIELD_ORDER:
|
|
raise WorkspaceError(f"Unknown task state field: {key}")
|
|
order = STATE_FIELD_ORDER.index(key)
|
|
insert_at = len(lines)
|
|
for later in STATE_FIELD_ORDER[order + 1:]:
|
|
if later in positions:
|
|
insert_at = positions[later]
|
|
break
|
|
lines.insert(insert_at, line)
|
|
positions = {
|
|
name: index + (1 if index >= insert_at else 0)
|
|
for name, index in positions.items()
|
|
}
|
|
positions[key] = insert_at
|
|
path.write_bytes((newline.join(lines) + newline).encode("utf-8"))
|
|
|
|
|
|
def task_ready(task, states):
|
|
return all(
|
|
dependency in states and states[dependency]["status"] == "approved"
|
|
for dependency in task["depends_on"]
|
|
)
|
|
|
|
|
|
def queue_sort_key(task):
|
|
return (
|
|
task["claimed_at"] or "9999",
|
|
task["claim_order"] if task["claim_order"] is not None else 1_000_000,
|
|
task["created"],
|
|
task["id"],
|
|
)
|
|
|
|
|
|
def changed_paths(path):
|
|
result = set()
|
|
for arguments in (
|
|
("diff", "--name-only"),
|
|
("diff", "--cached", "--name-only"),
|
|
("ls-files", "--others", "--exclude-standard"),
|
|
):
|
|
for value in run_git(path, *arguments).stdout.splitlines():
|
|
if value:
|
|
result.add(value)
|
|
return sorted(result)
|
|
|
|
|
|
def unpublished_counts(config):
|
|
values = run_git(
|
|
config["slot_worktree"],
|
|
"rev-list",
|
|
"--left-right",
|
|
"--count",
|
|
f"master...{config['slot_branch']}",
|
|
).stdout.strip().split()
|
|
return {"master_only": int(values[0]), "slot_only": int(values[1])}
|
|
|
|
|
|
def task_relative_dir(task_id):
|
|
return f"tasks/{task_id}"
|
|
|
|
|
|
def source_task_ref(task_id, name):
|
|
if not TASK_ID_PATTERN.fullmatch(task_id):
|
|
raise WorkspaceError(f"Invalid task identifier: {task_id!r}")
|
|
return f"refs/ai-tasks/{task_id}/{name}"
|
|
|
|
|
|
def resolved_ref(path, value):
|
|
result = run_git(
|
|
path,
|
|
"rev-parse",
|
|
"--verify",
|
|
f"{value}^{{commit}}",
|
|
check=False,
|
|
)
|
|
return result.stdout.strip() if not result.returncode else None
|
|
|
|
|
|
def ensure_changes_scoped(slot, task_ids, extra_paths=()):
|
|
allowed = tuple(task_relative_dir(task_id) + "/" for task_id in task_ids)
|
|
unexpected = [
|
|
path for path in changed_paths(slot)
|
|
if not path.startswith(allowed) and path not in extra_paths
|
|
]
|
|
if unexpected:
|
|
raise WorkspaceError(
|
|
"The AI slot has changes outside the active task: "
|
|
+ ", ".join(unexpected)
|
|
)
|
|
|
|
|
|
def update_main_from_origin(config):
|
|
main = Path(config["ai_main"])
|
|
ensure_clean(main, "ai-tdesktop master")
|
|
if has_origin(main):
|
|
run_git(main, "fetch", "origin")
|
|
if not origin_master_exists(main):
|
|
raise WorkspaceError("origin/master does not exist")
|
|
run_git(main, "merge", "--ff-only", "origin/master")
|
|
|
|
|
|
def sync_inbox_canonical(config):
|
|
update_main_from_origin(config)
|
|
sync_inbox_worktree(config)
|
|
|
|
|
|
def publish_worktree(config, worktree_key, branch_key, label):
|
|
main = Path(config["ai_main"])
|
|
worktree = Path(config[worktree_key])
|
|
branch = config[branch_key]
|
|
ensure_clean(main, "ai-tdesktop master")
|
|
ensure_clean(worktree, label)
|
|
while True:
|
|
update_main_from_origin(config)
|
|
rebase = run_git(worktree, "rebase", "master", check=False)
|
|
if rebase.returncode:
|
|
run_git(worktree, "rebase", "--abort", check=False)
|
|
raise WorkspaceError(
|
|
"AI state conflicts with newer master; the worktree commits were preserved. "
|
|
+ (rebase.stderr.strip() or rebase.stdout.strip())
|
|
)
|
|
if has_origin(main):
|
|
push = run_git(worktree, "push", "origin", "HEAD:master", check=False)
|
|
if push.returncode:
|
|
message = push.stderr.strip() or push.stdout.strip()
|
|
if retryable_push_failure(message):
|
|
continue
|
|
raise WorkspaceError(message)
|
|
merge = run_git(main, "merge", "--ff-only", branch, check=False)
|
|
if not merge.returncode:
|
|
return True
|
|
master_advanced = run_git(
|
|
main,
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
"master",
|
|
branch,
|
|
check=False,
|
|
).returncode != 0
|
|
if master_advanced:
|
|
continue
|
|
raise WorkspaceError(
|
|
"Could not fast-forward local AI master: "
|
|
+ (merge.stderr.strip() or merge.stdout.strip())
|
|
)
|
|
|
|
|
|
def publish_slot(config):
|
|
return publish_worktree(
|
|
config,
|
|
"slot_worktree",
|
|
"slot_branch",
|
|
"ai-tdesktop slot",
|
|
)
|
|
|
|
|
|
def publish_inbox(config):
|
|
return publish_worktree(
|
|
config,
|
|
"inbox_worktree",
|
|
"inbox_branch",
|
|
"ai-tdesktop inbox worktree",
|
|
)
|
|
|
|
|
|
def commit_paths(config, paths, subject):
|
|
slot = Path(config["slot_worktree"])
|
|
for path in paths:
|
|
run_git(slot, "add", "--", path)
|
|
if run_git(slot, "diff", "--cached", "--quiet", check=False).returncode == 0:
|
|
raise WorkspaceError("No AI task state changed")
|
|
run_git(slot, "commit", "-m", subject)
|
|
return publish_slot(config)
|
|
|
|
|
|
def task_summary(task, states):
|
|
return {
|
|
**task,
|
|
"ready": task_ready(task, states),
|
|
}
|
|
|
|
|
|
def normalized_task_name(value):
|
|
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
|
|
|
|
def resolve_task(states, value):
|
|
if TASK_ID_PATTERN.fullmatch(value):
|
|
if value not in states:
|
|
raise WorkspaceError(f"Task does not exist: {value}")
|
|
return states[value]
|
|
name = normalized_task_name(value)
|
|
if not name:
|
|
raise WorkspaceError("Task name is empty")
|
|
exact = [
|
|
task for task in states.values()
|
|
if task["id"].rsplit("/", 1)[-1] == name
|
|
]
|
|
if not exact:
|
|
exact = [
|
|
task for task in states.values()
|
|
if normalized_task_name(task["title"]) == name
|
|
]
|
|
unfinished = [
|
|
task for task in exact
|
|
if task["status"] in ("todo", "in-progress", "blocked")
|
|
]
|
|
candidates = unfinished or exact
|
|
if not candidates:
|
|
raise WorkspaceError(f"No task matches short name: {value!r}")
|
|
if len(candidates) != 1:
|
|
raise WorkspaceError(
|
|
f"Task name {value!r} is ambiguous: "
|
|
+ ", ".join(sorted(task["id"] for task in candidates))
|
|
)
|
|
return candidates[0]
|
|
|
|
|
|
def command_queue(args):
|
|
config = worktree_config(args, create=True)
|
|
main = Path(config["ai_main"])
|
|
slot = Path(config["slot_worktree"])
|
|
main_dirty = changed_paths(main)
|
|
slot_dirty = changed_paths(slot)
|
|
if not main_dirty and not slot_dirty:
|
|
counts = unpublished_counts(config)
|
|
if not counts["slot_only"]:
|
|
sync_canonical(config)
|
|
states = load_states(slot)
|
|
tag = config["checkout_tag"]
|
|
values = [task_summary(task, states) for task in states.values()]
|
|
own_in_progress = sorted(
|
|
(
|
|
task for task in values
|
|
if task["claimed_by"] == tag and task["status"] == "in-progress"
|
|
),
|
|
key=queue_sort_key,
|
|
)
|
|
own_todo = sorted(
|
|
(
|
|
task for task in values
|
|
if task["claimed_by"] == tag and task["status"] == "todo"
|
|
),
|
|
key=queue_sort_key,
|
|
)
|
|
own_blocked = sorted(
|
|
(
|
|
task for task in values
|
|
if task["claimed_by"] == tag and task["status"] == "blocked"
|
|
),
|
|
key=queue_sort_key,
|
|
)
|
|
unclaimed_todo = sorted(
|
|
(
|
|
task for task in values
|
|
if task["claimed_by"] is None and task["status"] == "todo"
|
|
),
|
|
key=lambda task: (task["created"], task["id"]),
|
|
)
|
|
violations = []
|
|
if len(own_in_progress) > 1:
|
|
violations.append(f"{tag} has more than one in-progress task")
|
|
for task in values:
|
|
if task["status"] == "in-progress" and task["claimed_by"] is None:
|
|
violations.append(f"{task['id']} is in-progress but unclaimed")
|
|
if task["status"] == "blocked" and task["claimed_by"] is None:
|
|
violations.append(f"{task['id']} is blocked but unclaimed")
|
|
inbox_file = main / "inbox" / "inbox.md"
|
|
result = {
|
|
**config,
|
|
"inbox_nonempty": (
|
|
inbox_file.is_file()
|
|
and bool(inbox_file.read_text(encoding="utf-8").strip())
|
|
),
|
|
"own_in_progress": own_in_progress,
|
|
"own_todo": own_todo,
|
|
"own_blocked": own_blocked,
|
|
"unclaimed_todo": unclaimed_todo,
|
|
"other_claimed_unfinished": sum(
|
|
1 for task in values
|
|
if task["claimed_by"] not in (None, tag)
|
|
and task["status"] in ("todo", "in-progress", "blocked")
|
|
),
|
|
"ai_main_dirty": main_dirty,
|
|
"slot_dirty": slot_dirty,
|
|
"commits": unpublished_counts(config),
|
|
"violations": violations,
|
|
}
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_resolve(args):
|
|
config = worktree_config(args, create=True)
|
|
main = Path(config["ai_main"])
|
|
slot = Path(config["slot_worktree"])
|
|
main_dirty = changed_paths(main)
|
|
if main_dirty:
|
|
raise WorkspaceError(
|
|
"ai-tdesktop master is not clean: " + ", ".join(main_dirty)
|
|
)
|
|
if not changed_paths(slot) and not unpublished_counts(config)["slot_only"]:
|
|
sync_canonical(config)
|
|
states = load_states(slot)
|
|
task = task_summary(resolve_task(states, args.name), states)
|
|
active = sorted(
|
|
value["id"] for value in states.values()
|
|
if value["claimed_by"] == config["checkout_tag"]
|
|
and value["status"] == "in-progress"
|
|
)
|
|
print(json.dumps({
|
|
**config,
|
|
"task": task,
|
|
"other_active_task": next(
|
|
(value for value in active if value != task["id"]),
|
|
None,
|
|
),
|
|
"slot_dirty": changed_paths(slot),
|
|
"commits": unpublished_counts(config),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_start(args):
|
|
config = worktree_config(args, create=True)
|
|
sync_canonical(config)
|
|
slot = Path(config["slot_worktree"])
|
|
states = load_states(slot)
|
|
task = states.get(args.task)
|
|
if task is None:
|
|
raise WorkspaceError(f"Task does not exist: {args.task}")
|
|
if task["status"] != "todo" or task["claimed_by"] not in (
|
|
None,
|
|
config["checkout_tag"],
|
|
):
|
|
raise WorkspaceError(
|
|
f"Task is not available todo work for this checkout: {args.task}"
|
|
)
|
|
if not task_ready(task, states):
|
|
raise WorkspaceError(f"Task has unfinished dependencies: {args.task}")
|
|
active = [
|
|
value["id"] for value in states.values()
|
|
if value["claimed_by"] == config["checkout_tag"]
|
|
and value["status"] == "in-progress"
|
|
]
|
|
if active:
|
|
raise WorkspaceError("Another task is already in progress: " + ", ".join(active))
|
|
path = state_path(slot, args.task)
|
|
update_state(path, {
|
|
"status": "in-progress",
|
|
"claimed_by": config["checkout_tag"],
|
|
"claimed_at": (
|
|
task["claimed_at"]
|
|
or datetime.datetime.now().astimezone().isoformat()
|
|
),
|
|
"claim_order": task["claim_order"] or 1,
|
|
"lease_until": None,
|
|
"phase": "setup",
|
|
})
|
|
try:
|
|
commit = commit_paths(
|
|
config,
|
|
[str(path.relative_to(slot))],
|
|
f"Start {args.task} on {config['checkout_tag']}",
|
|
)
|
|
except WorkspaceError as error:
|
|
main_states = load_states(Path(config["ai_main"]))
|
|
main_task = main_states.get(args.task)
|
|
lost = (
|
|
main_task is not None
|
|
and (
|
|
main_task["status"] != "todo"
|
|
or main_task["claimed_by"] not in (
|
|
None,
|
|
config["checkout_tag"],
|
|
)
|
|
)
|
|
)
|
|
counts = unpublished_counts(config)
|
|
if lost and counts["slot_only"] == 1:
|
|
head = run_git(slot, "rev-parse", "HEAD").stdout.strip()
|
|
run_git(slot, "rebase", "--onto", "master", head)
|
|
raise WorkspaceError(
|
|
f"Start lost to newer master for {args.task}"
|
|
) from error
|
|
raise
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"status": "in-progress",
|
|
"published": bool(commit),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_retry(args):
|
|
config = worktree_config(args, create=True)
|
|
sync_canonical(config)
|
|
slot = Path(config["slot_worktree"])
|
|
states = load_states(slot)
|
|
task = states.get(args.task)
|
|
if task is None:
|
|
raise WorkspaceError(f"Task does not exist: {args.task}")
|
|
if (
|
|
task["status"] != "blocked"
|
|
or task["claimed_by"] != config["checkout_tag"]
|
|
):
|
|
raise WorkspaceError(
|
|
f"Task is not blocked work owned by this checkout: {args.task}"
|
|
)
|
|
if not task_ready(task, states):
|
|
raise WorkspaceError(f"Task has unfinished dependencies: {args.task}")
|
|
active = [
|
|
value["id"] for value in states.values()
|
|
if value["claimed_by"] == config["checkout_tag"]
|
|
and value["status"] == "in-progress"
|
|
]
|
|
if active:
|
|
raise WorkspaceError("Another task is already in progress: " + ", ".join(active))
|
|
path = state_path(slot, args.task)
|
|
update_state(path, {
|
|
"status": "in-progress",
|
|
"phase": "resume",
|
|
"lease_until": None,
|
|
})
|
|
routed = path.parent / "work" / "discovered-routed.md"
|
|
if routed.is_file():
|
|
routed.unlink()
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"status": "in-progress",
|
|
"local": True,
|
|
"published": False,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def task_action_config(args, require_status="in-progress", allow_project=False):
|
|
config = worktree_config(args, create=True)
|
|
slot = Path(config["slot_worktree"])
|
|
ensure_clean(Path(config["ai_main"]), "ai-tdesktop master")
|
|
task = load_state(slot, state_path(slot, args.task))
|
|
extra_paths = ()
|
|
if allow_project and task["project"] is not None:
|
|
extra_paths = (f"projects/{task['project']}/project.md",)
|
|
ensure_changes_scoped(slot, [args.task], extra_paths)
|
|
if task["status"] != require_status or task["claimed_by"] != config["checkout_tag"]:
|
|
raise WorkspaceError(
|
|
f"Task is not {require_status} work owned by this checkout: {args.task}"
|
|
)
|
|
return config, slot
|
|
|
|
|
|
def command_checkpoint(args):
|
|
_, slot = task_action_config(args)
|
|
path = state_path(slot, args.task)
|
|
update_state(path, {"phase": args.phase})
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"phase": args.phase,
|
|
"local": True,
|
|
"published": False,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def task_commit_matches(source, value, task_id):
|
|
message = run_git(source, "show", "-s", "--format=%B", value).stdout.rstrip("\n")
|
|
lines = message.split("\n")
|
|
return (
|
|
len(lines) == 3
|
|
and bool(lines[0])
|
|
and not lines[1]
|
|
and lines[2] == f"Task: {task_id}"
|
|
)
|
|
|
|
|
|
def validate_task_commit(source, value, task_id):
|
|
if not task_commit_matches(source, value, task_id):
|
|
raise WorkspaceError(
|
|
"Telegram commit message must be exactly a one-line subject, a blank line, "
|
|
f"and Task: {task_id}"
|
|
)
|
|
|
|
|
|
def task_series_refs(source, task_id):
|
|
commits = run_git(
|
|
source,
|
|
"log",
|
|
"--first-parent",
|
|
"--format=%H",
|
|
"--fixed-strings",
|
|
f"--grep=Task: {task_id}",
|
|
"HEAD",
|
|
).stdout.splitlines()
|
|
for green in commits:
|
|
if not task_commit_matches(source, green, task_id):
|
|
continue
|
|
current = green
|
|
while True:
|
|
parent = resolved_ref(source, f"{current}^")
|
|
if parent is None:
|
|
raise WorkspaceError(
|
|
"A task implementation cannot start at the repository root"
|
|
)
|
|
if not task_commit_matches(source, parent, task_id):
|
|
return parent, green
|
|
current = parent
|
|
return None
|
|
|
|
|
|
def is_ancestor(source, older, newer="HEAD"):
|
|
return not run_git(
|
|
source,
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
older,
|
|
newer,
|
|
check=False,
|
|
).returncode
|
|
|
|
|
|
def task_type(slot, task_id):
|
|
return load_state(slot, state_path(slot, task_id))["type"]
|
|
|
|
|
|
def validate_source_state(config, task_id, required, kind=DEFAULT_TASK_TYPE):
|
|
source = Path(config["source_root"])
|
|
base = resolved_ref(source, source_task_ref(task_id, "base"))
|
|
green = resolved_ref(source, source_task_ref(task_id, "green"))
|
|
run = resolved_ref(source, source_task_ref(task_id, "run"))
|
|
head = resolved_ref(source, "HEAD")
|
|
if base is None:
|
|
raise WorkspaceError("The local task baseline ref is missing")
|
|
if run is None or head != run:
|
|
raise WorkspaceError("Telegram HEAD no longer matches the task run ref")
|
|
if kind == "verify":
|
|
if green is not None:
|
|
raise WorkspaceError(
|
|
"A verification task must not retain a Telegram implementation commit"
|
|
)
|
|
if head != base:
|
|
raise WorkspaceError(
|
|
"A verification task must leave Telegram at its local baseline"
|
|
)
|
|
return
|
|
if green is None:
|
|
if required:
|
|
raise WorkspaceError("An approved task must retain a Telegram implementation commit")
|
|
if head != base:
|
|
raise WorkspaceError(
|
|
"A blocked task without an implementation must be restored to its local baseline"
|
|
)
|
|
return
|
|
if not is_ancestor(source, green, head):
|
|
raise WorkspaceError(
|
|
"The retained task implementation is not in Telegram HEAD history"
|
|
)
|
|
validate_task_commit(source, green, task_id)
|
|
|
|
|
|
def ensure_no_persisted_commit_hashes(root):
|
|
text_suffixes = {".json", ".log", ".md", ".txt", ".yaml", ".yml"}
|
|
paths = [root] if root.is_file() else sorted(root.rglob("*"))
|
|
for path in paths:
|
|
if not path.is_file() or path.suffix.lower() not in text_suffixes:
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
if any(field in text for field in LEGACY_COMMIT_FIELDS):
|
|
raise WorkspaceError(f"Legacy commit-hash field found in {path}")
|
|
if COMMIT_HASH_PATTERN.search(text):
|
|
raise WorkspaceError(f"Persisted commit hash found in {path}")
|
|
|
|
|
|
def delete_source_refs(config, task_id, retain_implementation=False):
|
|
source = Path(config["source_root"])
|
|
names = ("run",) if retain_implementation else ("run", "green", "base")
|
|
for name in names:
|
|
value = source_task_ref(task_id, name)
|
|
if resolved_ref(source, value) is not None:
|
|
run_git(source, "update-ref", "-d", value)
|
|
|
|
|
|
def command_source_begin(args):
|
|
config, _ = task_action_config(args)
|
|
source = Path(config["source_root"])
|
|
base = source_task_ref(args.task, "base")
|
|
green = source_task_ref(args.task, "green")
|
|
run = source_task_ref(args.task, "run")
|
|
base_value = resolved_ref(source, base)
|
|
green_value = resolved_ref(source, green)
|
|
head = resolved_ref(source, "HEAD")
|
|
retained = (
|
|
base_value is not None
|
|
and green_value is not None
|
|
and is_ancestor(source, base_value, green_value)
|
|
and is_ancestor(source, green_value, head)
|
|
and task_commit_matches(source, green_value, args.task)
|
|
)
|
|
if retained or (base_value is not None and green_value is None and head == base_value):
|
|
state = "resumed"
|
|
else:
|
|
series = task_series_refs(source, args.task)
|
|
if series is not None:
|
|
series_base, series_green = series
|
|
run_git(source, "update-ref", base, series_base)
|
|
run_git(source, "update-ref", green, series_green)
|
|
state = "reconciled" if base_value is not None else "recovered"
|
|
else:
|
|
ensure_clean(source, "Telegram source checkout")
|
|
run_git(source, "update-ref", base, "HEAD")
|
|
if green_value is not None:
|
|
run_git(source, "update-ref", "-d", green)
|
|
state = "reconciled" if base_value is not None else "initialized"
|
|
run_git(source, "update-ref", run, "HEAD")
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"source_state": state,
|
|
"has_retained_implementation": (
|
|
resolved_ref(source, green) is not None
|
|
),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def mark_source_green(config, task_id):
|
|
source = Path(config["source_root"])
|
|
ensure_clean(source, "Telegram source checkout")
|
|
base = source_task_ref(task_id, "base")
|
|
if resolved_ref(source, base) is None:
|
|
raise WorkspaceError("The local task baseline ref is missing")
|
|
if run_git(source, "merge-base", "--is-ancestor", base, "HEAD", check=False).returncode:
|
|
raise WorkspaceError("The retained implementation does not descend from the task baseline")
|
|
validate_task_commit(source, "HEAD", task_id)
|
|
run_git(source, "update-ref", source_task_ref(task_id, "green"), "HEAD")
|
|
run_git(source, "update-ref", source_task_ref(task_id, "run"), "HEAD")
|
|
|
|
|
|
def command_source_mark_green(args):
|
|
config, _ = task_action_config(args)
|
|
mark_source_green(config, args.task)
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"source_state": "retained",
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def run_git_binary(path, *args):
|
|
result = subprocess.run(
|
|
["git", "-C", str(path), *args],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
if result.returncode:
|
|
raise WorkspaceError(
|
|
result.stderr.decode("utf-8", "replace").strip()
|
|
or "git failed"
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def resolved_exe(value):
|
|
path = Path(value).expanduser().resolve()
|
|
if not path.is_file():
|
|
raise WorkspaceError(f"Test executable does not exist: {path}")
|
|
return path
|
|
|
|
|
|
def portable_root_for(exe, override):
|
|
if override:
|
|
root = Path(override).expanduser().resolve()
|
|
if not root.is_dir():
|
|
raise WorkspaceError(f"Portable root does not exist: {root}")
|
|
return root
|
|
for parent in exe.parents:
|
|
if parent.suffix == ".app":
|
|
return parent.parent
|
|
return exe.parent
|
|
|
|
|
|
def unique_destination(directory, name):
|
|
candidate = directory / name
|
|
index = 2
|
|
while candidate.exists():
|
|
candidate = directory / (
|
|
f"{Path(name).stem}-{index:02d}{Path(name).suffix}"
|
|
)
|
|
index += 1
|
|
return candidate
|
|
|
|
|
|
def move_stale_leftover(path, target):
|
|
for attempt in reversed(range(5)):
|
|
moved = unique_destination(target, path.name)
|
|
try:
|
|
shutil.move(path, moved)
|
|
return moved
|
|
except OSError:
|
|
try:
|
|
moved.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
if not attempt:
|
|
raise
|
|
time.sleep(0.2)
|
|
|
|
|
|
def clear_stale_crash_state(live, destination):
|
|
report = live / "tdata" / "working"
|
|
dumps_dir = live / "tdata" / "dumps"
|
|
leftovers = []
|
|
if report.is_file() and report.stat().st_size > 0:
|
|
leftovers.append(("report", report))
|
|
if dumps_dir.is_dir():
|
|
leftovers.extend(
|
|
("dump", path) for path in sorted(dumps_dir.glob("*.dmp"))
|
|
if path.is_file()
|
|
)
|
|
cleared = []
|
|
for kind, path in leftovers:
|
|
target = destination / "dumps" if kind == "dump" else destination
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
moved = move_stale_leftover(path, target)
|
|
except OSError as error:
|
|
if kind == "report":
|
|
raise WorkspaceError(
|
|
f"Cannot clear the stale crash report {path}: {error}"
|
|
) from error
|
|
cleared.append({"from": str(path), "kind": kind, "to": None})
|
|
continue
|
|
cleared.append({"from": str(path), "kind": kind, "to": str(moved)})
|
|
return cleared
|
|
|
|
|
|
def setup_test_account(root):
|
|
golden = root / PORTABLE_GOLDEN
|
|
live = root / PORTABLE_LIVE
|
|
real = root / PORTABLE_REAL
|
|
if not golden.is_dir():
|
|
raise WorkspaceError(f"Missing golden test account: {golden}")
|
|
if (live / PORTABLE_MARKER).exists():
|
|
return "reused-marked-live"
|
|
if live.exists():
|
|
if real.exists():
|
|
shutil.rmtree(live)
|
|
state = "replaced-manual-live"
|
|
else:
|
|
live.rename(real)
|
|
state = "preserved-real"
|
|
else:
|
|
state = "fresh-copy"
|
|
shutil.copytree(golden, live)
|
|
(live / PORTABLE_MARKER).write_text("1\n", encoding="utf-8")
|
|
return state
|
|
|
|
|
|
def reset_broken_test_account(root):
|
|
live = root / PORTABLE_LIVE
|
|
if not (live / PORTABLE_MARKER).exists():
|
|
raise WorkspaceError(
|
|
f"Refusing to reset an unmarked live folder: {live}"
|
|
)
|
|
shutil.rmtree(live)
|
|
return setup_test_account(root)
|
|
|
|
|
|
def processes_with_executable(exe):
|
|
value = str(exe)
|
|
pids = []
|
|
if sys.platform == "win32":
|
|
escaped = value.replace("'", "''")
|
|
script = (
|
|
"Get-CimInstance Win32_Process | "
|
|
f"Where-Object {{ $_.ExecutablePath -eq '{escaped}' }} | "
|
|
"ForEach-Object { $_.ProcessId }"
|
|
)
|
|
result = subprocess.run(
|
|
["powershell", "-NoProfile", "-Command", script],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if line.isdigit():
|
|
pids.append(int(line))
|
|
return pids
|
|
result = subprocess.run(
|
|
["ps", "-axo", "pid=,comm="],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
for line in result.stdout.splitlines():
|
|
parts = line.strip().split(None, 1)
|
|
if len(parts) != 2 or not parts[0].isdigit():
|
|
continue
|
|
pid, comm = int(parts[0]), parts[1]
|
|
if comm == value:
|
|
pids.append(pid)
|
|
continue
|
|
if sys.platform.startswith("linux"):
|
|
try:
|
|
if os.readlink(f"/proc/{pid}/exe") == value:
|
|
pids.append(pid)
|
|
except OSError:
|
|
continue
|
|
return pids
|
|
|
|
|
|
def kill_processes_with_executable(exe):
|
|
killed = []
|
|
for pid in processes_with_executable(exe):
|
|
try:
|
|
if sys.platform == "win32":
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(pid), "/F"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
else:
|
|
os.kill(pid, signal.SIGKILL)
|
|
killed.append(pid)
|
|
except (OSError, subprocess.SubprocessError):
|
|
continue
|
|
return killed
|
|
|
|
|
|
def parse_test_log(text):
|
|
steps = []
|
|
passed = []
|
|
failed = []
|
|
screenshots = []
|
|
for line in text.splitlines():
|
|
if line.startswith("TEST_STEP: "):
|
|
steps.append(line[len("TEST_STEP: "):])
|
|
elif line.startswith("TEST_RESULT: PASS: "):
|
|
passed.append(line[len("TEST_RESULT: PASS: "):])
|
|
elif line.startswith("TEST_RESULT: FAIL: "):
|
|
failed.append(line[len("TEST_RESULT: FAIL: "):])
|
|
elif line.startswith("SCREENSHOT: "):
|
|
screenshots.append(line[len("SCREENSHOT: "):])
|
|
return {
|
|
"steps": steps,
|
|
"pass": passed,
|
|
"fail": failed,
|
|
"screenshots": screenshots,
|
|
}
|
|
|
|
|
|
def tail_of_file(path, lines=60):
|
|
if not path.is_file():
|
|
return None
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
return "\n".join(text.splitlines()[-lines:]) if text.strip() else None
|
|
|
|
|
|
def parse_env_values(values):
|
|
environment = {}
|
|
for value in values or ():
|
|
if "=" not in value:
|
|
raise WorkspaceError(f"Invalid --env value (want NAME=VALUE): {value!r}")
|
|
name, content = value.split("=", 1)
|
|
if not name:
|
|
raise WorkspaceError(f"Invalid --env value (empty name): {value!r}")
|
|
environment[name] = content
|
|
return environment
|
|
|
|
|
|
def command_test_run(args):
|
|
exe = resolved_exe(args.exe)
|
|
run_dir = Path(args.run_dir).expanduser().resolve()
|
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
(run_dir / "screenshots").mkdir(exist_ok=True)
|
|
portable = portable_root_for(exe, args.portable_root)
|
|
account = setup_test_account(portable)
|
|
stragglers = kill_processes_with_executable(exe)
|
|
|
|
log_path = run_dir / TEST_LOG_FILE
|
|
if log_path.exists():
|
|
log_path.unlink()
|
|
|
|
environment = os.environ.copy()
|
|
environment["TDESKTOP_TEST_EVIDENCE_DIR"] = str(run_dir)
|
|
environment.update(parse_env_values(args.env))
|
|
|
|
cleared = (
|
|
clear_stale_crash_state(
|
|
portable / PORTABLE_LIVE, run_dir / STALE_CRASH_DIR
|
|
)
|
|
if account == "reused-marked-live"
|
|
else []
|
|
)
|
|
|
|
stdout_path = run_dir / "app_stdout.txt"
|
|
stderr_path = run_dir / "app_stderr.txt"
|
|
working = portable / PORTABLE_LIVE / "tdata" / "working"
|
|
dumps_dir = portable / PORTABLE_LIVE / "tdata" / "dumps"
|
|
|
|
launched_at = time.time()
|
|
with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
|
|
process = subprocess.Popen(
|
|
[str(exe), "-testagent", "-noupdate"],
|
|
stdout=out,
|
|
stderr=err,
|
|
env=environment,
|
|
cwd=str(portable),
|
|
)
|
|
outcome = None
|
|
exit_code = None
|
|
complete_seen_at = None
|
|
last_size = -1
|
|
last_change = launched_at
|
|
while True:
|
|
time.sleep(0.5)
|
|
now = time.time()
|
|
size = log_path.stat().st_size if log_path.is_file() else -1
|
|
if size != last_size:
|
|
last_size = size
|
|
last_change = now
|
|
complete = False
|
|
if size > 0:
|
|
complete = TEST_COMPLETE_MARKER in log_path.read_text(
|
|
encoding="utf-8", errors="replace"
|
|
)
|
|
if complete and complete_seen_at is None:
|
|
complete_seen_at = now
|
|
exit_code = process.poll()
|
|
if exit_code is not None:
|
|
outcome = "exited"
|
|
break
|
|
if complete_seen_at is not None and now - complete_seen_at > args.grace:
|
|
process.kill()
|
|
outcome = "killed-after-complete"
|
|
break
|
|
if now - launched_at > args.deadline:
|
|
process.kill()
|
|
outcome = "deadline-killed"
|
|
break
|
|
if now - last_change > args.quiet and complete_seen_at is None:
|
|
process.kill()
|
|
outcome = "quiet-killed"
|
|
break
|
|
process.wait()
|
|
ended_at = time.time()
|
|
kill_processes_with_executable(exe)
|
|
|
|
log_text = (
|
|
log_path.read_text(encoding="utf-8", errors="replace")
|
|
if log_path.is_file()
|
|
else ""
|
|
)
|
|
test_complete = TEST_COMPLETE_MARKER in log_text
|
|
crash_report_fresh = (
|
|
working.is_file()
|
|
and working.stat().st_mtime >= launched_at
|
|
and working.stat().st_size > 0
|
|
)
|
|
dumps = sorted(
|
|
str(path) for path in dumps_dir.glob("*.dmp")
|
|
if path.stat().st_mtime >= launched_at
|
|
) if dumps_dir.is_dir() else []
|
|
if outcome == "exited":
|
|
if test_complete:
|
|
verdict_hint = "complete"
|
|
elif crash_report_fresh or dumps:
|
|
verdict_hint = "crash"
|
|
else:
|
|
verdict_hint = "died-without-complete"
|
|
elif outcome == "killed-after-complete":
|
|
verdict_hint = "complete"
|
|
else:
|
|
verdict_hint = "hang"
|
|
|
|
print(json.dumps({
|
|
"account": account,
|
|
"crash_report": str(working) if working.is_file() else None,
|
|
"crash_report_excerpt": (
|
|
working.read_text(encoding="utf-8", errors="replace")[:4000]
|
|
if crash_report_fresh
|
|
else None
|
|
),
|
|
"crash_report_fresh": crash_report_fresh,
|
|
"dumps": dumps,
|
|
"duration_seconds": round(ended_at - launched_at, 1),
|
|
"exe": str(exe),
|
|
"exit_code": exit_code,
|
|
"log_path": str(log_path) if log_path.is_file() else None,
|
|
"markers": parse_test_log(log_text),
|
|
"outcome": outcome,
|
|
"portable_root": str(portable),
|
|
"run_dir": str(run_dir),
|
|
"stale_crash_cleared": cleared,
|
|
"stderr_tail": tail_of_file(stderr_path),
|
|
"stragglers_killed": stragglers,
|
|
"test_complete": test_complete,
|
|
"verdict_hint": verdict_hint,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_test_cleanup(args):
|
|
exe = Path(args.exe).expanduser().resolve()
|
|
killed = kill_processes_with_executable(exe)
|
|
deleted = False
|
|
if args.delete_exe and exe.is_file():
|
|
exe.unlink()
|
|
deleted = True
|
|
print(json.dumps({
|
|
"deleted_exe": deleted,
|
|
"exe": str(exe),
|
|
"killed": killed,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_test_account_reset(args):
|
|
exe = resolved_exe(args.exe)
|
|
kill_processes_with_executable(exe)
|
|
portable = portable_root_for(exe, args.portable_root)
|
|
account = reset_broken_test_account(portable)
|
|
print(json.dumps({
|
|
"account": account,
|
|
"portable_root": str(portable),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def overlay_work_dir(config, slot, task_id):
|
|
work = slot / task_relative_dir(task_id) / "work"
|
|
if not work.is_dir():
|
|
raise WorkspaceError(f"Task work directory does not exist: {work}")
|
|
return work
|
|
|
|
|
|
def read_overlay_paths(work):
|
|
paths_file = work / OVERLAY_PATHS_FILE
|
|
if not paths_file.is_file():
|
|
raise WorkspaceError(f"Missing overlay inventory: {paths_file}")
|
|
paths = [
|
|
line.strip() for line in
|
|
paths_file.read_text(encoding="utf-8-sig").splitlines()
|
|
if line.strip()
|
|
]
|
|
if not paths:
|
|
raise WorkspaceError(f"Empty overlay inventory: {paths_file}")
|
|
return paths
|
|
|
|
|
|
def command_overlay_save(args):
|
|
config, slot = task_action_config(args)
|
|
source = Path(config["source_root"])
|
|
work = overlay_work_dir(config, slot, args.task)
|
|
inventory = read_overlay_paths(work)
|
|
untracked = run_git(
|
|
source, "ls-files", "--others", "--exclude-standard"
|
|
).stdout.splitlines()
|
|
if untracked:
|
|
raise WorkspaceError(
|
|
"The overlay may not use untracked source files: "
|
|
+ ", ".join(untracked)
|
|
)
|
|
dirty = changed_paths(source)
|
|
outside = [
|
|
path for path in dirty
|
|
if not path_is_covered(path, inventory)
|
|
]
|
|
if outside:
|
|
raise WorkspaceError(
|
|
"Dirty source paths are outside the overlay inventory: "
|
|
+ ", ".join(outside)
|
|
)
|
|
patch = run_git_binary(source, "diff", "--binary", "HEAD")
|
|
if not patch.strip():
|
|
raise WorkspaceError("The overlay diff is empty; nothing to save")
|
|
patch_path = work / OVERLAY_PATCH_FILE
|
|
patch_path.write_bytes(patch)
|
|
check = subprocess.run(
|
|
["git", "-C", str(source), "apply", "--check", "--reverse", str(patch_path)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
if check.returncode:
|
|
raise WorkspaceError(
|
|
"The saved overlay patch does not verify: "
|
|
+ check.stderr.strip()
|
|
)
|
|
restored = []
|
|
if args.restore != "none":
|
|
ref = source_task_ref(args.task, args.restore)
|
|
if resolved_ref(source, ref) is None:
|
|
raise WorkspaceError(f"Missing task ref for restore: {ref}")
|
|
run_git(source, "checkout", ref, "--", *inventory)
|
|
restored = inventory
|
|
remaining = [
|
|
path for path in changed_paths(source)
|
|
if path_is_covered(path, inventory)
|
|
]
|
|
if remaining:
|
|
raise WorkspaceError(
|
|
"Overlay paths remain dirty after restore: "
|
|
+ ", ".join(remaining)
|
|
)
|
|
print(json.dumps({
|
|
"patch": str(patch_path),
|
|
"patch_bytes": len(patch),
|
|
"restored": restored,
|
|
"task": args.task,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_overlay_apply(args):
|
|
config, slot = task_action_config(args)
|
|
source = Path(config["source_root"])
|
|
work = overlay_work_dir(config, slot, args.task)
|
|
patch_path = work / OVERLAY_PATCH_FILE
|
|
if not patch_path.is_file() or not patch_path.stat().st_size:
|
|
raise WorkspaceError(f"Missing overlay patch: {patch_path}")
|
|
inventory = read_overlay_paths(work)
|
|
result = subprocess.run(
|
|
["git", "-C", str(source), "apply", "--3way", str(patch_path)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
conflicts = run_git(
|
|
source, "diff", "--name-only", "--diff-filter=U"
|
|
).stdout.splitlines()
|
|
applied = not result.returncode and not conflicts
|
|
outside = [
|
|
path for path in changed_paths(source)
|
|
if not path_is_covered(path, inventory)
|
|
]
|
|
print(json.dumps({
|
|
"applied": applied,
|
|
"conflicts": conflicts,
|
|
"error": result.stderr.strip() if result.returncode else None,
|
|
"outside_inventory": outside,
|
|
"task": args.task,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def gitlink_paths(source, paths):
|
|
result = []
|
|
for path in paths:
|
|
entry = run_git(source, "ls-files", "-s", "--", path).stdout
|
|
if entry.startswith("160000 "):
|
|
result.append(path)
|
|
return result
|
|
|
|
|
|
def command_source_commit(args):
|
|
config, slot = task_action_config(args)
|
|
if task_type(slot, args.task) == "verify":
|
|
raise WorkspaceError(
|
|
"A verification task carries no implementation and cannot commit "
|
|
"Telegram source; report the deviation as a follow-up task instead"
|
|
)
|
|
source = Path(config["source_root"])
|
|
subject = args.subject.strip()
|
|
if not subject or "\n" in subject:
|
|
raise WorkspaceError("The commit subject must be a single non-empty line")
|
|
if len(subject) > 72:
|
|
raise WorkspaceError(
|
|
f"The commit subject is too long ({len(subject)} > 72 characters)"
|
|
)
|
|
work = slot / task_relative_dir(args.task) / "work"
|
|
owned_file = work / "owned-paths.txt"
|
|
if not owned_file.is_file():
|
|
raise WorkspaceError(f"Missing owned-paths inventory: {owned_file}")
|
|
owned = [
|
|
line.strip() for line in
|
|
owned_file.read_text(encoding="utf-8-sig").splitlines()
|
|
if line.strip()
|
|
]
|
|
if not owned:
|
|
raise WorkspaceError(f"Empty owned-paths inventory: {owned_file}")
|
|
source_note = f"tasks/{args.task}.md"
|
|
allowed = owned + [source_note]
|
|
dirty = changed_paths(source)
|
|
if not dirty:
|
|
raise WorkspaceError("The source checkout has no changes to commit")
|
|
outside = [
|
|
path for path in dirty
|
|
if not path_is_covered(path, allowed)
|
|
]
|
|
if outside:
|
|
raise WorkspaceError(
|
|
"Dirty source paths are outside the owned write set: "
|
|
+ ", ".join(outside)
|
|
)
|
|
submodules = gitlink_paths(source, dirty)
|
|
if submodules:
|
|
raise WorkspaceError(
|
|
"Submodule pointers must be committed explicitly first: "
|
|
+ ", ".join(submodules)
|
|
)
|
|
for path in dirty:
|
|
run_git(source, "add", "--", path)
|
|
run_git(
|
|
source,
|
|
"commit",
|
|
"-m",
|
|
f"{subject}\n\nTask: {args.task}",
|
|
)
|
|
validate_task_commit(source, "HEAD", args.task)
|
|
if args.mark_green:
|
|
mark_source_green(config, args.task)
|
|
print(json.dumps({
|
|
"committed": dirty,
|
|
"marked_green": bool(args.mark_green),
|
|
"subject": subject,
|
|
"task": args.task,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_source_verify_commit(args):
|
|
source = source_root(args.source_root)
|
|
validate_task_commit(source, args.ref, args.task)
|
|
subject = run_git(
|
|
source, "show", "-s", "--format=%s", args.ref
|
|
).stdout.strip()
|
|
print(json.dumps({
|
|
"ref": args.ref,
|
|
"subject": subject,
|
|
"task": args.task,
|
|
"valid": True,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
while True:
|
|
chunk = stream.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def command_fence_create(args):
|
|
root = Path(args.root).expanduser().resolve()
|
|
if not root.is_dir():
|
|
raise WorkspaceError(f"Fence root does not exist: {root}")
|
|
if not args.paths:
|
|
raise WorkspaceError("No fence paths were provided")
|
|
lines = []
|
|
for value in args.paths:
|
|
path = root / value
|
|
if not path.is_file():
|
|
raise WorkspaceError(f"Fence path does not exist: {path}")
|
|
lines.append(f"{file_sha256(path)} {value}")
|
|
target = Path(args.file).expanduser().resolve()
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(json.dumps({
|
|
"file": str(target),
|
|
"paths": len(lines),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_fence_check(args):
|
|
root = Path(args.root).expanduser().resolve()
|
|
target = Path(args.file).expanduser().resolve()
|
|
if not target.is_file():
|
|
raise WorkspaceError(f"Fence baseline does not exist: {target}")
|
|
mismatched = []
|
|
missing = []
|
|
checked = 0
|
|
for line in target.read_text(encoding="utf-8-sig").splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if " " not in line:
|
|
raise WorkspaceError(f"Invalid fence line: {line!r}")
|
|
expected, value = line.split(" ", 1)
|
|
path = root / value
|
|
checked += 1
|
|
if not path.is_file():
|
|
missing.append(value)
|
|
elif file_sha256(path) != expected:
|
|
mismatched.append(value)
|
|
ok = not mismatched and not missing
|
|
print(json.dumps({
|
|
"checked": checked,
|
|
"mismatched": mismatched,
|
|
"missing": missing,
|
|
"ok": ok,
|
|
}, indent=2, sort_keys=True))
|
|
if not ok:
|
|
sys.exit(2)
|
|
|
|
|
|
def command_source_preflight(args):
|
|
config, slot = task_action_config(args)
|
|
source = Path(config["source_root"])
|
|
dirty = changed_paths(source)
|
|
submodule_lines = run_git(
|
|
source, "submodule", "status", "--recursive"
|
|
).stdout.splitlines()
|
|
submodules_dirty = [
|
|
line.strip() for line in submodule_lines
|
|
if line and line[0] in "+-U"
|
|
]
|
|
work = slot / task_relative_dir(args.task) / "work"
|
|
owned_file = work / "owned-paths.txt"
|
|
owned = [
|
|
line.strip() for line in
|
|
owned_file.read_text(encoding="utf-8-sig").splitlines()
|
|
if line.strip()
|
|
] if owned_file.is_file() else []
|
|
dirty_outside_owned = [
|
|
path for path in dirty
|
|
if not path_is_covered(path, owned + [f"tasks/{args.task}.md"])
|
|
]
|
|
result = {
|
|
"dirty": dirty,
|
|
"dirty_outside_owned": dirty_outside_owned,
|
|
"owned_paths_present": owned_file.is_file(),
|
|
"source_clean": not dirty,
|
|
"submodules_dirty": submodules_dirty,
|
|
"task": args.task,
|
|
}
|
|
if args.exe:
|
|
exe = Path(args.exe).expanduser().resolve()
|
|
result["exe_present"] = exe.is_file()
|
|
if exe.is_file():
|
|
portable = portable_root_for(exe, None)
|
|
result["golden_account_present"] = (
|
|
portable / PORTABLE_GOLDEN
|
|
).is_dir()
|
|
result["live_marker_present"] = (
|
|
portable / PORTABLE_LIVE / PORTABLE_MARKER
|
|
).exists()
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
|
|
|
|
def validate_verify_result(lines, result_path, approved):
|
|
if "Touched: none" not in lines:
|
|
raise WorkspaceError(
|
|
f"A verification task must report Touched: none: {result_path}"
|
|
)
|
|
findings = [
|
|
line.split(":", 1)[1].strip() for line in lines
|
|
if line.startswith("Finding:")
|
|
]
|
|
if len(findings) != 1 or findings[0] not in VALID_FINDINGS:
|
|
raise WorkspaceError(
|
|
"A verification task must record exactly one Finding: "
|
|
+ " | ".join(sorted(VALID_FINDINGS))
|
|
+ f": {result_path}"
|
|
)
|
|
finding = findings[0]
|
|
if approved:
|
|
if finding == "inconclusive":
|
|
raise WorkspaceError(
|
|
"An inconclusive verification is blocked, never approved: "
|
|
f"{result_path}"
|
|
)
|
|
if finding == "deviation" and "Discovered: present" not in lines:
|
|
raise WorkspaceError(
|
|
"A verification that found a deviation must route it as a "
|
|
f"discovered follow-up task: {result_path}"
|
|
)
|
|
elif finding != "inconclusive":
|
|
raise WorkspaceError(
|
|
"A verification blocks only when it could not measure, so a blocked "
|
|
f"result must record Finding: inconclusive: {result_path}"
|
|
)
|
|
|
|
|
|
def command_finish(args):
|
|
config, slot = task_action_config(args, allow_project=True)
|
|
ensure_clean(Path(config["source_root"]), "Telegram source checkout")
|
|
kind = task_type(slot, args.task)
|
|
approved = args.status == "approved"
|
|
result_path = slot / task_relative_dir(args.task) / "work" / "result.md"
|
|
if not result_path.is_file():
|
|
raise WorkspaceError(f"Task result is missing: {result_path}")
|
|
result = result_path.read_text(encoding="utf-8-sig")
|
|
lines = result.splitlines()
|
|
expected = "STATUS: DONE" if approved else "STATUS: BLOCKED"
|
|
if expected not in lines:
|
|
raise WorkspaceError(f"Task result does not contain {expected}: {result_path}")
|
|
if approved and not any(
|
|
line in ("Verdict: APPROVED", "Verdict: NOT_APPLICABLE")
|
|
for line in lines
|
|
):
|
|
raise WorkspaceError(f"Task result does not contain an approved verdict: {result_path}")
|
|
if "Checkout: clean-buildable" not in lines:
|
|
raise WorkspaceError(f"Task result does not confirm a clean checkout: {result_path}")
|
|
if kind == "verify":
|
|
validate_verify_result(lines, result_path, approved)
|
|
ensure_no_persisted_commit_hashes(result_path.parents[1])
|
|
source_note = Path(config["source_root"]) / "tasks" / f"{args.task}.md"
|
|
if source_note.is_file():
|
|
ensure_no_persisted_commit_hashes(source_note)
|
|
validate_source_state(config, args.task, approved, kind)
|
|
path = state_path(slot, args.task)
|
|
update_state(path, {
|
|
"status": args.status,
|
|
"phase": "complete" if args.status == "approved" else "blocked",
|
|
"lease_until": None,
|
|
})
|
|
verb = "Approve" if args.status == "approved" else "Block"
|
|
paths = [task_relative_dir(args.task)]
|
|
project = load_state(slot, path)["project"]
|
|
if project is not None:
|
|
project_path = f"projects/{project}/project.md"
|
|
if project_path in changed_paths(slot):
|
|
ensure_no_persisted_commit_hashes(slot / project_path)
|
|
paths.append(project_path)
|
|
commit = commit_paths(
|
|
config,
|
|
paths,
|
|
f"{verb} {args.task}",
|
|
)
|
|
delete_source_refs(
|
|
config,
|
|
args.task,
|
|
retain_implementation=(args.status == "blocked"),
|
|
)
|
|
print(json.dumps({
|
|
"task": args.task,
|
|
"status": args.status,
|
|
"published": bool(commit),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_publish(args):
|
|
config = worktree_config(args, create=True)
|
|
published = publish_slot(config)
|
|
print(json.dumps({"published": bool(published)}, indent=2, sort_keys=True))
|
|
|
|
|
|
def normalized_publish_path(value):
|
|
path = PurePosixPath(value.replace("\\", "/"))
|
|
minimum_parts = {
|
|
"tasks": 5,
|
|
"projects": 2,
|
|
"receipts": 5,
|
|
}
|
|
if (
|
|
path.is_absolute()
|
|
or not path.parts
|
|
or ".." in path.parts
|
|
or path.parts[0] not in minimum_parts
|
|
or len(path.parts) < minimum_parts.get(path.parts[0], 0)
|
|
or (
|
|
path.parts[0] == "projects"
|
|
and len(path.parts) == 2
|
|
and path.parts[1] == PROJECT_ARCHIVE_DIR
|
|
)
|
|
):
|
|
raise WorkspaceError(f"Invalid inbox publication path: {value!r}")
|
|
return path.as_posix()
|
|
|
|
|
|
def path_is_covered(path, roots):
|
|
return any(path == root or path.startswith(root + "/") for root in roots)
|
|
|
|
|
|
def validate_receipt_text(text, metadata, label):
|
|
if metadata["digest"] not in text:
|
|
raise WorkspaceError(
|
|
f"{label} does not contain the inbox digest: {metadata['digest']}"
|
|
)
|
|
|
|
|
|
def command_inbox_publish(args):
|
|
config = inbox_worktree_config(args, create=True)
|
|
_, metadata = load_transaction(args.transaction)
|
|
if metadata["checkout_tag"] != config["checkout_tag"]:
|
|
raise WorkspaceError("The inbox transaction belongs to another checkout")
|
|
if Path(metadata["ai_main"]).resolve() != Path(config["ai_main"]).resolve():
|
|
raise WorkspaceError(
|
|
"The inbox transaction belongs to another ai-tdesktop repository"
|
|
)
|
|
worktree = Path(config["inbox_worktree"])
|
|
receipt = normalized_publish_path(args.receipt)
|
|
if not receipt.startswith("receipts/"):
|
|
raise WorkspaceError("The tracked receipt must be below receipts/")
|
|
if not (worktree / receipt).is_file():
|
|
raise WorkspaceError(
|
|
f"Tracked receipt is missing from the inbox worktree: {receipt}"
|
|
)
|
|
validate_receipt_text(
|
|
(worktree / receipt).read_text(encoding="utf-8-sig"),
|
|
metadata,
|
|
"Tracked receipt",
|
|
)
|
|
paths = sorted({normalized_publish_path(value) for value in args.paths})
|
|
if not path_is_covered(receipt, paths):
|
|
raise WorkspaceError(
|
|
"The tracked receipt is not covered by an explicit publication path"
|
|
)
|
|
changes = changed_paths(worktree)
|
|
unexpected = [path for path in changes if not path_is_covered(path, paths)]
|
|
if unexpected:
|
|
raise WorkspaceError(
|
|
"Inbox worktree changes are outside the explicit publication paths: "
|
|
+ ", ".join(unexpected)
|
|
)
|
|
unstaged_before = set(
|
|
run_git(worktree, "diff", "--name-only").stdout.splitlines()
|
|
)
|
|
unstaged_before.update(run_git(
|
|
worktree,
|
|
"ls-files",
|
|
"--others",
|
|
"--exclude-standard",
|
|
).stdout.splitlines())
|
|
for path in paths:
|
|
if (
|
|
(worktree / path).exists()
|
|
or any(path_is_covered(change, [path]) for change in unstaged_before)
|
|
):
|
|
run_git(worktree, "add", "-A", "--", path)
|
|
unstaged = run_git(worktree, "diff", "--name-only").stdout.splitlines()
|
|
untracked = run_git(
|
|
worktree,
|
|
"ls-files",
|
|
"--others",
|
|
"--exclude-standard",
|
|
).stdout.splitlines()
|
|
if unstaged or untracked:
|
|
raise WorkspaceError(
|
|
"Inbox worktree changes remain unstaged: "
|
|
+ ", ".join(sorted(set(unstaged + untracked)))
|
|
)
|
|
committed = run_git(
|
|
worktree,
|
|
"diff",
|
|
"--cached",
|
|
"--quiet",
|
|
check=False,
|
|
).returncode != 0
|
|
if committed:
|
|
run_git(
|
|
worktree,
|
|
"commit",
|
|
"-m",
|
|
f"Process inbox for {config['checkout_tag']}",
|
|
)
|
|
published = publish_inbox(config)
|
|
print(json.dumps({
|
|
"committed": committed,
|
|
"inbox_branch": config["inbox_branch"],
|
|
"inbox_worktree": config["inbox_worktree"],
|
|
"published": bool(published),
|
|
"receipt": receipt,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def rewrite_project_links(directory, pattern, replacement):
|
|
for path in sorted(directory.rglob("*.md")):
|
|
data = path.read_bytes()
|
|
if data.startswith(b"\xef\xbb\xbf"):
|
|
raise WorkspaceError(f"Refusing to preserve a UTF-8 BOM in {path}")
|
|
text = data.decode("utf-8")
|
|
replaced = pattern.sub(replacement, text)
|
|
if replaced != text:
|
|
path.write_bytes(replaced.encode("utf-8"))
|
|
|
|
|
|
def project_last_activity(states):
|
|
activity = {}
|
|
unfinished = set()
|
|
for task in states.values():
|
|
project = task["project"]
|
|
if project is None:
|
|
continue
|
|
try:
|
|
created = datetime.date.fromisoformat(task["created"])
|
|
except ValueError as error:
|
|
raise WorkspaceError(
|
|
f"Invalid created date in {task['id']}: {task['created']!r}"
|
|
) from error
|
|
if project not in activity or created > activity[project]:
|
|
activity[project] = created
|
|
if task["status"] != "approved":
|
|
unfinished.add(project)
|
|
return activity, unfinished
|
|
|
|
|
|
def command_archive_stale(args):
|
|
if args.days < 1:
|
|
raise WorkspaceError("The archive threshold must be at least one day")
|
|
config = worktree_config(args, create=True)
|
|
sync_canonical(config)
|
|
slot = Path(config["slot_worktree"])
|
|
activity, unfinished = project_last_activity(load_states(slot))
|
|
cutoff = datetime.date.today() - datetime.timedelta(days=args.days)
|
|
projects = slot / "projects"
|
|
candidates = sorted(
|
|
entry.name for entry in projects.iterdir()
|
|
if entry.is_dir() and entry.name != PROJECT_ARCHIVE_DIR
|
|
) if projects.is_dir() else []
|
|
archived = []
|
|
published = False
|
|
for slug in candidates:
|
|
if slug in unfinished or slug not in activity or activity[slug] > cutoff:
|
|
continue
|
|
target = projects / PROJECT_ARCHIVE_DIR / slug
|
|
if target.exists():
|
|
raise WorkspaceError(f"The archive already contains a project: {slug}")
|
|
target.parent.mkdir(exist_ok=True)
|
|
run_git(
|
|
slot,
|
|
"mv",
|
|
f"projects/{slug}",
|
|
f"projects/{PROJECT_ARCHIVE_DIR}/{slug}",
|
|
)
|
|
rewrite_project_links(target, PROJECT_LINK_PATTERN, "](../../../")
|
|
published = commit_paths(
|
|
config,
|
|
[f"projects/{PROJECT_ARCHIVE_DIR}/{slug}"],
|
|
f"Archive {slug}",
|
|
)
|
|
archived.append(slug)
|
|
print(json.dumps({
|
|
"archived": archived,
|
|
"days": args.days,
|
|
"published": bool(published),
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def unarchive_project(config, worktree_key, slug):
|
|
if slug == PROJECT_ARCHIVE_DIR or not TAG_PATTERN.fullmatch(slug):
|
|
raise WorkspaceError(f"Invalid project slug: {slug!r}")
|
|
worktree = Path(config[worktree_key])
|
|
archived = worktree / "projects" / PROJECT_ARCHIVE_DIR / slug
|
|
target = worktree / "projects" / slug
|
|
if not archived.is_dir():
|
|
raise WorkspaceError(f"No archived project: {slug}")
|
|
if target.exists():
|
|
raise WorkspaceError(f"The project is already live: {slug}")
|
|
run_git(
|
|
worktree,
|
|
"mv",
|
|
f"projects/{PROJECT_ARCHIVE_DIR}/{slug}",
|
|
f"projects/{slug}",
|
|
)
|
|
rewrite_project_links(target, ARCHIVED_PROJECT_LINK_PATTERN, "](../../")
|
|
run_git(worktree, "add", "--", f"projects/{slug}")
|
|
archive_root = worktree / "projects" / PROJECT_ARCHIVE_DIR
|
|
if archive_root.is_dir() and not any(archive_root.iterdir()):
|
|
archive_root.rmdir()
|
|
print(json.dumps({
|
|
"project": slug,
|
|
"published": False,
|
|
"unarchived": True,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_unarchive(args):
|
|
config = worktree_config(args, create=True)
|
|
unarchive_project(config, "slot_worktree", args.project)
|
|
|
|
|
|
def command_inbox_unarchive(args):
|
|
config = inbox_worktree_config(args, create=True)
|
|
unarchive_project(config, "inbox_worktree", args.project)
|
|
|
|
|
|
def payload_entries(inbox):
|
|
return sorted(
|
|
(path for path in inbox.iterdir() if path.name != "backup"),
|
|
key=lambda path: path.name,
|
|
)
|
|
|
|
|
|
def iter_payload_files(inbox):
|
|
for entry in payload_entries(inbox):
|
|
if entry.is_symlink():
|
|
raise WorkspaceError(f"Inbox symlinks are not supported: {entry}")
|
|
if entry.is_file():
|
|
yield entry
|
|
elif entry.is_dir():
|
|
for path in sorted(entry.rglob("*")):
|
|
if path.is_symlink():
|
|
raise WorkspaceError(f"Inbox symlinks are not supported: {path}")
|
|
if path.is_file():
|
|
yield path
|
|
|
|
|
|
def payload_digest(inbox):
|
|
digest = hashlib.sha256()
|
|
for path in iter_payload_files(inbox):
|
|
relative = path.relative_to(inbox).as_posix().encode("utf-8")
|
|
digest.update(relative)
|
|
digest.update(b"\0")
|
|
with path.open("rb") as stream:
|
|
while True:
|
|
chunk = stream.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
digest.update(b"\0")
|
|
return digest.hexdigest()
|
|
|
|
|
|
def copy_payload(inbox, destination):
|
|
for entry in payload_entries(inbox):
|
|
target = destination / entry.name
|
|
if entry.is_symlink():
|
|
raise WorkspaceError(f"Inbox symlinks are not supported: {entry}")
|
|
if entry.is_dir():
|
|
shutil.copytree(entry, target)
|
|
else:
|
|
shutil.copy2(entry, target)
|
|
|
|
|
|
def unique_backup_name(backup, base):
|
|
candidate = base
|
|
index = 2
|
|
while (backup / candidate).exists() or (backup / f".processing-{candidate}").exists():
|
|
candidate = f"{base}-{index:02d}"
|
|
index += 1
|
|
return candidate
|
|
|
|
|
|
def command_ensure(args):
|
|
config = worktree_config(args, create=True)
|
|
print(json.dumps(config, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_inbox_ensure(args):
|
|
config = inbox_worktree_config(args, create=True)
|
|
print(json.dumps(config, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_prepare(args):
|
|
config = inbox_worktree_config(args, create=True)
|
|
inbox = Path(config["inbox"])
|
|
inbox_file = inbox / "inbox.md"
|
|
backup = inbox / "backup"
|
|
active = sorted(backup.glob(".processing-*"))
|
|
if active:
|
|
if len(active) != 1:
|
|
raise WorkspaceError(
|
|
"Several inbox transactions are active: "
|
|
+ ", ".join(str(path) for path in active)
|
|
)
|
|
transaction, metadata = load_transaction(active[0])
|
|
if metadata["checkout_tag"] != config["checkout_tag"]:
|
|
raise WorkspaceError(
|
|
"Active inbox transaction belongs to "
|
|
f"{metadata['checkout_tag']}, not {config['checkout_tag']}"
|
|
)
|
|
if Path(metadata["ai_main"]).resolve() != Path(config["ai_main"]).resolve():
|
|
raise WorkspaceError(
|
|
"Active inbox transaction belongs to another ai-tdesktop repository"
|
|
)
|
|
current_digest = payload_digest(inbox)
|
|
print(json.dumps({
|
|
**config,
|
|
"transaction": str(transaction),
|
|
"digest": metadata["digest"],
|
|
"resumed": True,
|
|
"inbox_changed": current_digest != metadata["digest"],
|
|
}, indent=2, sort_keys=True))
|
|
return
|
|
if not inbox_file.read_text(encoding="utf-8").strip():
|
|
raise WorkspaceError(f"Inbox is empty: {inbox_file}")
|
|
sync_inbox_canonical(config)
|
|
|
|
stamp = datetime.datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
|
name = unique_backup_name(backup, f"{stamp}-inbox")
|
|
transaction = backup / f".processing-{name}"
|
|
transaction.mkdir()
|
|
digest = payload_digest(inbox)
|
|
copy_payload(inbox, transaction)
|
|
metadata = {
|
|
"version": 1,
|
|
"name": name,
|
|
"digest": digest,
|
|
"created_at": datetime.datetime.now().astimezone().isoformat(),
|
|
**config,
|
|
}
|
|
(transaction / "transaction.json").write_text(
|
|
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(json.dumps({
|
|
**config,
|
|
"transaction": str(transaction),
|
|
"digest": digest,
|
|
"resumed": False,
|
|
"inbox_changed": False,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def load_transaction(value):
|
|
transaction = Path(value).expanduser().resolve()
|
|
metadata_path = transaction / "transaction.json"
|
|
if not metadata_path.is_file():
|
|
raise WorkspaceError(f"Invalid transaction: {transaction}")
|
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
backup = Path(metadata["inbox"]) / "backup"
|
|
if transaction.parent.resolve() != backup.resolve():
|
|
raise WorkspaceError(f"Transaction is outside the inbox backup: {transaction}")
|
|
return transaction, metadata
|
|
|
|
|
|
def write_local_receipt(path, metadata, receipt, result):
|
|
text = (
|
|
f"Processed: {metadata['created_at']}\n"
|
|
f"Checkout: {metadata['checkout_tag']}\n"
|
|
f"Digest: {metadata['digest']}\n"
|
|
f"Tracked receipt: {receipt}\n"
|
|
f"Result: {result}\n"
|
|
)
|
|
(path / "receipt.txt").write_text(text, encoding="utf-8")
|
|
|
|
|
|
def clear_payload(inbox):
|
|
for entry in payload_entries(inbox):
|
|
if entry.is_dir() and not entry.is_symlink():
|
|
shutil.rmtree(entry)
|
|
else:
|
|
entry.unlink()
|
|
(inbox / "inbox.md").write_text("", encoding="utf-8")
|
|
|
|
|
|
def command_finalize(args):
|
|
transaction, metadata = load_transaction(args.transaction)
|
|
inbox = Path(metadata["inbox"])
|
|
main = Path(metadata["ai_main"]).resolve()
|
|
receipt_value = normalized_publish_path(args.receipt)
|
|
if not receipt_value.startswith("receipts/"):
|
|
raise WorkspaceError("The tracked receipt must be below receipts/")
|
|
if git_root(main) != main:
|
|
raise WorkspaceError(f"ai-tdesktop is not a worktree root: {main}")
|
|
main_branch = run_git(main, "branch", "--show-current").stdout.strip()
|
|
if main_branch != "master":
|
|
raise WorkspaceError(
|
|
f"The main ai-tdesktop worktree must be on master, not {main_branch!r}."
|
|
)
|
|
tracked = run_git(main, "show", f"HEAD:{receipt_value}", check=False)
|
|
if tracked.returncode:
|
|
raise WorkspaceError(
|
|
f"Tracked receipt is missing from master HEAD: {main / receipt_value}"
|
|
)
|
|
validate_receipt_text(tracked.stdout, metadata, "Tracked receipt on master")
|
|
current_digest = payload_digest(inbox)
|
|
unchanged = current_digest == metadata["digest"]
|
|
backup = inbox / "backup"
|
|
name = metadata["name"] if unchanged else f"{metadata['name']}-processed-snapshot"
|
|
final = backup / name
|
|
if final.exists():
|
|
name = unique_backup_name(backup, name)
|
|
final = backup / name
|
|
transaction.rename(final)
|
|
if unchanged:
|
|
clear_payload(inbox)
|
|
write_local_receipt(
|
|
final,
|
|
metadata,
|
|
receipt_value,
|
|
"inbox-cleared" if unchanged else "inbox-changed-and-left-intact",
|
|
)
|
|
print(json.dumps({
|
|
"backup": str(final),
|
|
"cleared": unchanged,
|
|
"published": True,
|
|
}, indent=2, sort_keys=True))
|
|
|
|
|
|
def command_abort(args):
|
|
transaction, metadata = load_transaction(args.transaction)
|
|
backup = transaction.parent
|
|
name = unique_backup_name(backup, f"{metadata['name']}-failed")
|
|
final = backup / name
|
|
transaction.rename(final)
|
|
write_local_receipt(final, metadata, "not-created", "processing-failed")
|
|
print(json.dumps({"backup": str(final), "cleared": False}, indent=2, sort_keys=True))
|
|
|
|
|
|
def add_common_arguments(parser):
|
|
parser.add_argument("--source-root")
|
|
parser.add_argument("--ai-main")
|
|
parser.add_argument("--worktrees-root")
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
ensure = subparsers.add_parser("ensure")
|
|
add_common_arguments(ensure)
|
|
ensure.set_defaults(handler=command_ensure)
|
|
|
|
inbox_ensure = subparsers.add_parser("inbox-ensure")
|
|
add_common_arguments(inbox_ensure)
|
|
inbox_ensure.set_defaults(handler=command_inbox_ensure)
|
|
|
|
prepare = subparsers.add_parser("prepare")
|
|
add_common_arguments(prepare)
|
|
prepare.set_defaults(handler=command_prepare)
|
|
|
|
finalize = subparsers.add_parser("finalize")
|
|
finalize.add_argument("--transaction", required=True)
|
|
finalize.add_argument("--receipt", required=True)
|
|
finalize.set_defaults(handler=command_finalize)
|
|
|
|
abort = subparsers.add_parser("abort")
|
|
abort.add_argument("--transaction", required=True)
|
|
abort.set_defaults(handler=command_abort)
|
|
|
|
queue = subparsers.add_parser("queue")
|
|
add_common_arguments(queue)
|
|
queue.set_defaults(handler=command_queue)
|
|
|
|
resolve = subparsers.add_parser("resolve")
|
|
add_common_arguments(resolve)
|
|
resolve.add_argument("--name", required=True)
|
|
resolve.set_defaults(handler=command_resolve)
|
|
|
|
start = subparsers.add_parser("start")
|
|
add_common_arguments(start)
|
|
start.add_argument("--task", required=True)
|
|
start.set_defaults(handler=command_start)
|
|
|
|
retry = subparsers.add_parser("retry")
|
|
add_common_arguments(retry)
|
|
retry.add_argument("--task", required=True)
|
|
retry.set_defaults(handler=command_retry)
|
|
|
|
checkpoint = subparsers.add_parser("checkpoint")
|
|
add_common_arguments(checkpoint)
|
|
checkpoint.add_argument("--task", required=True)
|
|
checkpoint.add_argument("--phase", required=True)
|
|
checkpoint.set_defaults(handler=command_checkpoint)
|
|
|
|
source_begin = subparsers.add_parser("source-begin")
|
|
add_common_arguments(source_begin)
|
|
source_begin.add_argument("--task", required=True)
|
|
source_begin.set_defaults(handler=command_source_begin)
|
|
|
|
source_mark_green = subparsers.add_parser("source-mark-green")
|
|
add_common_arguments(source_mark_green)
|
|
source_mark_green.add_argument("--task", required=True)
|
|
source_mark_green.set_defaults(handler=command_source_mark_green)
|
|
|
|
source_commit = subparsers.add_parser("source-commit")
|
|
add_common_arguments(source_commit)
|
|
source_commit.add_argument("--task", required=True)
|
|
source_commit.add_argument("--subject", required=True)
|
|
source_commit.add_argument("--mark-green", action="store_true")
|
|
source_commit.set_defaults(handler=command_source_commit)
|
|
|
|
source_verify_commit = subparsers.add_parser("source-verify-commit")
|
|
add_common_arguments(source_verify_commit)
|
|
source_verify_commit.add_argument("--task", required=True)
|
|
source_verify_commit.add_argument("--ref", default="HEAD")
|
|
source_verify_commit.set_defaults(handler=command_source_verify_commit)
|
|
|
|
source_preflight = subparsers.add_parser("source-preflight")
|
|
add_common_arguments(source_preflight)
|
|
source_preflight.add_argument("--task", required=True)
|
|
source_preflight.add_argument("--exe")
|
|
source_preflight.set_defaults(handler=command_source_preflight)
|
|
|
|
overlay_save = subparsers.add_parser("overlay-save")
|
|
add_common_arguments(overlay_save)
|
|
overlay_save.add_argument("--task", required=True)
|
|
overlay_save.add_argument(
|
|
"--restore",
|
|
choices=("run", "green", "none"),
|
|
default="run",
|
|
)
|
|
overlay_save.set_defaults(handler=command_overlay_save)
|
|
|
|
overlay_apply = subparsers.add_parser("overlay-apply")
|
|
add_common_arguments(overlay_apply)
|
|
overlay_apply.add_argument("--task", required=True)
|
|
overlay_apply.set_defaults(handler=command_overlay_apply)
|
|
|
|
test_run = subparsers.add_parser("test-run")
|
|
test_run.add_argument("--exe", required=True)
|
|
test_run.add_argument("--run-dir", required=True)
|
|
test_run.add_argument("--portable-root")
|
|
test_run.add_argument("--deadline", type=float, default=120.0)
|
|
test_run.add_argument("--quiet", type=float, default=60.0)
|
|
test_run.add_argument("--grace", type=float, default=15.0)
|
|
test_run.add_argument("--env", action="append")
|
|
test_run.set_defaults(handler=command_test_run)
|
|
|
|
test_cleanup = subparsers.add_parser("test-cleanup")
|
|
test_cleanup.add_argument("--exe", required=True)
|
|
test_cleanup.add_argument("--delete-exe", action="store_true")
|
|
test_cleanup.set_defaults(handler=command_test_cleanup)
|
|
|
|
test_account_reset = subparsers.add_parser("test-account-reset")
|
|
test_account_reset.add_argument("--exe", required=True)
|
|
test_account_reset.add_argument("--portable-root")
|
|
test_account_reset.set_defaults(handler=command_test_account_reset)
|
|
|
|
fence_create = subparsers.add_parser("fence-create")
|
|
fence_create.add_argument("--file", required=True)
|
|
fence_create.add_argument("--root", default=".")
|
|
fence_create.add_argument("paths", nargs="*")
|
|
fence_create.set_defaults(handler=command_fence_create)
|
|
|
|
fence_check = subparsers.add_parser("fence-check")
|
|
fence_check.add_argument("--file", required=True)
|
|
fence_check.add_argument("--root", default=".")
|
|
fence_check.set_defaults(handler=command_fence_check)
|
|
|
|
finish = subparsers.add_parser("finish")
|
|
add_common_arguments(finish)
|
|
finish.add_argument("--task", required=True)
|
|
finish.add_argument("--status", choices=("approved", "blocked"), required=True)
|
|
finish.set_defaults(handler=command_finish)
|
|
|
|
publish = subparsers.add_parser("publish")
|
|
add_common_arguments(publish)
|
|
publish.set_defaults(handler=command_publish)
|
|
|
|
inbox_publish = subparsers.add_parser("inbox-publish")
|
|
add_common_arguments(inbox_publish)
|
|
inbox_publish.add_argument("--transaction", required=True)
|
|
inbox_publish.add_argument("--receipt", required=True)
|
|
inbox_publish.add_argument(
|
|
"--path",
|
|
action="append",
|
|
dest="paths",
|
|
required=True,
|
|
)
|
|
inbox_publish.set_defaults(handler=command_inbox_publish)
|
|
|
|
archive_stale = subparsers.add_parser("archive-stale")
|
|
add_common_arguments(archive_stale)
|
|
archive_stale.add_argument("--days", type=int, default=90)
|
|
archive_stale.set_defaults(handler=command_archive_stale)
|
|
|
|
unarchive = subparsers.add_parser("unarchive")
|
|
add_common_arguments(unarchive)
|
|
unarchive.add_argument("--project", required=True)
|
|
unarchive.set_defaults(handler=command_unarchive)
|
|
|
|
inbox_unarchive = subparsers.add_parser("inbox-unarchive")
|
|
add_common_arguments(inbox_unarchive)
|
|
inbox_unarchive.add_argument("--project", required=True)
|
|
inbox_unarchive.set_defaults(handler=command_inbox_unarchive)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
try:
|
|
args.handler(args)
|
|
except (OSError, ValueError, WorkspaceError) as error:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|