Compare commits

...
10 Commits
Author SHA1 Message Date
KeelrLeek cb8dbc5f2e README: front page explaining what Harbor is, why it helps agent teams, how it works, and a quick start 2026-10-09 12:32:25 -05:00
KeelrLeek 56308e3aab Background compiles give way to the lander: the Logbook statistics and the results refresher wait while [logbook] lander_lock is fresh (under 60 min); the statistics tools run below normal priority and a timeout ends the whole tree (an orphaned cl.exe went on compiling main at normal priority); a retried statistics run no longer relogs the same commits 2026-10-09 10:46:40 -05:00
KeelrLeek 21b7fedb6f re_summary: one call for a function's name, prototype, size, convention, readable status, callers and callees with their status, globals, strings, vtable slots and Mac/Xbox hints (harbor re summary); ghidra_check: confirm the shared Ghidra server serves our image (address range, byte samples, SHA-256 when the imported file is readable) (harbor re ghidra-check) 2026-10-09 09:51:09 -05:00
KeelrLeek 57fb31f2d6 watch.py reports times in CST 2026-10-07 18:21:43 -05:00
KeelrLeek b28f80f1a4 Queue skips catch and unwind funclets (they open on the parent's frame); ruling #12777 2026-10-07 18:10:13 -05:00
KeelrLeek b8dab67871 Logbook clarity: game-code headline, CST times, one size unit, tier table from the progress map with a game column, Decomp view counts game functions, ship's log without repeated or cut titles, newest milestone in the banner; whole-program check builds a clean snapshot of main 2026-10-07 17:05:18 -05:00
KeelrLeek 783c46da56 Logbook: game-functions tile counts game functions only (was all-code decompiled over game total) 2026-10-07 16:48:43 -05:00
KeelrLeek 71e6d6cfda logbook: an All code bar (game + engine + platform, by bytes, with function counts) next to the game-code bar, so engine work shows too 2026-10-07 16:43:38 -05:00
KeelrLeek 59dac57bc8 claims: a draft claim inside another agent's band is allowed when a coordinator assigned the agent a task for that scope (reassigned rework) 2026-10-07 13:27:57 -05:00
KeelrLeek c77ed1a4db policy submits_per_hour: an agent can be capped at N submissions or resubmissions per hour (the User asked the DeepSeeks to slow down and check their work) 2026-10-07 11:04:54 -05:00
17 changed files with 2921 additions and 2265 deletions

No files matched your search

+83 -4
View File
@@ -1,6 +1,85 @@
# Harbor
<div align="center">
# ⚓ Harbor
**A local coordination hub for teams of AI coding agents, and the person running them.**
Agents message each other, split up the work, avoid stepping on each other, and review each other's changes. You watch it all from one page, and step in only when you want to.
`Python 3.11+` · `standard library only` · `SQLite` · `MCP` · `Claude Code` · `Codex` · `OpenCode` · `Kimi` · `DeepSeek`
</div>
---
## Why Harbor?
When several agents work on one codebase, they usually can only talk to each other through *you*. You copy questions from one window into another. You notice too late that two of them rewrote the same file. You can't see who is stuck, who is idle, or who quietly stopped an hour ago.
Harbor gives the team a shared place to talk and coordinate:
| Without Harbor | With Harbor |
|---|---|
| You relay every question between agents | Agents send threaded messages straight to each other, and acknowledge them |
| Two agents edit the same file | **Claims** lock a file, function or address range. A collision alert fires before anyone commits |
| "What should I do next?" | A **task queue** with priorities, dependencies and owners. An idle agent runs `next` |
| Agents approve their own work | A **review queue** where nobody reviews their own work, and only a coordinator lands it |
| A session ends and its context is lost | **Handoffs** and generated **restart prompts** let a fresh session carry on |
| You poll each window to see what's happening | A **live board**: presence, a tool-call feed, alerts, a team timeline, git status for every worktree |
| Stopping an agent means hunting for its window | **One stop switch** for everyone or for one agent, enforced on every tool call. Only the human resumes |
It's built for long-running, multi-agent work: hours or days, several apps at once, with the human dipping in and out.
## How it works
```mermaid
flowchart LR
subgraph Agents
A1[Claude Code]
A2[Codex]
A3[OpenCode / Kimi / DeepSeek]
end
A1 -- MCP tools --> H
A2 -- MCP tools --> H
A3 -- CLI / MCP --> H
A1 -. hooks: activity, stop switch .-> H
A2 -. hooks .-> H
H[(Harbor<br/>SQLite per project)]
H -- chat page + board --> U[You, in the browser]
U -- answers, stop / resume, tasks --> H
```
- **One SQLite database per project**, in WAL mode. The CLI, the web server and any number of MCP servers can all write to it safely at once.
- **Agents connect through MCP or the CLI.** `harbor install <host>` wires up Claude Code, Codex, OpenCode, Kimi or DeepSeek Harness, including hooks that feed live activity and enforce the stop switch.
- **Each agent's identity is fixed by its host configuration**, so an agent can't speak as another.
- **The web server binds to localhost only.** It gives you a texting-style chat page and a full coordination board.
## Quick start
```bash
git clone https://git.whoagland.com/Keelrleek/Harbor.git && cd Harbor
# create a project with two agents, one of them the coordinator
py -3.12 -B run.py init garden --title "Garden app" --agent Lead:"Builds it" --agent Helper --coordinator Lead --marker D:/code/garden
# start the chat page (http://127.0.0.1:8766) and the board (/board)
py -3.12 -B run.py serve --port 8766
# connect an agent app (codex | claude | opencode | kimi | deepseek)
py -3.12 -B run.py install claude --agent Lead
# check everything is wired up
py -3.12 -B run.py doctor
```
On Linux or macOS, use `python3` in place of `py -3.12`. To get a `harbor` command, run `pip install -e .`. The optional MCP server needs `pip install -e .[mcp]`.
Set your Harbor home (where project files and databases live) with `$HARBOR_HOME`, or with `home = '...'` in `local.toml`. The default is `~/.harbor`.
---
## Features
Harbor is a local coordination hub for a team of AI coding agents and the person running them. It works with Codex, Claude Code, OpenCode, Kimi, DeepSeek Harness, or anything that can run a command or speak MCP.
**Talking**
- **Threads.** Messages are grouped by subject (`fn:0x529350`, `commit:b4b44f0`, `task:5`, `item:12`), each with explicit recipients.
@@ -90,7 +169,7 @@ Harbor is a local coordination hub for a team of AI coding agents and the person
Each project is one SQLite database (WAL mode), so the CLI, the web server and many MCP servers can write at once safely. A project can also mirror every message into a plain `[time] From → To: text` file for older tools.
## Quick start
## Project setup
```bash
py -3.12 -B run.py init garden --title "Garden app" --agent Lead:"Builds it" --agent Helper --coordinator Lead --marker D:/code/garden
@@ -183,4 +262,4 @@ Hooks resolve the project from the agent's working folder and stay silent anywhe
py -3.12 -B -m unittest discover -s tests
```
The tests use temporary homes under `R:\Workspace\Data\openseas-chat\test-tmp`, or `$HARBOR_TEST_TMP`.
Set `$HARBOR_TEST_TMP` to a scratch folder first; the tests create their temporary homes there (the built-in default is the original author's path).
+16 -1
View File
@@ -255,7 +255,7 @@ def build_parser():
p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)")
p.add_argument("scope")
p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations")
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools", "debugger-tools", "match", "similar", "struct-audit", "behaviour"])
p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "summary", "ghidra", "ghidra-tools", "ghidra-check", "debugger-tools", "match", "similar", "struct-audit", "behaviour"])
p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool")
p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON")
p.add_argument("--decomp", help="match: the checkout or worktree to compile from")
@@ -722,6 +722,21 @@ def main(argv=None):
elif args.action == "ghidra-tools":
res = Ghidra(harbor.project.reverse).tools()
out(res, "\n".join(f"{t['name']:<26} {t['description'].splitlines()[0][:90]}" for t in res))
elif args.action == "ghidra-check":
from .reverse import ghidra_check
res = ghidra_check(harbor.project.reverse)
mark = lambda ok: "ok " if ok else ("?? " if ok is None else "FAIL")
out(res, "\n".join(
[f"{'Ghidra serves our program' if res['ok'] else 'Ghidra does NOT serve our program'}: "
f"{res['program']} ({res['functions_in_ghidra']} functions); image {res['image_base']}, "
f"{res['image_size']} bytes, sha256 {res['image_sha256'][:16]}..."] +
[f" {mark(c['ok'])} {c['check']}: " + (f"expected {c['expected']}, Ghidra {c['ghidra']}" if "ghidra" in c
else ("differs at " + ", ".join(c["differ_at"]) if c.get("differ_at") else "all equal"))
for c in res["checks"]]))
elif args.action == "summary":
from .reverse import format_summary
res = r.summary(args.target)
out(res, format_summary(res))
elif args.action == "ghidra":
text = Ghidra(harbor.project.reverse).call(args.target, json.loads(args.arguments))
out({"result": text}, text)
+11 -1
View File
@@ -709,7 +709,14 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
if kind == "draft" and lo is not None:
bands = [a for a in active if a["kind"] == "band"
and scopes_overlap((canonical, lo, hi), (a["scope"], a["lo"], a["hi"]))]
if bands and not any(b["agent"] == agent for b in bands):
# a task a coordinator assigned to this agent for this scope is the exception to another's band
assigned = any(scopes_overlap((canonical, lo, hi), (t["scope"], *parse_scope(t["scope"])[1:]))
for t in c.execute("SELECT scope FROM tasks WHERE assignee=? AND scope IS NOT NULL "
"AND state IN ('open','in_progress') AND created_by IN (%s)"
% ",".join("?" * len(self.coordinators)),
(agent, *self.coordinators)).fetchall()
if t["scope"].startswith(("fn:", "range:", "file:")))
if bands and not any(b["agent"] == agent for b in bands) and not assigned:
conflicts += bands
if conflicts and not override:
described = "; ".join(f"{a['agent']} holds {a['kind']} {a['scope']}"
@@ -762,6 +769,7 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
raise HarborError("no reviewer: name one, or set reviewers in the project file")
canonical = parse_scope(scope)[0] if scope else None
commit = self.resolve_commit(author, commit)
self.check_submit_policy(author)
def do(c):
now = self.now()
@@ -825,6 +833,8 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
self.check_landing_policy(item_id)
if is_author and commit:
commit = self.resolve_commit(agent, commit)
if is_author and to_state == "submitted":
self.check_submit_policy(agent)
def do(c):
now = self.now()
+292 -278
View File
@@ -1,278 +1,292 @@
"""How work flows: git radar, bulk tasks and templates, the stuck-agent watchdog, and team policies.
git radar read-only git facts per agent workdir: branch, last commit, uncommitted files, commits behind the main
branch, and commits made since the agent last submitted work (work not yet in the review queue)
templates multi-step task pipelines defined in the project file, created in one call with dependencies
bulk many tasks from a pasted list ("0x445310 RouteCellCost"), routed to the band owner
watchdog an agent busy with tools for hours without any visible progress
policies rules from the project file that Harbor enforces (approvals before landing, message caps)
Mixed into Harbor (core.py).
"""
import re
import subprocess
import time
from pathlib import Path
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
GIT_TIMEOUT = 10
_git_cache = {}
class _Values(dict):
"""Template placeholders; an unknown {name} stays as written instead of failing."""
def __missing__(self, key):
return "{" + key + "}"
def _git(workdir, *args):
"""A read-only git command (never takes locks, never writes the index)."""
# The worktree's own config is agent-controlled: never let it start programs (fsmonitor, pagers) from here.
r = subprocess.run(["git", "--no-pager", "--no-optional-locks", "-c", "core.fsmonitor=false",
"-c", "core.untrackedCache=false", "-C", str(workdir), *args], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
return r.stdout
class FlowMixin:
def resolve_commit(self, agent, commit):
"""The full hash of a commit an item names, checked in agent's workdir (or the Logbook's decomp checkout).
A placeholder ('HEAD', 'PLACEHOLDER') or a hash git does not know is refused; without any git checkout to
ask, the value is kept as given."""
if not commit:
return commit
commit = str(commit).strip()
spec = next((a for a in self.project.agents if a["name"] == agent), {})
places = [spec.get("workdir"), (self.project.logbook or {}).get("decomp")]
repos = [p for p in places if p and (Path(p) / ".git").exists()]
if not repos:
return commit
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
raise HarborError(f"commit {commit!r} is not a hash: give the commit's hash (git rev-parse HEAD), "
"not a branch name, HEAD or a placeholder")
for repo in repos:
try:
return _git(repo, "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}").strip()
except (RuntimeError, subprocess.TimeoutExpired):
continue
raise HarborError(f"commit {commit} is not in the repository: commit your work first and give the hash "
"git rev-parse HEAD prints")
def worktree_change(self, agent):
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
spec = next((a for a in self.project.agents if a["name"] == agent), {})
workdir = spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
newest = None
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
path = line[3:].split(" -> ")[-1].strip().strip('"')
try:
mtime = (Path(workdir) / path).stat().st_mtime
except OSError:
continue
if newest is None or mtime > newest[0]:
newest = (mtime, path)
return newest
# ----------------------------------------------------------------- git radar
def git_status(self, agent, max_age=60):
"""Git facts for agent's workdir (cached for max_age seconds)."""
spec = next((a for a in self.project.agents if a["name"] == agent), None)
workdir = spec and spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
key = (str(workdir), self.project.name)
hit = _git_cache.get(key)
if hit and time.monotonic() - hit[0] < max_age:
return hit[1]
main = self.project.git_main
out = {"agent": agent, "workdir": workdir}
try:
out["branch"] = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD").strip()
sha, stamp, subject = (_git(workdir, "log", "-1", "--format=%h%x1f%ct%x1f%s").strip().split("\x1f") + ["", "", ""])[:3]
out["last_commit"] = {"sha": sha, "at": iso(utc(int(stamp))) if stamp else None, "subject": subject}
out["dirty"] = len([l for l in _git(workdir, "status", "--porcelain").splitlines() if l.strip()])
out["behind_main"] = out["ahead_of_main"] = 0
if out["branch"] != main:
try:
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
except (RuntimeError, ValueError):
out["main_missing"] = True
since = self._last_submit_at(agent)
# Author dates survive a rebase (commit dates do not), so rebased work does not look new again.
cutoff = since or iso(utc(self.clock() - 86400))
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
if out["branch"] != main and not out.get("main_missing"):
args.append(f"{main}..HEAD")
commits = []
for line in _git(workdir, *args).splitlines():
parts = line.split("\x1f")
if len(parts) == 3 and iso(utc(int(parts[1]))) > cutoff:
commits.append({"sha": parts[0], "at": iso(utc(int(parts[1]))), "subject": parts[2]})
commits = commits[:50]
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
if r[0]}
submits = out["branch"] != main and str(spec.get("git_submits", True)).lower() not in ("false", "0", "no")
out["unsubmitted"] = [c for c in commits if c["sha"][:7] not in known] if submits else []
out["since"] = since
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
out["error"] = str(error)[:200]
_git_cache[key] = (time.monotonic(), out)
return out
def _last_submit_at(self, agent):
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
(agent,)).fetchone()
return row["a"] if row and row["a"] else None
def git_radar(self):
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
if a["kind"] != "human" and a.get("workdir")}
# ----------------------------------------------------------------- bulk tasks and templates
def band_owner(self, scope):
canonical, lo, hi = parse_scope(scope)
if lo is None:
return None
now = self.now()
for c in self.conn.execute("SELECT * FROM claims WHERE kind='band' AND released_at IS NULL AND "
"(expires_at IS NULL OR expires_at > ?)", (now,)):
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
return c["agent"]
return None
def run_template(self, by, template, scope=None, title=None, owner=None, priority="normal", origin="cli"):
"""Create the steps of a template as linked tasks. Placeholders: {scope}, {title}, {owner}."""
spec = self.project.templates.get(template)
if not spec:
raise HarborError(f"no template {template!r}; the project file defines: "
+ (", ".join(self.project.templates) or "none"))
owner = owner or (self._route(by, scope) if scope else None)
values = _Values(scope=scope or "", title=title or scope or template, owner=owner or "")
for step in spec.get("steps", []): # check every step before creating any
assignee = (step.get("assignee") or "").format_map(values) or None
if assignee:
self._check_agent(assignee, allow_human=False)
if assignee != by and by not in self.coordinators:
raise HarborError(f"template {template!r} assigns a step to {assignee}; only a coordinator does that")
created, by_name = [], {}
for step in spec.get("steps", []):
assignee = (step.get("assignee") or "").format_map(values) or None
after = [by_name[n] for n in step.get("after", []) if n in by_name]
task = self.add_task(by, step.get("title", "{title}").format_map(values), body=step.get("body"),
scope=scope if step.get("scoped", True) else None, assignee=assignee,
priority=step.get("priority", priority), after=after, origin=origin)
by_name[step.get("name", str(len(by_name)))] = task["id"]
created.append(task)
return created
LINE = re.compile(r"^\s*(?:fn:)?(0x[0-9A-Fa-f]+)\b[\s:|,-]*(.*)$")
def bulk_tasks(self, by, text, template=None, priority="normal", assignee=None, origin="cli"):
"""One task (or template pipeline) per line: '0x445310 RouteCellCost' or 'fn:0x445310 | Make it readable'.
Lines without an address become plain tasks. Each address goes to its band owner unless assignee is set."""
if assignee and assignee != by and by not in self.coordinators:
raise HarborError("only a coordinator assigns tasks to someone else")
created, skipped = [], []
open_scopes = {t["scope"] for t in self.tasks(states=("open", "in_progress", "blocked", "review"), limit=100000)}
for raw in str(text).splitlines(): # validate everything first: no half-created batches
m = self.LINE.match(raw.strip()) if raw.strip() and not raw.strip().startswith("#") else None
if m:
parse_scope(f"fn:{m.group(1)}")
for raw in str(text).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = self.LINE.match(line)
scope = f"fn:{m.group(1)}" if m else None
name = (m.group(2).strip() if m else line) or None
if scope:
canonical = parse_scope(scope)[0]
if canonical in open_scopes:
skipped.append(f"{canonical} already has an open task")
continue
open_scopes.add(canonical)
if template:
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
origin=origin)
else:
title = (f"Make {scope.split(':', 1)[1]} readable" + (f" ({name})" if name else "")) if scope else name
created.append(self.add_task(by, title, scope=scope,
assignee=assignee or (self._route(by, scope) if scope else None),
priority=priority, origin=origin, notify=template is not None))
if not template: # one notice per agent, not one thread per task
mine = {}
for t in created:
if t.get("assignee") and t["assignee"] != by:
mine.setdefault(t["assignee"], []).append(t)
for agent, items in mine.items():
lines = "\n".join(f"#{t['id']} {t['title']}" for t in items)
self.send(by, [agent], f"{len(items)} new task{'s' if len(items) > 1 else ''} for you; take them "
f"with next:\n{lines}", thread="tasks", subject="New tasks", kind="request",
origin="system")
return {"created": created, "skipped": skipped}
def _route(self, by, scope):
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
owner = self.band_owner(scope)
return owner if owner and (by in self.coordinators or owner == by) else None
# ----------------------------------------------------------------- stuck-agent watchdog
def busy_without_progress(self, hours=2, min_tools=150):
"""Agents with many tool calls in the window but no message, submit, review move, task change, handoff or
new `doing` line - possibly stuck in a loop."""
since = iso(utc(self.clock() - hours * 3600))
out = []
for a in self.agents():
if a["kind"] == "human" or self.stopped(a["name"]):
continue
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
(a["name"], since)).fetchone()[0]
if tools < min_tools:
continue
progress = any(self.conn.execute(sql, (a["name"], since)).fetchone()[0] for sql in (
"SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND origin NOT IN ('hook', 'system')",
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM task_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM handoffs WHERE agent=? AND created_at>=?",
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
if not progress:
out.append({"agent": a["name"], "tools": tools, "hours": hours})
return out
# ----------------------------------------------------------------- policies
def policy(self, key, default=None):
return self.project.policy.get(key, default)
def check_landing_policy(self, item_id):
required = list(self.policy("land_requires") or [])
author = self.conn.execute("SELECT author FROM items WHERE id=?", (int(item_id),)).fetchone()
required = [r for r in required if not author or r != author["author"]] # authors cannot approve
if not required:
return
last_submit = self.conn.execute("SELECT MAX(id) FROM item_events WHERE item_id=? AND to_state='submitted'",
(int(item_id),)).fetchone()[0] or 0
approvers = {r["agent"] for r in self.conn.execute(
"SELECT agent FROM item_events WHERE item_id=? AND to_state='approved' AND id>?", (int(item_id), last_submit))}
missing = [r for r in required if r not in approvers]
if missing:
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
"(since its last submission)")
def check_message_policy(self, sender, to_all):
cap = self.policy("all_per_hour")
if not cap or not to_all or sender in self.coordinators:
return
since = iso(utc(self.clock() - 3600))
count = self.conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND "
"recipients='[\"All\"]' AND origin NOT IN ('legacy', 'system', 'hook')",
(sender, since)).fetchone()[0]
if count >= int(cap):
raise HarborError(f"policy: at most {cap} messages to everyone per hour - use `doing` for progress, or "
"a thread with the people concerned")
"""How work flows: git radar, bulk tasks and templates, the stuck-agent watchdog, and team policies.
git radar read-only git facts per agent workdir: branch, last commit, uncommitted files, commits behind the main
branch, and commits made since the agent last submitted work (work not yet in the review queue)
templates multi-step task pipelines defined in the project file, created in one call with dependencies
bulk many tasks from a pasted list ("0x445310 RouteCellCost"), routed to the band owner
watchdog an agent busy with tools for hours without any visible progress
policies rules from the project file that Harbor enforces (approvals before landing, message caps)
Mixed into Harbor (core.py).
"""
import re
import subprocess
import time
from pathlib import Path
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
GIT_TIMEOUT = 10
_git_cache = {}
class _Values(dict):
"""Template placeholders; an unknown {name} stays as written instead of failing."""
def __missing__(self, key):
return "{" + key + "}"
def _git(workdir, *args):
"""A read-only git command (never takes locks, never writes the index)."""
# The worktree's own config is agent-controlled: never let it start programs (fsmonitor, pagers) from here.
r = subprocess.run(["git", "--no-pager", "--no-optional-locks", "-c", "core.fsmonitor=false",
"-c", "core.untrackedCache=false", "-C", str(workdir), *args], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
return r.stdout
class FlowMixin:
def resolve_commit(self, agent, commit):
"""The full hash of a commit an item names, checked in agent's workdir (or the Logbook's decomp checkout).
A placeholder ('HEAD', 'PLACEHOLDER') or a hash git does not know is refused; without any git checkout to
ask, the value is kept as given."""
if not commit:
return commit
commit = str(commit).strip()
spec = next((a for a in self.project.agents if a["name"] == agent), {})
places = [spec.get("workdir"), (self.project.logbook or {}).get("decomp")]
repos = [p for p in places if p and (Path(p) / ".git").exists()]
if not repos:
return commit
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
raise HarborError(f"commit {commit!r} is not a hash: give the commit's hash (git rev-parse HEAD), "
"not a branch name, HEAD or a placeholder")
for repo in repos:
try:
return _git(repo, "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}").strip()
except (RuntimeError, subprocess.TimeoutExpired):
continue
raise HarborError(f"commit {commit} is not in the repository: commit your work first and give the hash "
"git rev-parse HEAD prints")
def worktree_change(self, agent):
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
spec = next((a for a in self.project.agents if a["name"] == agent), {})
workdir = spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
newest = None
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
path = line[3:].split(" -> ")[-1].strip().strip('"')
try:
mtime = (Path(workdir) / path).stat().st_mtime
except OSError:
continue
if newest is None or mtime > newest[0]:
newest = (mtime, path)
return newest
# ----------------------------------------------------------------- git radar
def git_status(self, agent, max_age=60):
"""Git facts for agent's workdir (cached for max_age seconds)."""
spec = next((a for a in self.project.agents if a["name"] == agent), None)
workdir = spec and spec.get("workdir")
if not workdir or not Path(workdir).is_dir():
return None
key = (str(workdir), self.project.name)
hit = _git_cache.get(key)
if hit and time.monotonic() - hit[0] < max_age:
return hit[1]
main = self.project.git_main
out = {"agent": agent, "workdir": workdir}
try:
out["branch"] = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD").strip()
sha, stamp, subject = (_git(workdir, "log", "-1", "--format=%h%x1f%ct%x1f%s").strip().split("\x1f") + ["", "", ""])[:3]
out["last_commit"] = {"sha": sha, "at": iso(utc(int(stamp))) if stamp else None, "subject": subject}
out["dirty"] = len([l for l in _git(workdir, "status", "--porcelain").splitlines() if l.strip()])
out["behind_main"] = out["ahead_of_main"] = 0
if out["branch"] != main:
try:
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
except (RuntimeError, ValueError):
out["main_missing"] = True
since = self._last_submit_at(agent)
# Author dates survive a rebase (commit dates do not), so rebased work does not look new again.
cutoff = since or iso(utc(self.clock() - 86400))
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
if out["branch"] != main and not out.get("main_missing"):
args.append(f"{main}..HEAD")
commits = []
for line in _git(workdir, *args).splitlines():
parts = line.split("\x1f")
if len(parts) == 3 and iso(utc(int(parts[1]))) > cutoff:
commits.append({"sha": parts[0], "at": iso(utc(int(parts[1]))), "subject": parts[2]})
commits = commits[:50]
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
if r[0]}
submits = out["branch"] != main and str(spec.get("git_submits", True)).lower() not in ("false", "0", "no")
out["unsubmitted"] = [c for c in commits if c["sha"][:7] not in known] if submits else []
out["since"] = since
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
out["error"] = str(error)[:200]
_git_cache[key] = (time.monotonic(), out)
return out
def _last_submit_at(self, agent):
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
(agent,)).fetchone()
return row["a"] if row and row["a"] else None
def git_radar(self):
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
if a["kind"] != "human" and a.get("workdir")}
# ----------------------------------------------------------------- bulk tasks and templates
def band_owner(self, scope):
canonical, lo, hi = parse_scope(scope)
if lo is None:
return None
now = self.now()
for c in self.conn.execute("SELECT * FROM claims WHERE kind='band' AND released_at IS NULL AND "
"(expires_at IS NULL OR expires_at > ?)", (now,)):
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
return c["agent"]
return None
def run_template(self, by, template, scope=None, title=None, owner=None, priority="normal", origin="cli"):
"""Create the steps of a template as linked tasks. Placeholders: {scope}, {title}, {owner}."""
spec = self.project.templates.get(template)
if not spec:
raise HarborError(f"no template {template!r}; the project file defines: "
+ (", ".join(self.project.templates) or "none"))
owner = owner or (self._route(by, scope) if scope else None)
values = _Values(scope=scope or "", title=title or scope or template, owner=owner or "")
for step in spec.get("steps", []): # check every step before creating any
assignee = (step.get("assignee") or "").format_map(values) or None
if assignee:
self._check_agent(assignee, allow_human=False)
if assignee != by and by not in self.coordinators:
raise HarborError(f"template {template!r} assigns a step to {assignee}; only a coordinator does that")
created, by_name = [], {}
for step in spec.get("steps", []):
assignee = (step.get("assignee") or "").format_map(values) or None
after = [by_name[n] for n in step.get("after", []) if n in by_name]
task = self.add_task(by, step.get("title", "{title}").format_map(values), body=step.get("body"),
scope=scope if step.get("scoped", True) else None, assignee=assignee,
priority=step.get("priority", priority), after=after, origin=origin)
by_name[step.get("name", str(len(by_name)))] = task["id"]
created.append(task)
return created
LINE = re.compile(r"^\s*(?:fn:)?(0x[0-9A-Fa-f]+)\b[\s:|,-]*(.*)$")
def bulk_tasks(self, by, text, template=None, priority="normal", assignee=None, origin="cli"):
"""One task (or template pipeline) per line: '0x445310 RouteCellCost' or 'fn:0x445310 | Make it readable'.
Lines without an address become plain tasks. Each address goes to its band owner unless assignee is set."""
if assignee and assignee != by and by not in self.coordinators:
raise HarborError("only a coordinator assigns tasks to someone else")
created, skipped = [], []
open_scopes = {t["scope"] for t in self.tasks(states=("open", "in_progress", "blocked", "review"), limit=100000)}
for raw in str(text).splitlines(): # validate everything first: no half-created batches
m = self.LINE.match(raw.strip()) if raw.strip() and not raw.strip().startswith("#") else None
if m:
parse_scope(f"fn:{m.group(1)}")
for raw in str(text).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = self.LINE.match(line)
scope = f"fn:{m.group(1)}" if m else None
name = (m.group(2).strip() if m else line) or None
if scope:
canonical = parse_scope(scope)[0]
if canonical in open_scopes:
skipped.append(f"{canonical} already has an open task")
continue
open_scopes.add(canonical)
if template:
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
origin=origin)
else:
title = (f"Make {scope.split(':', 1)[1]} readable" + (f" ({name})" if name else "")) if scope else name
created.append(self.add_task(by, title, scope=scope,
assignee=assignee or (self._route(by, scope) if scope else None),
priority=priority, origin=origin, notify=template is not None))
if not template: # one notice per agent, not one thread per task
mine = {}
for t in created:
if t.get("assignee") and t["assignee"] != by:
mine.setdefault(t["assignee"], []).append(t)
for agent, items in mine.items():
lines = "\n".join(f"#{t['id']} {t['title']}" for t in items)
self.send(by, [agent], f"{len(items)} new task{'s' if len(items) > 1 else ''} for you; take them "
f"with next:\n{lines}", thread="tasks", subject="New tasks", kind="request",
origin="system")
return {"created": created, "skipped": skipped}
def _route(self, by, scope):
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
owner = self.band_owner(scope)
return owner if owner and (by in self.coordinators or owner == by) else None
# ----------------------------------------------------------------- stuck-agent watchdog
def busy_without_progress(self, hours=2, min_tools=150):
"""Agents with many tool calls in the window but no message, submit, review move, task change, handoff or
new `doing` line - possibly stuck in a loop."""
since = iso(utc(self.clock() - hours * 3600))
out = []
for a in self.agents():
if a["kind"] == "human" or self.stopped(a["name"]):
continue
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
(a["name"], since)).fetchone()[0]
if tools < min_tools:
continue
progress = any(self.conn.execute(sql, (a["name"], since)).fetchone()[0] for sql in (
"SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND origin NOT IN ('hook', 'system')",
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM task_events WHERE agent=? AND at>=?",
"SELECT COUNT(*) FROM handoffs WHERE agent=? AND created_at>=?",
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
if not progress:
out.append({"agent": a["name"], "tools": tools, "hours": hours})
return out
# ----------------------------------------------------------------- policies
def policy(self, key, default=None):
return self.project.policy.get(key, default)
def check_landing_policy(self, item_id):
required = list(self.policy("land_requires") or [])
author = self.conn.execute("SELECT author FROM items WHERE id=?", (int(item_id),)).fetchone()
required = [r for r in required if not author or r != author["author"]] # authors cannot approve
if not required:
return
last_submit = self.conn.execute("SELECT MAX(id) FROM item_events WHERE item_id=? AND to_state='submitted'",
(int(item_id),)).fetchone()[0] or 0
approvers = {r["agent"] for r in self.conn.execute(
"SELECT agent FROM item_events WHERE item_id=? AND to_state='approved' AND id>?", (int(item_id), last_submit))}
missing = [r for r in required if r not in approvers]
if missing:
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
"(since its last submission)")
def check_submit_policy(self, author):
"""policy submits_per_hour: an agent capped at N submissions (new or resubmitted) per hour must slow down
and check its work before sending more."""
cap = (self.policy("submits_per_hour") or {}).get(author)
if not cap:
return
since = iso(utc(self.clock() - 3600))
count = self.conn.execute("SELECT COUNT(*) FROM item_events WHERE agent=? AND to_state='submitted' AND at>=?",
(author, since)).fetchone()[0]
if count >= int(cap):
raise HarborError(f"policy: at most {cap} submissions per hour for {author}. Slow down: re-read the "
"disassembly against your C line by line, run quick_readable and re_behaviour, list "
"your changed annotation rows, and submit only work you have checked twice")
def check_message_policy(self, sender, to_all):
cap = self.policy("all_per_hour")
if not cap or not to_all or sender in self.coordinators:
return
since = iso(utc(self.clock() - 3600))
count = self.conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND "
"recipients='[\"All\"]' AND origin NOT IN ('legacy', 'system', 'hook')",
(sender, since)).fetchone()[0]
if count >= int(cap):
raise HarborError(f"policy: at most {cap} messages to everyone per hour - use `doing` for progress, or "
"a thread with the people concerned")
+69
View File
@@ -0,0 +1,69 @@
"""The server's background compiles (the Logbook's statistics, the whole-program check, the emulator run) give way to
the lander: its gates (a full readable check of every file) are what land work on main, and a second compile of main
beside them roughly quadruples their time.
Two parts:
lander_busy(project) while [logbook] lander_lock names a file that exists and was written in the last
STALE_MINUTES, the lander is running its gates: background jobs do not start. The lander
creates the file before its gates and removes it after the push; an older file is a lander
that died, and is ignored.
run_low(...) subprocess.run at BELOW_NORMAL priority (children such as cl.exe inherit it) that, on a
timeout, ends the whole process tree instead of leaving the compiler running orphaned.
Configured per project in the [logbook] table:
lander_lock = 'R:/Workspace/Data/lander-busy.lock' unset or "": never wait"""
import os
import subprocess
import time
from pathlib import Path
STALE_MINUTES = 60
BELOW_NORMAL = getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0)
IDLE = getattr(subprocess, "IDLE_PRIORITY_CLASS", 0)
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def lock_path(project):
path = ((getattr(project, "logbook", None) or {}).get("lander_lock") or "").strip()
return Path(path) if path else None
def lander_busy(project, now=None):
"""Why background work should wait (the lander's lock is fresh), or None."""
path = lock_path(project)
if path is None:
return None
try:
written = path.stat().st_mtime
except OSError:
return None
age = (time.time() if now is None else now) - written
if age >= STALE_MINUTES * 60:
return None
return f"the lander is running its gates ({path.name}, {max(0, int(age // 60))} min old)"
def kill_tree(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, creationflags=NO_WINDOW)
else:
try:
os.kill(pid, 9)
except OSError:
pass
def run_low(argv, timeout, priority=BELOW_NORMAL, **kwargs):
"""subprocess.run(argv, capture_output=True, text=True, ...) at a lower priority class; on a timeout the whole
tree (cmd.exe, cl.exe) is ended before TimeoutExpired is raised."""
flags = kwargs.pop("creationflags", 0) | (priority | NO_WINDOW if os.name == "nt" else 0)
with subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, creationflags=flags, **kwargs) as proc:
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
kill_tree(proc.pid)
proc.kill()
proc.communicate()
raise subprocess.TimeoutExpired(argv, timeout) from None
return subprocess.CompletedProcess(argv, proc.returncode, stdout, stderr)
+20 -7
View File
@@ -9,6 +9,8 @@ Configured per project in a [logbook] table:
decomp = 'R:/.../open-seas-decomp' the repository whose main branch is followed
stats_repo = 'R:/.../stats-work/repo' a scratch copy of open-seas holding tools/progress/*.py
interval_minutes = 10 how often the server looks (default 10)
lander_lock = 'R:/.../lander-busy.lock' wait while the lander runs its gates (harbor.lander_gate)
The progress tools run below normal priority.
Milestones and narrative entries stay with people: they need judgement.
State (the last commit seen) is kept in the logbook folder as auto.json."""
@@ -22,9 +24,10 @@ import time
import traceback
from pathlib import Path
from . import logbook
from . import lander_gate, logbook
MAX_ENTRIES = 25
STATS_TIMEOUT = 1800 # each progress tool; sync_decomp compiles every readable file, below normal priority
def _git(repo, *args):
@@ -73,8 +76,8 @@ def log_commits(project, repo, since, head):
for sha, when, subject, body in commits:
files = [f for f in _git(repo, "show", "--name-only", "--format=", sha).splitlines() if f.strip()]
lines = [l for l in body.strip().splitlines() if l.strip() and not l.lower().startswith("co-authored-by")]
detail = "\n".join(lines[:8]) if lines else subject
detail += f"\n(main {sha[:8]}, {len(files)} file{'s' if len(files) != 1 else ''})"
detail = "\n".join(lines[:8]) + "\n" if lines else "" # the board shows the title already
detail += f"(main {sha[:8]}, {len(files)} file{'s' if len(files) != 1 else ''})"
added.append(logbook.add_entry(project, subject, detail, kind=_kind(files), at=_utc(when)))
return added
@@ -88,8 +91,10 @@ def rebuild_stats(project, repo, stats_repo, head):
installer = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer"
if os.path.isdir(installer) and installer.lower() not in env.get("PATH", "").lower():
env["PATH"] = installer + os.pathsep + env.get("PATH", "")
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo), env=env,
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
# below normal priority (cl.exe inherits it), so the lander's and the agents' compiles go first; a timeout ends
# the whole tree, not only python (an orphaned cl.exe went on compiling all of main at normal priority)
run = lambda *argv: lander_gate.run_low([sys.executable, "-B", *map(str, argv)], STATS_TIMEOUT,
cwd=str(stats_repo), env=env, encoding="utf-8", errors="replace")
sync = run(tools / "sync_decomp.py", "--decomp", repo)
if sync.returncode != 0:
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
@@ -134,12 +139,20 @@ def refresh(project, force=False):
state = _state(project)
if state.get("last_head") == head and not force:
return {"changed": False, "head": head[:8]}
busy = None if force else lander_gate.lander_busy(project)
if busy: # its gates compile all of main: look again next time
return {"changed": False, "head": head[:8], "waiting": busy}
out = {"changed": True, "head": head[:8]}
if state.get("last_head"):
# last_head is where the statistics are; logged_head where the ship's log is (a failed statistics run is retried,
# and must not log the same commits twice)
logged = state.get("logged_head") or state.get("last_head")
if logged and logged != head:
try:
out["entries"] = len(log_commits(project, repo, state["last_head"], head))
out["entries"] = len(log_commits(project, repo, logged, head))
except RuntimeError as error: # e.g. the old commit vanished after a rewrite
out["log_error"] = str(error)
if logged:
state["logged_head"] = head
if stats_repo:
try:
out["stats"] = rebuild_stats(project, snapshot_at(config, repo, head), stats_repo, head)
+21
View File
@@ -38,6 +38,8 @@ review queue, presence and stop flags. Protocol:
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next.
11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's
own image with the team's current names, so do not write your own disassembler or offset maths.
re_summary(address) gives the overview in one call: name, prototype, size, convention, readable status,
callers and callees with their status, globals, strings, vtable slots and the Mac/Xbox hints.
For Ghidra's view (decompiler, p-code, stack frames, struct field references, types) use ghidra_tools() once,
then ghidra(tool, arguments): one shared read-only Ghidra session holding the team's annotations.
To check a value in the running game (x87 control word, `this`, struct fields), use debugger_tools() and
@@ -563,6 +565,14 @@ def re_lookup(target: str) -> dict:
return _re(lambda r: r.lookup(target))
@mcp.tool
def re_summary(target: str) -> dict:
"""One call before starting on a function: its current name and prototype, size and calling convention (read
from the bytes), whether it is readable on main, its callers and callees with their names and whether each is
readable on main, the globals and strings it uses, the vtable slots it fills, and the Mac/Xbox name hints."""
return _re(lambda r: r.summary(target))
_ghidra = {}
@@ -692,6 +702,17 @@ def ghidra_tools() -> list:
return _gh(lambda g: g.tools())
@mcp.tool()
def ghidra_check() -> dict:
"""Is the shared Ghidra session serving our program? Compares its address range with the project image's base
and size, the SHA-256 of the file it imported with ours, and sample bytes; ok is false when any differs."""
try:
from .reverse import ghidra_check as check
return check(harbor().project.reverse)
except (HarborError, projects.ProjectError) as error:
raise ToolError(str(error)) from None
@mcp.tool()
def ghidra(tool: str, arguments: dict | None = None) -> str:
"""Run one tool in the shared read-only Ghidra session, e.g. ghidra('get_code', {'function': 'GetFogLevel',
+331 -321
View File
@@ -1,321 +1,331 @@
"""The Logbook's progress map, in the style of decomp.dev: one unit per original source file, its functions and how
far each has come, plus the headline share of game code decompiled and the Code and Data bars.
GET /api/logbook/progress-map answers with one compact JSON document. It is computed once and kept until one of its
inputs changes (main's HEAD, the Logbook's symbol table and statistics, the match, emulator and library results,
facts, names, source files, globals, or a deferral note); `?have=<version>` answers {"version", "same": true} when
the caller already holds the current one, so a page that polls does not download or re-lay-out anything.
Definitions follow the Logbook exactly (open-seas tools/progress/progress.py, which writes stats.json):
- the symbol table is <[logbook] stats_repo>/re/symbols/pc102.csv: its category and status per function;
- counted ("game code") = category game and status not superseded or wontfix;
- decompiled = status decompiled, reimplemented or verified, so percent = decompiled game bytes / game bytes,
the Logbook's game_bytes_decompiled_percent.
Decompiled functions are split further, best first: exact (byte match in the [reverse] match_results), verified
(behaves the same in the emulator, diffrun_results, not 'weak'), readable. The rest: deferred (a live DEFERRED note),
raw, and, not counted, unsorted, engine (Gamebryo, named only) and library (C runtime, D3DX9, platform wrappers).
"results" says which commit the exact and verified tiers are from (harbor.results_refresh keeps them current).
Without a stats_repo the categories come from names.csv / facts and readable from the src headers (the queue's view).
Units, first that applies: the source file from the Xbox build's assert sites ([reverse] source_files); the D3DX9 block
(platform/d3dx9) or the import-thunk stubs from library_ranges; the unit facts gives (engine/<class>,
platform/<library>); otherwise
<category> plus its 64 KB address block, labelled "(no source file)"."""
import csv
import hashlib
import json
import subprocess
import threading
import time
from pathlib import Path
from .queue import FunctionQueue
STATUSES = ("exact", "verified", "readable", "deferred", "raw", "unsorted", "engine", "library")
DECOMPILED = ("decompiled", "reimplemented", "verified")
EXCLUDED = ("superseded", "wontfix")
UNIT_FIELDS = ("name", "kind", "lo", "hi", "bytes", "counted", "exact", "verified", "readable", "functions")
FUNCTION_FIELDS = ("unit", "address", "size", "status", "name")
KEY_TTL = 15 # seconds a computed input key is trusted before the files and HEAD are looked at again
def _int(text):
return int(str(text), 16) if str(text).lower().startswith("0x") else int(text)
def _mtime(path):
try:
return Path(path).stat().st_mtime_ns if path else None
except OSError:
return None
def _git(repo, *args):
try:
out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=20,
encoding="utf-8", errors="replace")
except (OSError, subprocess.TimeoutExpired):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _utc(iso):
import datetime
try:
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except ValueError:
return None
class ProgressMap:
def __init__(self, project, queue_factory):
self.project = project
self.queue_factory = queue_factory # -> FunctionQueue on a connection this thread may use
self.lock = threading.Lock()
self.value = None # (key, document)
self.checked = (0.0, None) # (monotonic time, key)
self.computations = 0
# ------------------------------------------------------------------ inputs
def _config(self):
return self.project.reverse or {}, getattr(self.project, "logbook", None) or {}
def _symbols_path(self):
_, lb = self._config()
return Path(lb["stats_repo"]) / "re" / "symbols" / "pc102.csv" if lb.get("stats_repo") else None
def _decomp(self):
rev, lb = self._config()
if lb.get("decomp"):
return Path(lb["decomp"])
return Path(rev["annotations"]).parent if rev.get("annotations") else None
def _logbook_dir(self):
from . import logbook
return logbook.folder(self.project)
def key(self, q):
rev, _ = self._config()
decomp = self._decomp()
head = _git(decomp, "rev-parse", "HEAD") if decomp and (decomp / ".git").exists() else ""
paths = [self._symbols_path(), self._logbook_dir() / "stats.json", self._logbook_dir() / "history.json",
self._logbook_dir() / "results.json"]
paths += [rev.get(k) for k in ("facts", "match_results", "diffrun_results", "library_results",
"library_ranges", "source_files")]
if rev.get("annotations"):
paths += [Path(rev["annotations"]) / "names.csv", Path(rev["annotations"]) / "globals.csv"]
try:
note = q.h.conn.execute("SELECT MAX(id) FROM notes").fetchone()[0]
except Exception:
note = None
raw = json.dumps([head, note, [str(p) for p in paths], [_mtime(p) for p in paths]])
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], head
# ------------------------------------------------------------------ answer
def get(self, have=None):
with self.lock:
q = self.queue_factory()
now = time.monotonic()
if self.value and now - self.checked[0] < KEY_TTL:
key = self.checked[1]
else:
key, head = self.key(q)
self.checked = (now, key)
if not self.value or self.value[0] != key:
document = self.compute(q, head)
if document is None and self.value: # the symbol table is being rewritten: keep the last
document = self.value[1]
elif document is None:
document = self.compute(q, head, use_symbols=False)
document["version"] = key
self.value = (key, document)
self.computations += 1
if have and have == self.value[0]:
return {"version": have, "same": True}
return self.value[1]
# ------------------------------------------------------------------ the computation
def _symbols(self, expected):
"""pc102.csv as {address: (category, status, name)}, or None while it is missing or half written."""
path = self._symbols_path()
if not path or not path.exists():
return None
try:
with path.open(encoding="utf-8", newline="") as stream:
rows = {_int(r["address"]): (r["category"], r["status"], r.get("name") or "")
for r in csv.DictReader(stream)}
except (OSError, ValueError, KeyError):
return None
return rows if len(rows) >= 0.98 * expected else None
def _source_files(self, path):
out = {}
if path and Path(path).exists():
with open(path, encoding="utf-8", newline="") as stream:
for row in csv.DictReader(stream):
if row.get("source_file"):
out[_int(row["pc_address"])] = row["source_file"]
return out
def _all_ranges(self, path):
"""[(lo, hi, label)] of the library_ranges csv (the D3DX9 block and the compiler's stubs)."""
out = []
if path and Path(path).exists():
with open(path, encoding="utf-8", newline="") as stream:
for r in csv.DictReader(stream):
verdict = r.get("verdict", "")
label = "platform/d3dx9" if verdict.startswith("D3DX") else \
"Import thunks and unwind funclets" if "stubs" in verdict else None
if label:
out.append((_int(r["start"]), _int(r["end"]), label))
return out
def _data(self, facts, rev):
"""Named globals against the distinct .data/.rdata addresses the code touches (needs sections.json)."""
facts_path = Path(rev["facts"]) if rev.get("facts") else None
sections_path = facts_path.parent / "sections.json" if facts_path else None
globals_path = Path(rev["annotations"]) / "globals.csv" if rev.get("annotations") else None
if not sections_path or not sections_path.exists() or not globals_path or not globals_path.exists():
return None
spans = [(s["start"], s["end"]) for s in json.loads(sections_path.read_text(encoding="utf-8"))
if s.get("name") in (".data", ".rdata")]
touched = set()
for d in facts.values():
for ref in d.get("data", []):
a = _int(ref)
if any(lo <= a < hi for lo, hi in spans):
touched.add(a)
with globals_path.open(encoding="utf-8", newline="") as stream:
named = {_int(r["address"]) for r in csv.DictReader(line for line in stream if not line.startswith("#"))
if r.get("address") and r.get("name")}
if not touched:
return None
return {"addresses": len(touched), "named": len(touched & named), "globals": len(named),
"bytes": sum(hi - lo for lo, hi in spans)}
def compute(self, q, head, use_symbols=True):
rev, _ = self._config()
facts = q.r._facts()
symbols = self._symbols(len(facts)) if use_symbols else None
if use_symbols and self._symbols_path() and symbols is None and self._symbols_path().exists():
return None
names = q.r._csv("names.csv")
statuses = None if symbols else q.statuses()
exact = {a for a, row in q._results("match_results").items()
if str(row.get("status")) in ("match", "exact", "exact-relaxed")}
verified = {a for a, row in q._results("diffrun_results").items()
if str(row.get("verdict")) == "same" and not row.get("weak")}
library = set(q._results("library_results"))
ranges = self._all_ranges(rev.get("library_ranges"))
lib_ranges = [(lo, hi) for lo, hi, _ in ranges]
deferred = q.deferrals()
sources = self._source_files(rev.get("source_files"))
units, unit_index, functions = [], {}, []
game_functions = game_done = 0
for addr in sorted(facts):
d = facts[addr]
size = int(d.get("size") or 0)
if symbols and addr in symbols:
category, status, sym_name = symbols[addr]
else:
category = q._category(addr)
status = "decompiled" if (statuses or {}).get(addr, ("raw",))[0] == "readable" else "classified"
sym_name = ""
row = names.get(addr)
name = (row[1] if row and len(row) > 1 and row[1] else "") or sym_name or d.get("name") or f"FUN_{addr:06X}"
counted = category == "game" and status not in EXCLUDED
done = status in DECOMPILED
is_lib = q.is_library(addr, library, lib_ranges) or category in ("library", "platform", "crt")
if counted or (done and not is_lib) or (category == "unsorted" and not is_lib):
tier = "exact" if done and addr in exact else "verified" if done and addr in verified else \
"readable" if done else "deferred" if addr in deferred else \
"raw" if counted else "unsorted"
elif is_lib:
tier = "library"
else:
tier = "engine"
source = sources.get(addr)
in_range = next((label for lo, hi, label in ranges if lo <= addr < hi), None)
if source:
unit = source
elif in_range:
unit = in_range
elif d.get("unit"):
unit = d["unit"]
else:
unit = f"{category} · 0x{addr & ~0xFFFF:06X} block (no source file)"
if unit not in unit_index:
unit_index[unit] = len(units)
units.append({"name": unit, "lo": addr, "hi": addr + size, "bytes": 0, "counted": 0, "exact": 0,
"verified": 0, "readable": 0, "functions": 0, "kinds": {}})
u = units[unit_index[unit]]
u["hi"] = max(u["hi"], addr + size)
u["bytes"] += size
u["functions"] += 1
kind = "game" if counted else "library" if tier == "library" else "engine" if category == "engine" \
else "unsorted" if category == "unsorted" else "library"
u["kinds"][kind] = u["kinds"].get(kind, 0) + size
if counted:
game_functions += 1
game_done += done
u["counted"] += size
if tier in ("exact", "verified", "readable"):
u[tier] += size
functions.append([unit_index[unit], addr, size, STATUSES.index(tier), name])
for u in units:
kinds = u.pop("kinds")
u["kind"] = max(kinds, key=kinds.get) if kinds else "unsorted" # what most of its bytes are
code = {k: sum(u[k] for u in units) for k in ("exact", "verified", "readable")}
code["bytes"] = sum(u["counted"] for u in units)
code["functions"], code["decompiled_functions"] = game_functions, game_done
percent = round(100 * (code["exact"] + code["verified"] + code["readable"]) / code["bytes"], 2) \
if code["bytes"] else 0.0
from . import logbook
stats = logbook._read(self.project, "stats.json", None) or {}
history = sorted(logbook._read(self.project, "history.json", []), key=lambda p: p.get("at", ""))
decomp = self._decomp()
commit = {"sha": stats.get("commit"), "at": None}
if commit["sha"] and decomp:
commit["at"] = _utc(_git(decomp, "log", "-1", "--format=%cI", commit["sha"])) or None
main = {"sha": head[:7] if head else None,
"at": _utc(_git(decomp, "log", "-1", "--format=%cI", head)) if head else None}
from . import results_refresh
return {
"project": self.project.title or self.project.name,
"results": results_refresh.freshness(self.project),
"source": "logbook symbol table" if symbols else "src headers and names.csv",
"commit": commit, "main": main, "updated_at": stats.get("updated_at"),
"percent": percent, "logbook_percent": stats.get("game_bytes_decompiled_percent"),
"code": code, "data": self._data(facts, rev),
"history": [[p.get("at"), p.get("decompiled"), p.get("commit"), p.get("game_bytes")]
for p in history if p.get("decompiled") is not None],
"statuses": list(STATUSES),
"unit_fields": list(UNIT_FIELDS),
"units": [[u[k] for k in UNIT_FIELDS] for u in units],
"function_fields": list(FUNCTION_FIELDS),
"functions": functions,
}
_MAPS = {}
_LOCK = threading.Lock()
def map_for(project):
with _LOCK:
if project.name not in _MAPS:
from . import decomp_api
api = decomp_api.api_for(project)
_MAPS[project.name] = ProgressMap(project, lambda: FunctionQueue(api.h))
return _MAPS[project.name]
def handle(project, query):
if not (project.reverse or {}).get("facts"):
return 404, {"error": "this project has no [reverse] table"}
return 200, map_for(project).get(query.get("have"))
"""The Logbook's progress map, in the style of decomp.dev: one unit per original source file, its functions and how
far each has come, plus the headline share of game code decompiled and the Code and Data bars.
GET /api/logbook/progress-map answers with one compact JSON document. It is computed once and kept until one of its
inputs changes (main's HEAD, the Logbook's symbol table and statistics, the match, emulator and library results,
facts, names, source files, globals, or a deferral note); `?have=<version>` answers {"version", "same": true} when
the caller already holds the current one, so a page that polls does not download or re-lay-out anything.
Definitions follow the Logbook exactly (open-seas tools/progress/progress.py, which writes stats.json):
- the symbol table is <[logbook] stats_repo>/re/symbols/pc102.csv: its category and status per function;
- counted ("game code") = category game and status not superseded or wontfix;
- decompiled = status decompiled, reimplemented or verified, so percent = decompiled game bytes / game bytes,
the Logbook's game_bytes_decompiled_percent.
Decompiled functions are split further, best first: exact (byte match in the [reverse] match_results), verified
(behaves the same in the emulator, diffrun_results, not 'weak'), readable. The rest: deferred (a live DEFERRED note),
raw, and, not counted, unsorted, engine (Gamebryo, named only) and library (C runtime, D3DX9, platform wrappers).
"results" says which commit the exact and verified tiers are from (harbor.results_refresh keeps them current).
Without a stats_repo the categories come from names.csv / facts and readable from the src headers (the queue's view).
Units, first that applies: the source file from the Xbox build's assert sites ([reverse] source_files); the D3DX9 block
(platform/d3dx9) or the import-thunk stubs from library_ranges; the unit facts gives (engine/<class>,
platform/<library>); otherwise
<category> plus its 64 KB address block, labelled "(no source file)"."""
import csv
import hashlib
import json
import subprocess
import threading
import time
from pathlib import Path
from .queue import FunctionQueue
STATUSES = ("exact", "verified", "readable", "deferred", "raw", "unsorted", "engine", "library")
DECOMPILED = ("decompiled", "reimplemented", "verified")
EXCLUDED = ("superseded", "wontfix")
UNIT_FIELDS = ("name", "kind", "lo", "hi", "bytes", "counted", "exact", "verified", "readable", "functions")
FUNCTION_FIELDS = ("unit", "address", "size", "status", "name")
KEY_TTL = 15 # seconds a computed input key is trusted before the files and HEAD are looked at again
def _int(text):
return int(str(text), 16) if str(text).lower().startswith("0x") else int(text)
def _mtime(path):
try:
return Path(path).stat().st_mtime_ns if path else None
except OSError:
return None
def _git(repo, *args):
try:
out = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=20,
encoding="utf-8", errors="replace")
except (OSError, subprocess.TimeoutExpired):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
def _utc(iso):
import datetime
try:
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
except ValueError:
return None
class ProgressMap:
def __init__(self, project, queue_factory):
self.project = project
self.queue_factory = queue_factory # -> FunctionQueue on a connection this thread may use
self.lock = threading.Lock()
self.value = None # (key, document)
self.checked = (0.0, None) # (monotonic time, key)
self.computations = 0
# ------------------------------------------------------------------ inputs
def _config(self):
return self.project.reverse or {}, getattr(self.project, "logbook", None) or {}
def _symbols_path(self):
_, lb = self._config()
return Path(lb["stats_repo"]) / "re" / "symbols" / "pc102.csv" if lb.get("stats_repo") else None
def _decomp(self):
rev, lb = self._config()
if lb.get("decomp"):
return Path(lb["decomp"])
return Path(rev["annotations"]).parent if rev.get("annotations") else None
def _logbook_dir(self):
from . import logbook
return logbook.folder(self.project)
def key(self, q):
rev, _ = self._config()
decomp = self._decomp()
head = _git(decomp, "rev-parse", "HEAD") if decomp and (decomp / ".git").exists() else ""
paths = [self._symbols_path(), self._logbook_dir() / "stats.json", self._logbook_dir() / "history.json",
self._logbook_dir() / "results.json"]
paths += [rev.get(k) for k in ("facts", "match_results", "diffrun_results", "library_results",
"library_ranges", "source_files")]
if rev.get("annotations"):
paths += [Path(rev["annotations"]) / "names.csv", Path(rev["annotations"]) / "globals.csv"]
try:
note = q.h.conn.execute("SELECT MAX(id) FROM notes").fetchone()[0]
except Exception:
note = None
raw = json.dumps([head, note, [str(p) for p in paths], [_mtime(p) for p in paths]])
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], head
# ------------------------------------------------------------------ answer
def get(self, have=None):
with self.lock:
q = self.queue_factory()
now = time.monotonic()
if self.value and now - self.checked[0] < KEY_TTL:
key = self.checked[1]
else:
key, head = self.key(q)
self.checked = (now, key)
if not self.value or self.value[0] != key:
document = self.compute(q, head)
if document is None and self.value: # the symbol table is being rewritten: keep the last
document = self.value[1]
elif document is None:
document = self.compute(q, head, use_symbols=False)
document["version"] = key
self.value = (key, document)
self.computations += 1
if have and have == self.value[0]:
return {"version": have, "same": True}
return self.value[1]
# ------------------------------------------------------------------ the computation
def _symbols(self, expected):
"""pc102.csv as {address: (category, status, name)}, or None while it is missing or half written."""
path = self._symbols_path()
if not path or not path.exists():
return None
try:
with path.open(encoding="utf-8", newline="") as stream:
rows = {_int(r["address"]): (r["category"], r["status"], r.get("name") or "")
for r in csv.DictReader(stream)}
except (OSError, ValueError, KeyError):
return None
return rows if len(rows) >= 0.98 * expected else None
def _source_files(self, path):
out = {}
if path and Path(path).exists():
with open(path, encoding="utf-8", newline="") as stream:
for row in csv.DictReader(stream):
if row.get("source_file"):
out[_int(row["pc_address"])] = row["source_file"]
return out
def _all_ranges(self, path):
"""[(lo, hi, label)] of the library_ranges csv (the D3DX9 block and the compiler's stubs)."""
out = []
if path and Path(path).exists():
with open(path, encoding="utf-8", newline="") as stream:
for r in csv.DictReader(stream):
verdict = r.get("verdict", "")
label = "platform/d3dx9" if verdict.startswith("D3DX") else \
"Import thunks and unwind funclets" if "stubs" in verdict else None
if label:
out.append((_int(r["start"]), _int(r["end"]), label))
return out
def _data(self, facts, rev):
"""Named globals against the distinct .data/.rdata addresses the code touches (needs sections.json)."""
facts_path = Path(rev["facts"]) if rev.get("facts") else None
sections_path = facts_path.parent / "sections.json" if facts_path else None
globals_path = Path(rev["annotations"]) / "globals.csv" if rev.get("annotations") else None
if not sections_path or not sections_path.exists() or not globals_path or not globals_path.exists():
return None
spans = [(s["start"], s["end"]) for s in json.loads(sections_path.read_text(encoding="utf-8"))
if s.get("name") in (".data", ".rdata")]
touched = set()
for d in facts.values():
for ref in d.get("data", []):
a = _int(ref)
if any(lo <= a < hi for lo, hi in spans):
touched.add(a)
with globals_path.open(encoding="utf-8", newline="") as stream:
named = {_int(r["address"]) for r in csv.DictReader(line for line in stream if not line.startswith("#"))
if r.get("address") and r.get("name")}
if not touched:
return None
return {"addresses": len(touched), "named": len(touched & named), "globals": len(named),
"bytes": sum(hi - lo for lo, hi in spans)}
def compute(self, q, head, use_symbols=True):
rev, _ = self._config()
facts = q.r._facts()
symbols = self._symbols(len(facts)) if use_symbols else None
if use_symbols and self._symbols_path() and symbols is None and self._symbols_path().exists():
return None
names = q.r._csv("names.csv")
statuses = None if symbols else q.statuses()
exact = {a for a, row in q._results("match_results").items()
if str(row.get("status")) in ("match", "exact", "exact-relaxed")}
verified = {a for a, row in q._results("diffrun_results").items()
if str(row.get("verdict")) == "same" and not row.get("weak")}
library = set(q._results("library_results"))
ranges = self._all_ranges(rev.get("library_ranges"))
lib_ranges = [(lo, hi) for lo, hi, _ in ranges]
deferred = q.deferrals()
sources = self._source_files(rev.get("source_files"))
units, unit_index, functions = [], {}, []
game_functions = game_done = 0
tiers = {t: {"functions": 0, "bytes": 0, "game_functions": 0, "game_bytes": 0} for t in STATUSES}
for addr in sorted(facts):
d = facts[addr]
size = int(d.get("size") or 0)
if symbols and addr in symbols:
category, status, sym_name = symbols[addr]
else:
category = q._category(addr)
status = "decompiled" if (statuses or {}).get(addr, ("raw",))[0] == "readable" else "classified"
sym_name = ""
row = names.get(addr)
name = (row[1] if row and len(row) > 1 and row[1] else "") or sym_name or d.get("name") or f"FUN_{addr:06X}"
counted = category == "game" and status not in EXCLUDED
done = status in DECOMPILED
is_lib = q.is_library(addr, library, lib_ranges) or category in ("library", "platform", "crt")
if counted or (done and not is_lib) or (category == "unsorted" and not is_lib):
tier = "exact" if done and addr in exact else "verified" if done and addr in verified else \
"readable" if done else "deferred" if addr in deferred else \
"raw" if counted else "unsorted"
elif is_lib:
tier = "library"
else:
tier = "engine"
source = sources.get(addr)
in_range = next((label for lo, hi, label in ranges if lo <= addr < hi), None)
if source:
unit = source
elif in_range:
unit = in_range
elif d.get("unit"):
unit = d["unit"]
else:
unit = f"{category} · 0x{addr & ~0xFFFF:06X} block (no source file)"
if unit not in unit_index:
unit_index[unit] = len(units)
units.append({"name": unit, "lo": addr, "hi": addr + size, "bytes": 0, "counted": 0, "exact": 0,
"verified": 0, "readable": 0, "functions": 0, "kinds": {}})
u = units[unit_index[unit]]
u["hi"] = max(u["hi"], addr + size)
u["bytes"] += size
u["functions"] += 1
kind = "game" if counted else "library" if tier == "library" else "engine" if category == "engine" \
else "unsorted" if category == "unsorted" else "library"
u["kinds"][kind] = u["kinds"].get(kind, 0) + size
tiers[tier]["functions"] += 1
tiers[tier]["bytes"] += size
if counted: # the Code bar's game functions, split by tier
tiers[tier]["game_functions"] += 1
tiers[tier]["game_bytes"] += size
if counted:
game_functions += 1
game_done += done
u["counted"] += size
if tier in ("exact", "verified", "readable"):
u[tier] += size
functions.append([unit_index[unit], addr, size, STATUSES.index(tier), name])
for u in units:
kinds = u.pop("kinds")
u["kind"] = max(kinds, key=kinds.get) if kinds else "unsorted" # what most of its bytes are
code = {k: sum(u[k] for u in units) for k in ("exact", "verified", "readable")}
code["bytes"] = sum(u["counted"] for u in units)
code["functions"], code["decompiled_functions"] = game_functions, game_done
percent = round(100 * (code["exact"] + code["verified"] + code["readable"]) / code["bytes"], 2) \
if code["bytes"] else 0.0
from . import logbook
stats = logbook._read(self.project, "stats.json", None) or {}
history = sorted(logbook._read(self.project, "history.json", []), key=lambda p: p.get("at", ""))
decomp = self._decomp()
commit = {"sha": stats.get("commit"), "at": None}
if commit["sha"] and decomp:
commit["at"] = _utc(_git(decomp, "log", "-1", "--format=%cI", commit["sha"])) or None
main = {"sha": head[:7] if head else None,
"at": _utc(_git(decomp, "log", "-1", "--format=%cI", head)) if head else None}
from . import results_refresh
return {
"project": self.project.title or self.project.name,
"results": results_refresh.freshness(self.project),
"source": "logbook symbol table" if symbols else "src headers and names.csv",
"commit": commit, "main": main, "updated_at": stats.get("updated_at"),
"percent": percent, "logbook_percent": stats.get("game_bytes_decompiled_percent"),
"code": code, "data": self._data(facts, rev), "tiers": tiers,
# every function in the executable (game, engine, platform, CRT), so engine work shows too
"all_code": {"bytes": stats.get("bytes"), "functions": stats.get("functions"),
"decompiled_bytes": ((stats.get("by_status") or {}).get("decompiled") or {}).get("bytes"),
"decompiled_functions": ((stats.get("by_status") or {}).get("decompiled") or {}).get("functions")},
"history": [[p.get("at"), p.get("decompiled"), p.get("commit"), p.get("game_bytes")]
for p in history if p.get("decompiled") is not None],
"statuses": list(STATUSES),
"unit_fields": list(UNIT_FIELDS),
"units": [[u[k] for k in UNIT_FIELDS] for u in units],
"function_fields": list(FUNCTION_FIELDS),
"functions": functions,
}
_MAPS = {}
_LOCK = threading.Lock()
def map_for(project):
with _LOCK:
if project.name not in _MAPS:
from . import decomp_api
api = decomp_api.api_for(project)
_MAPS[project.name] = ProgressMap(project, lambda: FunctionQueue(api.h))
return _MAPS[project.name]
def handle(project, query):
if not (project.reverse or {}).get("facts"):
return 404, {"error": "this project has no [reverse] table"}
return 200, map_for(project).get(query.get("have"))
+8
View File
@@ -196,6 +196,14 @@ def _policy(raw, names):
policy["all_per_hour"] = int(policy["all_per_hour"])
except (TypeError, ValueError):
raise ProjectError("policy all_per_hour must be a whole number")
if "submits_per_hour" in policy: # {agent: n}: submissions and resubmissions per hour
caps = policy["submits_per_hour"]
if not isinstance(caps, dict) or not all(k in names for k in caps):
raise ProjectError("policy submits_per_hour must map agents on the roster to whole numbers")
try:
policy["submits_per_hour"] = {k: int(v) for k, v in caps.items()}
except (TypeError, ValueError):
raise ProjectError("policy submits_per_hour must map agents on the roster to whole numbers")
return policy
+4
View File
@@ -207,6 +207,10 @@ class FunctionQueue:
head = self.r.read(addr, 10) if int(facts[addr].get("size") or 0) == 10 else b""
if head[:1] and 0xB8 <= head[0] <= 0xBF and head[5:6] == b"\xe9":
continue # mov reg, imm32; jmp: an at-exit destructor thunk or an SEH handler stub (compiler's)
first = self.r.read(addr, 3)
if len(first) == 3 and first[0] in (0x8B, 0x8D, 0xFF, 0x83, 0xC7, 0x89) and (first[1] & 0xC7) == 0x45 \
and first[2] >= 0x80:
continue # opens on the parent's [ebp-x] with no frame: a catch or unwind funclet (ruling #12777)
score, reasons = self.score(addr, facts, named, rejected, similar_readable, with_convention=False)
ranked.append((score, addr, reasons))
ranked.sort(reverse=True)
+14 -12
View File
@@ -13,6 +13,7 @@ Configured per project in the [logbook] table (main is [logbook] decomp):
behaviour_every_hours = 12
behaviour_window = "01-06" from 01:00 to 06:00 local time; "" for any hour
No run starts while the lander runs its gates ([logbook] lander_lock, harbor.lander_gate).
One run at a time (the server runs one thread for every project), at the lowest process priority, into a .partial file
that replaces the results only once it is complete. The match run holds the same per-checkout lock as re_match_program,
so it waits its turn behind any other whole-program build of main.
@@ -31,7 +32,7 @@ import time
import traceback
from pathlib import Path
from . import logbook
from . import lander_gate, logbook
JOBS = ("match", "behaviour")
RESULTS_KEY = {"match": "match_results", "behaviour": "diffrun_results"}
@@ -180,14 +181,7 @@ def _spawn(argv, cwd, stdout, stderr):
def _kill_tree(pid):
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
else:
try:
os.kill(pid, 9)
except OSError:
pass
lander_gate.kill_tree(pid)
def _alive(pid):
@@ -205,12 +199,12 @@ def _paths(project, job):
return paths
def _argv(project, job, out):
def _argv(project, job, out, decomp=None):
need = configured(project, job)
if job == "match":
script = Path(need["script"])
return [sys.executable, "-B", str(script), "check", "--build", "--decomp",
str(Path(project.logbook["decomp"])), "--json", str(out)], script.parent
str(Path(decomp or project.logbook["decomp"])), "--json", str(out)], script.parent
command = [str(c) for c in need["command"]]
return command + ["--readable", "--jobs", "4", "--json", str(out)], Path(command[-1]).parent
@@ -260,7 +254,12 @@ def _complete(project, state, run, now, returncode=None):
def run_job(project, state, job, head, now, reason=""):
paths = _paths(project, job)
argv, cwd = _argv(project, job, paths["partial"])
decomp = None
if job == "match" and project.logbook.get("results_snapshot") and head:
# a clean checkout of exactly `head`: the live checkout is the lander's and changes under a running build
from .logbook_auto import snapshot_at
decomp = snapshot_at({"snapshot": project.logbook["results_snapshot"]}, project.logbook["decomp"], head)
argv, cwd = _argv(project, job, paths["partial"], decomp)
for stale in [paths["partial"]] + ([paths["text_partial"]] if job == "behaviour" else []):
if stale.exists():
stale.write_bytes(b"") # a leftover of an interrupted run is never taken for this one
@@ -310,6 +309,9 @@ def tick(project, now=None, hour=None):
job, why = choose(settings(project), state, head, now, hour, requested, available)
if not job:
return {"idle": why, "head": head[:8]}
busy = lander_gate.lander_busy(project, now)
if busy: # a request is kept for the next look
return {"waiting": busy, "job": job}
if job == "match":
from .util import HarborError
try:
+230
View File
@@ -7,6 +7,8 @@ Configured per project in a [reverse] table:
imports = 'R:/.../imports.json' optional: [{module, name, slot}]
annotations = 'R:/.../annotations' names.csv, functions.csv, globals.csv, prototypes.h
src = 'R:/.../src' optional: the decompiled C files (<ADDR>_<Name>.c)
sections = 'R:/.../sections.json' optional: [{name, start, end}] (default: sections.json beside the image),
used to find the vtables and pointer tables a function sits in
Every answer comes from those files, read fresh when they change, so the agents see the team's current names.
Nothing here writes anything."""
@@ -42,6 +44,7 @@ class Reverse:
self.conventions_path = Path(config["conventions"]) if config.get("conventions") else None
self.source_files = Path(config["source_files"]) if config.get("source_files") else None
self.mac_names = Path(config["mac_names"]) if config.get("mac_names") else None
self.sections_path = Path(config["sections"]) if config.get("sections") else None
self._cache = {}
# ----------------------------------------------------------------- loading (cached by file mtime)
@@ -491,6 +494,176 @@ class Reverse:
break
return out
# ----------------------------------------------------------------- one-call summary
def _src_index(self):
"""address -> path of src/**/<ADDR>_<Name>.c (one directory walk, reused for 30 seconds)."""
import time
hit = self._cache.get("src_index")
if hit and time.monotonic() - hit[0] < 30:
return hit[1]
out = {}
if self.src and self.src.exists():
for folder, _, files in os.walk(self.src):
for name in files:
m = re.match(r"([0-9A-Fa-f]{6,8})_.*\.c$", name)
if m:
out.setdefault(int(m.group(1), 16), Path(folder) / name)
self._cache["src_index"] = (time.monotonic(), out)
return out
def status_of(self, addr):
"""The decompiled file's status on main ('readable', 'raw', ...), or None when there is no file."""
path = self._src_index().get(addr)
if not path:
return None
try:
with path.open(encoding="utf-8", errors="replace") as f:
head = [next(f, "") for _ in range(8)]
except OSError:
return None
return next((l.split("status:", 1)[1].strip() for l in head if "status:" in l), "raw")
def _sections(self):
"""[(name, start, end)] from sections.json beside the image (or [reverse] sections), else []."""
path = self.sections_path or self.image.with_name("sections.json")
return self._load("sections", path, lambda p: [
(s["name"], _int(s["start"]), _int(s["end"])) for s in json.loads(p.read_text(encoding="utf-8"))] if p else [])
def _code_data_refs(self):
facts = self._facts()
key = ("data_refs", id(facts))
if key not in self._cache:
self._cache[key] = {_int(a) for d in facts.values() for a in d.get("data", [])}
return self._cache[key]
def _mac_vtables(self):
return self._load("mac_vtables", self.mac_names.with_name("vtables.csv") if self.mac_names else None,
lambda p: {int(r["pc_vtable"], 16): r.get("class") for r in
csv.DictReader(p.read_text(encoding="utf-8").splitlines())
if (r.get("pc_vtable") or "").startswith("0x")} if p else {})
def vtable_slots(self, addr):
"""Where the function's address sits in a table of code pointers outside .text: the table start (walked back
over function addresses to the entry the code references), the slot index, and what the table is."""
data = self._bytes()
facts = self._facts()
sections = self._sections()
text = next(((lo, hi) for name, lo, hi in sections if name == ".text"), None)
tables = [(lo, hi) for name, lo, hi in sections if name in (".rdata", ".data")]
if not tables: # no section list: everything past the last function
end = max((a + int(d.get("size") or 0) for a, d in facts.items()), default=self.base)
tables = [(end, self.base + len(data))]
refs, mac = self._code_data_refs(), self._mac_vtables()
is_code = (lambda v: v in facts) if facts else (lambda v: text and text[0] <= v < text[1])
needle = struct.pack("<I", addr)
out = []
for lo, hi in tables:
pos = data.find(needle, max(0, lo - self.base), hi - self.base)
while pos >= 0 and len(out) < 40:
if pos % 4 == 0:
start = pos
while start - 4 >= lo - self.base and (start + self.base) not in refs and \
(start + self.base) not in mac and \
is_code(struct.unpack_from("<I", data, start - 4)[0]) and pos - start < 4 * 400:
start -= 4
table = start + self.base
slot = (pos - start) // 4
name = self.name_of(table)
kind = "vtable" if table in refs or table in mac or (name and "vtable" in name.lower()) \
else "pointer table"
out.append({"table": f"0x{table:X}", "slot": slot, "offset": f"0x{slot * 4:X}", "kind": kind,
**({"table_name": name} if name else {}),
**({"mac_class": mac[table]} if table in mac else {})})
pos = data.find(needle, pos + 1, hi - self.base)
return out
def summary(self, target):
"""Everything a worker needs before starting on a function, in one call: name, prototype, size, convention,
whether it is readable on main, callers and callees (named, with their status on main), globals and strings,
the vtable slots it fills, and the Mac/Xbox name hints."""
addr = self.resolve(target)
func = self.function_at(addr)
if not func:
raise HarborError(f"0x{addr:X} is not inside a known function")
start, size, name = func
d = self._facts()[start]
def fn(a):
status = self.status_of(a)
return {"address": f"0x{a:X}", "name": self.name_of(a), "status": status or "no file",
"readable": status == "readable"}
out = {"address": f"0x{start:X}", "name": name, "size": size, "end": f"0x{start + size:X}"}
if addr != start:
out["asked"] = f"0x{addr:X} (+0x{addr - start:X})"
proto = self._prototypes().get(name) if name else None
out["prototype"] = proto
conv = self.convention(start)
out["convention"] = conv["summary"] if conv else None
if conv:
out["registers_in"], out["ret_pops"] = conv["registers_in"], conv["ret_pops"]
status = self.status_of(start)
out["status"] = status or "no file"
out["readable"] = status == "readable"
path = self._src_index().get(start)
if path and self.src:
out["file"] = str(path.relative_to(self.src.parent)).replace(os.sep, "/")
row = self._csv("names.csv").get(start)
if row and len(row) > 3 and row[3]:
out["note"] = row[3] if len(row[3]) < 600 else row[3][:600] + "..."
callers = sorted(set(self._callers().get(start, [])))
callees = list(dict.fromkeys(_int(a) for a in d.get("calls", [])))
out["callers"] = [fn(a) for a in callers]
out["callees"] = [fn(a) for a in callees]
if d.get("imports"):
out["imports"] = d["imports"]
glob = self._csv("globals.csv")
out["globals"] = [{"address": f"0x{a:X}", "name": self.name_of(a),
**({"type": glob[a][2]} if a in glob and len(glob[a]) > 2 and glob[a][2] else {})}
for a in dict.fromkeys(_int(x) for x in d.get("data", []))]
out["strings"] = d.get("strings", [])
out["vtable_slots"] = self.vtable_slots(start)
out["hints"] = self.origins(start)
out["counts"] = {"callers": len(callers), "callers_readable": sum(c["readable"] for c in out["callers"]),
"callees": len(callees), "callees_readable": sum(c["readable"] for c in out["callees"])}
return out
def format_summary(s):
"""Reverse.summary() as text for the command line."""
mark = lambda f: f"{f['address']} {f['name'] or '(unnamed)'} [{'readable' if f['readable'] else f['status']}]"
lines = [f"{s['name'] or '(unnamed)'} {s['address']}..{s['end']} ({s['size']} bytes)"
+ (f", asked {s['asked']}" if s.get("asked") else ""),
f"status on main: {s['status']}" + (f" ({s['file']})" if s.get("file") else ""),
f"convention: {s['convention'] or 'unknown'}",
"prototype: " + (s["prototype"] or "none in prototypes.h")]
c = s["counts"]
lines.append(f"callers ({c['callers']}, {c['callers_readable']} readable):")
lines += [f" {mark(f)}" for f in s["callers"]] or [" none"]
lines.append(f"callees ({c['callees']}, {c['callees_readable']} readable):")
lines += [f" {mark(f)}" for f in s["callees"]] or [" none"]
if s.get("imports"):
lines.append("imports: " + ", ".join(s["imports"]))
lines.append(f"globals ({len(s['globals'])}):")
lines += [f" {g['address']} {g['name'] or '(unnamed)'}" + (f" : {g['type']}" if g.get("type") else "")
for g in s["globals"]] or [" none"]
if s["strings"]:
lines.append("strings: " + ", ".join(repr(x) for x in s["strings"]))
lines.append("vtable slots:" + ("" if s["vtable_slots"] else " none"))
lines += [f" {v['kind']} {v['table']}" + (f" {v['table_name']}" if v.get("table_name") else "")
+ (f" (Mac class {v['mac_class']})" if v.get("mac_class") else "") + f" slot {v['slot']} (+{v['offset']})"
for v in s["vtable_slots"]]
h = s["hints"]
if h.get("mac_name"):
lines.append(f"Mac name: {h['mac_name'].get('demangled')} ({h['mac_name'].get('confidence')})")
if h.get("source_file"):
sf = h["source_file"]
lines.append(f"Xbox source file: {sf.get('source_file')}" + (f" lines {sf['lines']}" if sf.get("lines") else "")
+ f" ({sf.get('confidence')})")
if not h:
lines.append("Mac/Xbox hints: none")
return "\n".join(lines)
class Ghidra:
"""A small client for the shared, read-only Ghidra MCP server (GhidrAssistMCP over streamable HTTP), so every
@@ -572,6 +745,63 @@ class Ghidra:
return re.sub(r"^\[Context\] Operating on: [^\n]*\n+", "", text)
def _hexdump_bytes(text):
"""The bytes of a GhidrAssistMCP get_data_at hexdump ('401000 53 55 8b ... |SU.|')."""
out = bytearray()
for line in text.splitlines():
m = re.match(r"^\s*[0-9A-Fa-f]{4,16}\s{2}((?:[0-9A-Fa-f]{2}\s{1,2})+)", line + " ")
if m:
out += bytes.fromhex("".join(m.group(1).split()))
return bytes(out)
def ghidra_check(config, samples=16, sample_len=128):
"""Is the shared Ghidra server serving our program? Read-only: compares the program's address range with the
project image (base and size), the SHA-256 of the file Ghidra imported (when that path is readable here) with
the image's, and sample bytes read through Ghidra (get_data_at) with the image at spread-out addresses."""
import hashlib
reverse = Reverse(config)
image = reverse._bytes()
if not image:
raise HarborError(f"the project image {reverse.image} is missing")
base = reverse.base
g = Ghidra(config)
info = g.call("get_binary_info", {})
binaries = g.call("list_binaries", {})
field = lambda label, text: (re.search(rf"^\s*{label}:\s*(.+?)\s*$", text, re.M) or [None, None])[1]
lo, hi = field("Min Address", info), field("Max Address", info)
checks = []
want_lo, want_hi = base, base + len(image) - 1
got = (int(lo, 16), int(hi, 16)) if lo and hi else None
checks.append({"check": "address range", "ok": got == (want_lo, want_hi),
"expected": f"0x{want_lo:X}..0x{want_hi:X} ({len(image)} bytes)",
"ghidra": f"0x{got[0]:X}..0x{got[1]:X} ({got[1] - got[0] + 1} bytes)" if got else info.strip()[:200]})
sha = hashlib.sha256(image).hexdigest()
exe = field("Executable Path", binaries)
local = Path(exe[1:] if exe and re.match(r"^/[A-Za-z]:/", exe) else exe) if exe else None
if local and local.is_file():
theirs = hashlib.sha256(local.read_bytes()).hexdigest()
checks.append({"check": "sha256 of the imported file", "ok": theirs == sha, "expected": sha,
"ghidra": theirs, "file": str(local)})
else:
checks.append({"check": "sha256 of the imported file", "ok": None, "expected": sha,
"ghidra": f"not readable here ({exe or 'no path reported'})"})
step = max(sample_len, (len(image) - sample_len) // max(1, samples - 1)) & ~0xF
bad = []
for i in range(samples):
addr = base + min(i * step, len(image) - sample_len)
theirs = _hexdump_bytes(g.call("get_data_at", {"address": f"0x{addr:X}", "len": sample_len}))
ours = image[addr - base:addr - base + sample_len]
if theirs != ours:
bad.append(f"0x{addr:X}")
checks.append({"check": f"{samples} byte samples of {sample_len}", "ok": not bad,
**({"differ_at": bad} if bad else {})})
ok = all(c["ok"] is not False for c in checks)
return {"ok": ok, "program": field("Name", info), "image": str(reverse.image), "image_sha256": sha,
"image_base": f"0x{base:X}", "image_size": len(image), "functions_in_ghidra": field("Function Count", info),
"checks": checks}
class Debugger:
"""A per-agent x64dbg session through the x64dbg-automate MCP server (stdio), started on first use and kept for
the life of this Harbor process, so breakpoints and the paused game survive between calls. Configured by
+1647 -1639
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -58,6 +58,54 @@ class LogbookAuto(HarborCase):
self.assertTrue(entry["at"].endswith("Z"))
self.assertFalse(logbook_auto.refresh(self.h.project)["changed"])
def test_waits_for_the_lander_and_a_failed_stats_run_logs_nothing_twice(self):
import os
import time
from unittest import mock
logbook_auto._save_state(self.h.project, {"last_head": self.first})
lock = self.dir / "lander-busy.lock"
lock.write_text("x", encoding="utf-8")
self.h.project.logbook.update(lander_lock=str(lock), stats_repo=str(self.dir / "stats"))
out = logbook_auto.refresh(self.h.project)
self.assertFalse(out["changed"])
self.assertIn("lander", out["waiting"])
self.assertEqual(logbook.read_all(self.h.project)["log"], [])
old = time.time() - 61 * 60
os.utime(lock, (old, old)) # stale: ignored
with mock.patch.object(logbook_auto, "rebuild_stats", side_effect=RuntimeError("timed out")):
out = logbook_auto.refresh(self.h.project)
self.assertEqual((out["entries"], out["stats_error"]), (1, "timed out"))
again = logbook_auto.refresh(self.h.project) # the statistics are retried ...
self.assertIn("stats_error", again)
self.assertNotIn("entries", again) # ... but the commit is not relogged
self.assertEqual(len(logbook.read_all(self.h.project)["log"]), 1)
class LanderGate(unittest.TestCase):
def test_run_low_lowers_priority_and_ends_the_tree_on_timeout(self):
import os
import sys
import time
from harbor import lander_gate
script = "import subprocess, sys, time; subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']);" \
" print('ok', flush=True); time.sleep(60)"
from unittest import mock
seen = {}
real = subprocess.Popen
def popen(argv, **kw):
seen.update(kw)
return real([sys.executable, "-c", "pass"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
with mock.patch.object(lander_gate.subprocess, "Popen", popen):
self.assertEqual(lander_gate.run_low(["x"], 60).returncode, 0)
if os.name == "nt":
self.assertTrue(seen["creationflags"] & subprocess.BELOW_NORMAL_PRIORITY_CLASS)
start = time.monotonic()
with self.assertRaises(subprocess.TimeoutExpired):
lander_gate.run_low([sys.executable, "-c", script], 3)
self.assertLess(time.monotonic() - start, 45) # the grandchild did not hold the pipes
class ProgressMapTests(HarborCase):
"""GET /api/logbook/progress-map: units, tiers and the headline, by the Logbook's own definitions."""
+13
View File
@@ -208,6 +208,19 @@ class Refresher(HarborCase):
rr.save_state(self.h.project, st)
self.assertIsNone(rr.freshness(self.h.project)["match"]["sha"])
def test_waits_while_the_lander_runs_its_gates(self):
lock = self.dir / "lander-busy.lock"
self.h.project.logbook["lander_lock"] = str(lock)
lock.write_text("lander", encoding="utf-8")
os.utime(lock, (T0 - 300, T0 - 300))
rr.request(self.h.project, "match")
out = rr.tick(self.h.project, now=T0, hour=12)
self.assertIn("lander", out["waiting"])
self.assertEqual(self.calls, [])
self.assertEqual(rr._requested(self.h.project), {"match"}) # kept for the next look
self.assertTrue(rr.tick(self.h.project, now=T0 + 61 * 60, hour=12)["ok"]) # a lock over an hour old is stale
self.assertEqual(len(self.calls), 1)
def test_runs_at_idle_priority(self):
seen = {}
+99
View File
@@ -58,6 +58,105 @@ class ReverseTools(unittest.TestCase):
with self.assertRaises(HarborError):
Reverse({})
def test_summary_names_status_vtables_and_hints(self):
image = bytearray((self.d / "image.bin").read_bytes())
image[0xC0:0xC8] = struct.pack("<II", 0x401000, 0x401040) # 0x4010C0: a two-slot vtable
(self.d / "image.bin").write_bytes(bytes(image))
(self.d / "sections.json").write_text(json.dumps([
{"name": ".text", "start": 0x401000, "end": 0x401080}, {"name": ".rdata", "start": 0x4010B0,
"end": 0x401100}]), encoding="utf-8")
facts = [json.loads(l) for l in (self.d / "facts.jsonl").read_text(encoding="utf-8").splitlines()]
facts[1]["data"] = ["0x4010C0"] # the constructor installs it
facts[0]["strings"] = [".cue"]
(self.d / "facts.jsonl").write_text("\n".join(json.dumps(f) for f in facts), encoding="utf-8")
src = self.d / "src" / "game"
src.mkdir(parents=True)
(src / "401040_Helper.c").write_text("/* Helper\n * status: readable\n */\nvoid Helper(void) {}\n",
encoding="utf-8")
(self.d / "mac.csv").write_text("pc_address,demangled,confidence\n0x00401000,Level::Get(),high\n",
encoding="utf-8")
r = Reverse({"image": str(self.d / "image.bin"), "base": "0x401000", "facts": str(self.d / "facts.jsonl"),
"annotations": str(self.d / "annotations"), "src": str(self.d / "src"),
"mac_names": str(self.d / "mac.csv")})
s = r.summary("GetLevel")
self.assertEqual((s["name"], s["size"], s["status"], s["readable"]), ("GetLevel", 18, "no file", False))
self.assertIn("plain ret", s["prototype"])
self.assertEqual(s["callees"], [{"address": "0x401040", "name": "Helper", "status": "readable",
"readable": True}])
self.assertEqual(s["globals"], [{"address": "0x401080", "name": "g_root", "type": "int"}])
self.assertEqual(s["strings"], [".cue"])
self.assertEqual(s["vtable_slots"], [{"table": "0x4010C0", "slot": 0, "offset": "0x0", "kind": "vtable"}])
self.assertEqual(s["hints"]["mac_name"]["demangled"], "Level::Get()")
h = r.summary("0x401040")
self.assertTrue(h["readable"])
self.assertEqual([c["name"] for c in h["callers"]], ["GetLevel"])
self.assertEqual(h["vtable_slots"][0]["slot"], 1)
from harbor.reverse import format_summary
self.assertIn("vtable 0x4010C0 slot 1 (+0x4)", format_summary(h))
class GhidraCheck(unittest.TestCase):
"""ghidra_check compares the served program with the project image: range, SHA-256 of the file, byte samples."""
def serve(self, lo, hi, path, image):
import http.server
import threading
class Fake(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if "id" not in body:
self.send_response(202)
self.end_headers()
return
params = body.get("params", {})
name, args = params.get("name"), params.get("arguments", {})
text = ""
if name == "get_binary_info":
text = f"Program Information:\nName: image.bin\nImage Base: 00000000\nMin Address: {lo:08x}\n" \
f"Max Address: {hi:08x}\nFunction Count: 2\n"
elif name == "list_binaries":
text = f"Open Programs in Ghidra:\n\n1. image.bin [ACTIVE]\n Executable Path: /{path}\n"
elif name == "get_data_at":
a, n = int(args["address"], 16), int(args["len"])
chunk = image[a - 0x401000:a - 0x401000 + n]
rows = [f"{a + i:x} " + " ".join(f"{b:02x}" for b in chunk[i:i + 16]) + " |....|"
for i in range(0, len(chunk), 16)]
text = f"Hexdump at {a:08x} ({n} bytes):\n\n" + "\n".join(rows) + "\n"
result = {"content": [{"type": "text", "text": text}]} if body["method"] == "tools/call" else {}
data = json.dumps({"jsonrpc": "2.0", "id": body["id"], "result": result}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
return f"http://127.0.0.1:{server.server_port}/mcp"
def test_match_and_mismatch(self):
from harbor.reverse import ghidra_check
d = temp_dir()
self.addCleanup(shutil.rmtree, d, True)
image = bytes(range(256)) * 4
(d / "image.bin").write_bytes(image)
config = {"image": str(d / "image.bin"), "base": "0x401000"}
good = ghidra_check(config | {"ghidra_url": self.serve(0x401000, 0x4013FF, (d / "image.bin").as_posix(), image)})
self.assertTrue(good["ok"], good)
self.assertEqual([c["ok"] for c in good["checks"]], [True, True, True])
other = d / "other.bin"
other.write_bytes(image[:-1] + b"\x00")
bad = ghidra_check(config | {"ghidra_url": self.serve(0x401000, 0x4013FE, other.as_posix(),
image[:512] + b"\0" * 512)})
self.assertFalse(bad["ok"])
self.assertEqual([c["ok"] for c in bad["checks"]], [False, False, False])
class GhidraForwarder(unittest.TestCase):
"""The ghidra() tool against a fake streamable-HTTP MCP server: a session, a polled async task, a refusal."""
+15 -2
View File
@@ -18,6 +18,19 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from harbor import projects # noqa: E402
def cst(iso):
"""'2026-10-07T23:21:05Z' -> '18:21 CST' (US Central, as the User reads every time)."""
import datetime
from zoneinfo import ZoneInfo
try:
when = datetime.datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
except ValueError:
return str(iso)
if when.tzinfo is None:
when = when.replace(tzinfo=datetime.timezone.utc)
return when.astimezone(ZoneInfo("America/Chicago")).strftime("%H:%M") + " CST"
def snapshot(conn, me):
q = lambda sql, *a: conn.execute(sql, a).fetchall()
return {
@@ -88,7 +101,7 @@ def main():
for agent, last in stalled(conn, project, args.me, args.stall):
if agent in {a.strip() for a in args.ignore.split(",") if a.strip()}:
continue
found.append(f"stalled: {agent} has made no tool call since {last[11:16]}Z (not stopped) - wake it")
found.append(f"stalled: {agent} has made no tool call since {cst(last)} (not stopped) - wake it")
if found:
print("\n".join(found))
return 0
@@ -96,7 +109,7 @@ def main():
tasks = conn.execute("SELECT state, COUNT(*) FROM tasks GROUP BY state").fetchall()
last = conn.execute("SELECT agent, MAX(at) FROM activity_log GROUP BY agent").fetchall()
print(f"quiet for {args.minutes:g} min; open review items {open_items}; tasks {dict(tasks)}; last tool call "
+ ", ".join(f"{a} {t[11:16]}Z" for a, t in last))
+ ", ".join(f"{a} {cst(t)}" for a, t in last))
return 0