Logbook lives in Harbor and keeps itself current
The board's Logbook view replaces the published page: statistics, the chart of the executable, where the code stands (with the function tiers), the trend, milestones and the ship's log, stored as JSON in the Harbor home. A server thread follows main: each new commit becomes a log entry and the statistics, chart and trend point are rebuilt with the open-seas progress tools on a scratch copy. 'harbor logbook add|stats|map|milestone|refresh'.
This commit is contained in:
1 parent
87631bec52
commit
b7e09cfbd1
7 files changed
+497
-6
No files matched your search
@@ -262,6 +262,13 @@ def build_parser():
|
||||
p.add_argument("--max-bytes", type=int, dest="max_bytes")
|
||||
p.add_argument("--count", type=int, default=16)
|
||||
p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"])
|
||||
p = sub.add_parser("logbook", help="the project Logbook: add a log entry, set statistics, the chart or a milestone")
|
||||
p.add_argument("action", choices=["add", "stats", "map", "milestone", "show", "import", "refresh"])
|
||||
p.add_argument("--title")
|
||||
p.add_argument("--kind", default="note")
|
||||
p.add_argument("--detail", help="the entry text (or --file)")
|
||||
p.add_argument("--file", help="a text file (add) or a JSON file (stats, map, milestone) or an export folder (import)")
|
||||
p.add_argument("--no-history", action="store_true", help="stats: do not add a trend point")
|
||||
p = sub.add_parser("next-functions", help="functions in your bands to decompile next, easiest first")
|
||||
p.add_argument("-n", type=int, default=10)
|
||||
p = sub.add_parser("defer", help="set a function aside with a reason and evidence")
|
||||
@@ -718,6 +725,34 @@ def main(argv=None):
|
||||
else:
|
||||
res = r.lookup(args.target)
|
||||
out(res, json.dumps(res, indent=1))
|
||||
elif cmd == "logbook":
|
||||
from . import logbook
|
||||
project = harbor.project
|
||||
if args.action in ("add", "stats", "map", "milestone", "import", "refresh") and who() not in harbor.coordinators:
|
||||
raise HarborError("the Logbook is written by a coordinator")
|
||||
text = Path(args.file).read_text(encoding="utf-8") if args.file and args.action != "import" else None
|
||||
if args.action == "add":
|
||||
res = logbook.add_entry(project, args.title, args.detail or text, kind=args.kind)
|
||||
out(res, f"logged {res['id']}: {res['title']}")
|
||||
elif args.action == "stats":
|
||||
res = logbook.set_stats(project, json.loads(text), history_point=not args.no_history)
|
||||
out(res, "statistics updated")
|
||||
elif args.action == "map":
|
||||
res = logbook.set_map(project, json.loads(text))
|
||||
out(res, f"chart updated ({res['cells']} cells)")
|
||||
elif args.action == "milestone":
|
||||
res = logbook.upsert_milestone(project, json.loads(text))
|
||||
out(res, f"milestone {res['id']} saved")
|
||||
elif args.action == "refresh":
|
||||
from . import logbook_auto
|
||||
res = logbook_auto.refresh(project, force=True)
|
||||
out(res, json.dumps(res))
|
||||
elif args.action == "import":
|
||||
res = logbook.import_export(project, args.file)
|
||||
out(res, json.dumps(res))
|
||||
else:
|
||||
res = logbook.read_all(project, log_limit=10)
|
||||
out(res, "\n".join(f"{e['at'][:16]} [{e['kind']}] {e['title']}" for e in res["log"]))
|
||||
elif cmd in ("next-functions", "defer", "progress"):
|
||||
from .queue import FunctionQueue
|
||||
q = FunctionQueue(harbor)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The project's Logbook, kept by Harbor and shown on the board's Logbook view: headline statistics, the chart of the
|
||||
executable, the trend, milestones and the ship's log. (It replaced a separately published page on 2026-10-05.)
|
||||
|
||||
One JSON file per collection in the project's logbook folder ([logbook] dir, default <harbor home>/logbook/<project>):
|
||||
stats.json the latest statistics (functions, bytes, by_category, percentages, commit, updated_at)
|
||||
map.json the chart of the executable (low, high, cells: "game,engine,platform,crt,unsorted,done" per cell)
|
||||
history.json [{at, classified, decompiled, weighted, verified}] one point per statistics report
|
||||
milestones.json [{id, code, title, phase, state, order, items: [{text, done}]}]
|
||||
log.json [{id, at, kind, title, detail}] the ship's log, newest last
|
||||
Writes replace the file atomically. Nothing here talks to the network."""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from . import projects
|
||||
from .util import HarborError
|
||||
|
||||
KINDS = ("milestone", "tooling", "progress", "decision", "finding", "naming", "review", "setback", "note")
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def folder(project):
|
||||
configured = (getattr(project, "logbook", None) or {}).get("dir")
|
||||
path = Path(configured) if configured else projects.harbor_home() / "logbook" / project.name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _read(project, name, default):
|
||||
path = folder(project) / name
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
raise HarborError(f"{path} is not valid JSON") from None
|
||||
|
||||
|
||||
def _write(project, name, value):
|
||||
path = folder(project) / name
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(value, indent=1, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def read_all(project, log_limit=80):
|
||||
log = _read(project, "log.json", [])
|
||||
return {"stats": _read(project, "stats.json", None), "map": _read(project, "map.json", None),
|
||||
"history": sorted(_read(project, "history.json", []), key=lambda p: p.get("at", "")),
|
||||
"milestones": sorted(_read(project, "milestones.json", []), key=lambda m: m.get("order", 0)),
|
||||
"log": sorted(log, key=lambda e: e.get("at", ""), reverse=True)[:log_limit], "log_total": len(log),
|
||||
"auto": _read(project, "auto.json", {})}
|
||||
|
||||
|
||||
def add_entry(project, title, detail, kind="note", at=None):
|
||||
title, detail = (title or "").strip(), (detail or "").strip()
|
||||
if not title or not detail:
|
||||
raise HarborError("a log entry needs a title and a detail")
|
||||
if kind not in KINDS:
|
||||
raise HarborError("kind must be one of " + ", ".join(KINDS))
|
||||
with _LOCK:
|
||||
log = _read(project, "log.json", [])
|
||||
at = at or now()
|
||||
stem = at[:16].replace("-", "").replace(":", "").replace("T", "-")
|
||||
n = 1 + sum(1 for e in log if str(e.get("id", "")).startswith(stem))
|
||||
entry = {"id": f"{stem}-{n}", "at": at, "kind": kind, "title": title, "detail": detail}
|
||||
log.append(entry)
|
||||
_write(project, "log.json", log)
|
||||
return entry
|
||||
|
||||
|
||||
def set_stats(project, stats, history_point=True):
|
||||
"""Replace the statistics; also record a trend point (classified, decompiled, weighted, verified)."""
|
||||
if not isinstance(stats, dict) or "functions" not in stats:
|
||||
raise HarborError("statistics need at least 'functions'")
|
||||
stats = {**stats, "updated_at": stats.get("updated_at") or now()}
|
||||
with _LOCK:
|
||||
_write(project, "stats.json", stats)
|
||||
if history_point:
|
||||
history = _read(project, "history.json", [])
|
||||
history.append({"at": stats["updated_at"], "classified": stats.get("classified_percent", 0),
|
||||
"decompiled": stats.get("game_bytes_decompiled_percent", 0),
|
||||
"weighted": stats.get("weighted_percent", 0),
|
||||
"verified": stats.get("game_bytes_verified_percent", 0)})
|
||||
_write(project, "history.json", history)
|
||||
return stats
|
||||
|
||||
|
||||
def set_map(project, chart):
|
||||
if not isinstance(chart, dict) or "cells" not in chart:
|
||||
raise HarborError("the chart needs 'cells'")
|
||||
with _LOCK:
|
||||
_write(project, "map.json", chart)
|
||||
return {"cells": len(chart["cells"])}
|
||||
|
||||
|
||||
def upsert_milestone(project, milestone):
|
||||
if not isinstance(milestone, dict) or not milestone.get("id"):
|
||||
raise HarborError("a milestone needs an id")
|
||||
with _LOCK:
|
||||
milestones = [m for m in _read(project, "milestones.json", []) if m.get("id") != milestone["id"]]
|
||||
milestones.append(milestone)
|
||||
_write(project, "milestones.json", sorted(milestones, key=lambda m: m.get("order", 0)))
|
||||
return milestone
|
||||
|
||||
|
||||
def import_export(project, export_dir):
|
||||
"""One-time import of the published page's database export (one JSON file per document)."""
|
||||
export = Path(export_dir)
|
||||
|
||||
def docs(collection):
|
||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted((export / collection).glob("*.json"))]
|
||||
with _LOCK:
|
||||
log = [{**d, "id": d.get("id") or p.stem} for d, p in zip(docs("log"), sorted((export / "log").glob("*.json")))]
|
||||
_write(project, "log.json", sorted(log, key=lambda e: e.get("at", "")))
|
||||
_write(project, "history.json", sorted(docs("history"), key=lambda e: e.get("at", "")))
|
||||
_write(project, "milestones.json", sorted(docs("milestones"), key=lambda m: m.get("order", 0)))
|
||||
if (export / "stats" / "current.json").exists():
|
||||
_write(project, "stats.json", json.loads((export / "stats" / "current.json").read_text(encoding="utf-8")))
|
||||
if (export / "stats" / "map.json").exists():
|
||||
_write(project, "map.json", json.loads((export / "stats" / "map.json").read_text(encoding="utf-8")))
|
||||
data = read_all(project, log_limit=0)
|
||||
return {"log": data["log_total"], "history": len(data["history"]), "milestones": len(data["milestones"]),
|
||||
"stats": bool(data["stats"]), "map": bool(data["map"])}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Keeps the Logbook current without anyone writing to it: when the decomp repository's main branch moves,
|
||||
|
||||
1. each new commit on main becomes a ship's log entry (its subject and the first lines of its message), and
|
||||
2. the statistics, the chart of the executable and a trend point are rebuilt with the open-seas progress tools,
|
||||
run on a scratch copy of that repository (the real one stays untouched).
|
||||
|
||||
Configured per project in a [logbook] table:
|
||||
auto = true
|
||||
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)
|
||||
Milestones and narrative entries stay with people: they need judgement.
|
||||
|
||||
State (the last commit seen) is kept in the logbook folder as auto.json."""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from . import logbook
|
||||
|
||||
MAX_ENTRIES = 25
|
||||
|
||||
|
||||
def _git(repo, *args):
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()[:300]}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _state(project):
|
||||
path = logbook.folder(project) / "auto.json"
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
|
||||
|
||||
def _save_state(project, state):
|
||||
path = logbook.folder(project) / "auto.json"
|
||||
path.write_text(json.dumps(state, indent=1), encoding="utf-8")
|
||||
|
||||
|
||||
def _utc(iso):
|
||||
"""git's %cI (local time with an offset) as UTC, the Logbook's format."""
|
||||
import datetime
|
||||
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _kind(files):
|
||||
if files and all(f.startswith("annotations/") for f in files):
|
||||
return "naming"
|
||||
if any(f.startswith("src/") for f in files):
|
||||
return "progress"
|
||||
return "note"
|
||||
|
||||
|
||||
def log_commits(project, repo, since, head):
|
||||
"""One ship's log entry per commit on main after `since` (oldest first); a summary when there are many."""
|
||||
raw = _git(repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%cI%x1f%s%x1f%b%x1e", f"{since}..{head}")
|
||||
commits = [c.strip().split("\x1f") for c in raw.split("\x1e") if c.strip()]
|
||||
added = []
|
||||
if len(commits) > MAX_ENTRIES:
|
||||
subjects = "\n".join(f"- {c[2]} ({c[0][:8]})" for c in commits[-40:])
|
||||
added.append(logbook.add_entry(project, f"{len(commits)} commits landed on main",
|
||||
subjects, kind="progress", at=_utc(commits[-1][1])))
|
||||
return added
|
||||
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 ''})"
|
||||
added.append(logbook.add_entry(project, subject, detail, kind=_kind(files), at=_utc(when)))
|
||||
return added
|
||||
|
||||
|
||||
def rebuild_stats(project, repo, stats_repo, head):
|
||||
"""Run the open-seas progress tools on the scratch copy and store their statistics and chart."""
|
||||
tools = Path(stats_repo) / "tools" / "progress"
|
||||
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo),
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
|
||||
sync = run(tools / "sync_decomp.py", "--decomp", repo)
|
||||
if sync.returncode != 0:
|
||||
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
|
||||
report = run(tools / "progress.py")
|
||||
if report.returncode != 0:
|
||||
raise RuntimeError("progress: " + (report.stdout + report.stderr).strip()[-400:])
|
||||
with tempfile.TemporaryDirectory() as out:
|
||||
payload = run(tools / "dashboard_payload.py", out)
|
||||
if payload.returncode != 0:
|
||||
raise RuntimeError("dashboard_payload: " + (payload.stdout + payload.stderr).strip()[-400:])
|
||||
stats = json.loads((Path(out) / "stats_current.json").read_text(encoding="utf-8"))
|
||||
chart = json.loads((Path(out) / "stats_map.json").read_text(encoding="utf-8"))
|
||||
stats["commit"] = head[:7]
|
||||
stats["updated_at"] = logbook.now()
|
||||
logbook.set_stats(project, stats)
|
||||
logbook.set_map(project, chart)
|
||||
return {"classified": stats.get("classified_percent"), "weighted": stats.get("weighted_percent")}
|
||||
|
||||
|
||||
def refresh(project, force=False):
|
||||
"""Bring the Logbook up to main. Returns what changed."""
|
||||
config = getattr(project, "logbook", None) or {}
|
||||
repo, stats_repo = config.get("decomp"), config.get("stats_repo")
|
||||
if not repo:
|
||||
return {"changed": False, "reason": "no [logbook] decomp"}
|
||||
head = _git(repo, "rev-parse", "HEAD").strip()
|
||||
state = _state(project)
|
||||
if state.get("last_head") == head and not force:
|
||||
return {"changed": False, "head": head[:8]}
|
||||
out = {"changed": True, "head": head[:8]}
|
||||
if state.get("last_head"):
|
||||
try:
|
||||
out["entries"] = len(log_commits(project, repo, state["last_head"], head))
|
||||
except RuntimeError as error: # e.g. the old commit vanished after a rewrite
|
||||
out["log_error"] = str(error)
|
||||
if stats_repo:
|
||||
try:
|
||||
out["stats"] = rebuild_stats(project, repo, stats_repo, head)
|
||||
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||
out["stats_error"] = str(error)
|
||||
_save_state(project, {**state, "last_head": head, "last_refresh": logbook.now(),
|
||||
**({"last_error": out.get("stats_error") or out.get("log_error")}
|
||||
if out.get("stats_error") or out.get("log_error") else {"last_error": None})})
|
||||
return out
|
||||
|
||||
|
||||
def loop(registry):
|
||||
"""Server thread: refresh every project with [logbook] auto = true."""
|
||||
while True:
|
||||
wait = 600
|
||||
for name in registry.names():
|
||||
try:
|
||||
h, _ = registry.get(name)
|
||||
config = getattr(h.project, "logbook", None) or {}
|
||||
if config.get("auto"):
|
||||
wait = min(wait, int(config.get("interval_minutes", 10)) * 60)
|
||||
refresh(h.project)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
time.sleep(max(60, wait))
|
||||
@@ -94,6 +94,7 @@ class Project:
|
||||
policy: dict = field(default_factory=dict)
|
||||
autopilot: bool = False # running agents keep working instead of ending their turns (hooks)
|
||||
shared_files: tuple = () # glob patterns (repo-relative) every agent edits and the integrator merges: not collisions
|
||||
logbook: dict = field(default_factory=dict) # [logbook]: dir, auto, decomp, stats_repo (harbor.logbook_auto)
|
||||
reverse: dict = field(default_factory=dict) # [reverse]: image, base, facts, imports, annotations, src (harbor.reverse)
|
||||
|
||||
@property
|
||||
@@ -209,6 +210,7 @@ def project_from_dict(data, source=None, home=None):
|
||||
templates=_templates(data.get("templates", {}), names), policy=_policy(data.get("policy"), names),
|
||||
autopilot=bool(data.get("autopilot", False)),
|
||||
shared_files=tuple(str(p).replace("\\", "/") for p in data.get("shared_files", [])),
|
||||
logbook=dict(data.get("logbook", {})),
|
||||
reverse=dict(data.get("reverse", {})))
|
||||
|
||||
|
||||
|
||||
@@ -330,6 +330,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"stopped": any(h.stopped(a) for a in h.project.ai_names)})
|
||||
return self._reply(200, out)
|
||||
h, lock = REGISTRY.get(name)
|
||||
if path == "/api/logbook":
|
||||
from . import logbook
|
||||
return self._reply(200, logbook.read_all(h.project, log_limit=min(int(query.get("limit", 80)), 500)))
|
||||
if path.startswith("/api/decomp/"):
|
||||
# its own connection and lock: these answers take seconds and must not hold up the chat
|
||||
from . import decomp_api
|
||||
@@ -548,6 +551,8 @@ def main(argv=None):
|
||||
threading.Thread(target=backup_loop, daemon=True).start()
|
||||
threading.Thread(target=git_loop, daemon=True).start()
|
||||
threading.Thread(target=toast_loop, daemon=True).start()
|
||||
from . import logbook_auto
|
||||
threading.Thread(target=logbook_auto.loop, args=(REGISTRY,), daemon=True).start()
|
||||
print(f"Harbor on http://127.0.0.1:{PORT} - projects: {', '.join(REGISTRY.names())}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
+119
-6
@@ -55,6 +55,37 @@ body { overflow: hidden; }
|
||||
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; }
|
||||
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
|
||||
.view.on { display: grid; }
|
||||
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
|
||||
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
|
||||
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
|
||||
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
|
||||
.lb-stamp { font: 12px/1.5 var(--mono); color: var(--muted); text-align: right; } .lb-stamp b { color: var(--text); font-weight: 600; }
|
||||
.lb-figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
||||
.lb-fig { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-2, 12px); padding: 14px 16px; }
|
||||
.lb-fig .v { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; } .lb-fig .l { color: var(--muted); font-size: 13px; margin-top: 4px; } .lb-fig .n { color: var(--faint); font: 11.5px/1.35 var(--mono); margin-top: 6px; }
|
||||
canvas#lbMap { width: 100%; height: 150px; display: block; }
|
||||
.lb-axis { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--faint); margin-top: 6px; }
|
||||
.lb-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
|
||||
.lb-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
||||
.lb-stack { display: flex; height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; margin-top: 10px; }
|
||||
.lb-stack i { display: block; height: 100%; min-width: 2px; }
|
||||
.lb-hatch { background: repeating-linear-gradient(135deg, var(--surface-3) 0 5px, var(--line-2) 5px 10px) !important; }
|
||||
.lb-cat { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 2px 10px; padding: 10px 0; border-top: 1px solid var(--line); align-items: start; }
|
||||
.lb-cat .sw { width: 12px; height: 12px; border-radius: 3px; margin-top: 4px; } .lb-cat .plan { color: var(--muted); font-size: 12.5px; }
|
||||
.lb-cat .nums { text-align: right; font: 12.5px/1.45 var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); } .lb-cat .nums b { color: var(--text); }
|
||||
.lb-cat .sub { grid-column: 2 / 4; margin-top: 8px; }
|
||||
.lb-big { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
svg#lbTrend { width: 100%; height: 110px; display: block; }
|
||||
.lb-ms h3 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; } .lb-ms h3 .code { font-family: var(--mono); color: var(--accent-ink); }
|
||||
.lb-ms { padding: 10px 0; border-top: 1px solid var(--line); } .lb-ms:first-child { border-top: 0; }
|
||||
.lb-ms ul { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } .lb-ms li { display: grid; grid-template-columns: 16px 1fr; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||
.lb-ms li::before { content: "○"; } .lb-ms li.done { color: var(--text); } .lb-ms li.done::before { content: "●"; color: var(--green); }
|
||||
.lb-phase { font-weight: 700; color: var(--accent-ink); margin-top: 10px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.lb-log { list-style: none; margin: 0; padding: 0; } .lb-log li { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-top: 1px solid var(--line); }
|
||||
.lb-log li:first-child { border-top: 0; } .lb-log time { font: 11.5px/1.5 var(--mono); color: var(--faint); }
|
||||
.lb-log .kind { font: 700 10px/1 inherit; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink); margin-right: 8px; }
|
||||
.lb-log .t { font-weight: 600; } .lb-log .d { color: var(--muted); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 4px; }
|
||||
@media (max-width: 560px) { .lb-log li { grid-template-columns: 1fr; gap: 2px; } .lb-stamp { text-align: left; } }
|
||||
.tierbar { display: flex; height: 22px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; }
|
||||
.tierbar span { display: block; height: 100%; min-width: 3px; }
|
||||
.tier-exact { background: var(--green); } .tier-verified { background: var(--blue); } .tier-readable { background: var(--violet); }
|
||||
@@ -442,6 +473,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
<nav class="nav" aria-label="Sections">
|
||||
<div class="nav-label">Board</div>
|
||||
<button class="nav-btn" data-go="overview" title="Overview (1)"><svg class="i"><use href="static/icons.svg#home"/></svg><span>Overview</span><span class="count-badge" id="navOverview" hidden></span></button>
|
||||
<button class="nav-btn" data-go="logbook" title="Logbook (9)"><svg class="i"><use href="static/icons.svg#anchor"/></svg><span>Logbook</span></button>
|
||||
<button class="nav-btn" data-go="launch" title="Launch (2)"><svg class="i"><use href="static/icons.svg#rocket"/></svg><span>Launch</span><span class="count-badge" id="navLaunch" hidden></span></button>
|
||||
<button class="nav-btn" data-go="tasks" title="Tasks (3)"><svg class="i"><use href="static/icons.svg#tasks"/></svg><span>Tasks</span><span class="count-badge" id="navTasks" hidden></span></button>
|
||||
<button class="nav-btn" data-go="review" title="Review (4)"><svg class="i"><use href="static/icons.svg#review"/></svg><span>Review</span><span class="count-badge" id="navReview" hidden></span></button>
|
||||
@@ -637,6 +669,43 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- logbook -->
|
||||
<section class="view lb" id="view-logbook" aria-label="Logbook">
|
||||
<section class="card"><div class="body lb-head">
|
||||
<div><span class="chip" id="lbMilestone">Milestone</span>
|
||||
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
|
||||
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
|
||||
<div class="lb-figs" id="lbFigs"></div>
|
||||
<section class="card" aria-labelledby="h-lbmap">
|
||||
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
|
||||
<div class="body"><canvas id="lbMap" aria-label="Address space of the executable, bytes per category"></canvas>
|
||||
<div class="lb-axis"><span id="lbLow">0x401000</span><span>code address →</span><span id="lbHigh"></span></div>
|
||||
<div class="lb-legend" id="lbLegend"></div></div>
|
||||
</section>
|
||||
<div class="cols">
|
||||
<section class="card" aria-labelledby="h-lbwhere">
|
||||
<header><h2 id="h-lbwhere"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Where the code stands</h2></header>
|
||||
<div class="body" id="lbWhere"></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-lbtrend">
|
||||
<header><h2 id="h-lbtrend"><svg class="i"><use href="static/icons.svg#activity"/></svg>Trend</h2></header>
|
||||
<div class="body"><svg id="lbTrend" viewBox="0 0 300 110" preserveAspectRatio="none" aria-label="Progress over time"></svg>
|
||||
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--lb-game)"></i>classified</span><span><i class="lb-sw" style="background:var(--lb-platform)"></i>game code decompiled</span><span><i class="lb-sw" style="background:var(--lb-done)"></i>weighted progress</span></div>
|
||||
<div class="note" id="lbTrendNote" style="margin-top:8px"></div></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="cols">
|
||||
<section class="card" aria-labelledby="h-lbms">
|
||||
<header><h2 id="h-lbms"><svg class="i"><use href="static/icons.svg#flag"/></svg>Milestones</h2></header>
|
||||
<div class="body" id="lbMilestones"></div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="h-lblog">
|
||||
<header><h2 id="h-lblog"><svg class="i"><use href="static/icons.svg#book"/></svg>Ship’s log</h2><span class="count" id="lbLogCount"></span></header>
|
||||
<div class="body"><ul class="lb-log" id="lbLog"></ul></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- decomp -->
|
||||
<section class="view" id="view-decomp" aria-label="Decomp">
|
||||
<section class="card" aria-labelledby="h-progress">
|
||||
@@ -757,7 +826,8 @@ const VIEWS={overview:['Overview','Who is working, what needs you, and what happ
|
||||
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
|
||||
activity:['Activity','Live tool calls, the team pulse and the scoreboard','activity'],knowledge:['Knowledge','Search every message, pinned decisions and threads','book'],
|
||||
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],
|
||||
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],chat:['Chat','The team’s conversation','chat']};
|
||||
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],
|
||||
logbook:['Logbook','Where the decomp stands, how it got here and what comes next','anchor'],chat:['Chat','The team’s conversation','chat']};
|
||||
function show(view){if(!VIEWS[view])view='overview';current=view;
|
||||
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+view));
|
||||
document.querySelectorAll('.nav-btn[data-go]').forEach(b=>{if(b.dataset.go===view)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current')});
|
||||
@@ -771,6 +841,7 @@ function show(view){if(!VIEWS[view])view='overview';current=view;
|
||||
if(data&&view==='activity'){renderPulse();renderFeed()}
|
||||
if(data&&view==='launch')renderLaunch();
|
||||
if(data&&view==='decomp')renderDecomp();
|
||||
if(data&&view==='logbook')renderLogbook();
|
||||
if(view==='health')renderTools();
|
||||
if(view==='health'&&!doctorRan)runDoctor()}
|
||||
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')}
|
||||
@@ -1020,10 +1091,10 @@ function renderDecomp(){renderProgress();renderNextUp();renderDeferred()}
|
||||
async function renderProgress(){let p;try{p=await get('api/decomp/progress')}catch(e){put('progress','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
const t=p.tiers,own=['exact','verified','readable','deferred','raw'].reduce((a,k)=>a+t[k].functions,0),done=t.exact.functions+t.verified.functions+t.readable.functions;
|
||||
el('progressSum').textContent=fmt(done)+' of '+fmt(own)+' game functions decompiled';
|
||||
put('progress','<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'
|
||||
+'<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
|
||||
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'
|
||||
+(p.sources&&!p.sources.diffrun_results?'<div class="note" style="margin-top:10px">The emulator check has not reported yet, so Verified is still empty.</div>':''))}
|
||||
put('progress',tierBar(t)+'<div class="lb-legend">'+TIERS.map(([k,l])=>'<span><i class="tier-key tier-'+k+'"></i>'+esc(l)+' '+fmt(t[k].functions)+'</span>').join('')+'</div><div class="note" style="margin-top:10px">Bytes, categories, the trend and the ship’s log: <a href="#logbook" data-go="logbook">open the Logbook</a>.</div>');}
|
||||
function tierTable(t){return '<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
|
||||
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'}
|
||||
function tierBar(t){return '<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'}
|
||||
async function renderNextUp(){if(!el('nextUp')._html)put('nextUp',empty('clock','Ranking the bands…','This takes a few seconds the first time.'));let q;
|
||||
try{q=await get('api/decomp/queues')}catch(e){put('nextUp','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
put('nextUp',q.length?q.map(a=>'<div class="band"><div class="cell-who">'+avatar(a.agent,'xs')+'<b>'+esc(a.agent)+'</b><span class="note mono">'+esc(a.bands.map(b=>b.join('–')).join(', '))+'</span></div>'
|
||||
@@ -1055,6 +1126,48 @@ async function inspectFn(){const target=el('fnTarget').value.trim();if(!target)r
|
||||
async function renderTools(){let t;try{t=await get('api/decomp/tools')}catch(e){put('retools','<div class="empty err">'+esc(e.message)+'</div>');return}
|
||||
const bad=t.filter(x=>!x.ok).length;el('reSum').textContent=bad?bad+' to check':'all in place';
|
||||
put('retools','<table><thead><tr><th>Status</th><th>Tool</th></tr></thead><tbody>'+t.map(x=>'<tr><td><span class="tag '+(x.ok?'green':'amber')+'"><span class="dot"></span>'+(x.ok?'ok':'check')+'</span></td><td>'+esc(x.tool)+'<div class="note">'+esc(x.detail)+'</div></td></tr>').join('')+'</tbody></table>')}
|
||||
const LB_CATS=[['game','Game code','Firaxis game logic: decompiled to readable C, then ported to Rust'],['engine','Engine','Gamebryo: named and documented, replaced in the port'],
|
||||
['platform','Platform','Miles, Bink, D3D9 and input wrappers: replaced in the port'],['crt','C runtime','MSVC 7.1 runtime: replaced by Rust’s standard library'],['unsorted','Unsorted','not classified yet; each lands in one of the four above']];
|
||||
let LB=null;
|
||||
const pct=n=>Number(n||0).toFixed(Number(n)>=10?1:2)+'%';
|
||||
const kb=b=>b>=1048576?(b/1048576).toFixed(2)+' MB':Math.round((b||0)/1024)+' KB';
|
||||
const cssVar=n=>getComputedStyle(el('view-logbook')).getPropertyValue(n).trim();
|
||||
async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbStamp','<span class="err">'+esc(e.message)+'</span>');return}
|
||||
const s=LB.stats||{},cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0};
|
||||
const cur=LB.milestones.find(m=>/progress/i.test(m.state||''));el('lbMilestone').textContent=cur?'Milestone '+cur.code+' · '+cur.title:'Milestones';
|
||||
const au=LB.auto||{};put('lbStamp',(s.updated_at?'Statistics updated <b>'+esc(central(s.updated_at))+'</b><br>main at <b>'+esc(s.commit||'–')+'</b>':'No statistics yet')+(au.last_refresh?'<br>Keeps itself current: last checked '+esc(central(au.last_refresh))+(au.last_error?' · <span class="err">'+esc(String(au.last_error).slice(0,120))+'</span>':''):''));
|
||||
put('lbFigs',[[fmt(s.functions),'functions in the original executable',fmt(s.bytes)+' bytes of code'],[pct(s.classified_percent),'classified (category known)',fmt(cat('unsorted').functions)+' still unsorted'],
|
||||
[pct(s.weighted_percent),'weighted progress','documented 20 · decompiled 40 · reimplemented 75 · verified 100'],[pct(s.game_bytes_decompiled_percent),'of game code decompiled to readable C','Rust: '+pct(s.game_bytes_reimplemented_percent)+' reimplemented']]
|
||||
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
|
||||
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
|
||||
drawLbMap();renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
|
||||
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
|
||||
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
|
||||
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
|
||||
let tiers='';try{const p=await get('api/decomp/progress');tiers='<h3 class="sub-h" style="margin-top:18px">Game functions by how far they have come</h3>'+tierBar(p.tiers)+tierTable(p.tiers)}catch(e){}
|
||||
put('lbWhere','<div><span class="lb-big">'+pct(sorted)+'</span> <span class="note">of the executable’s code is sorted into a category · '+fmt(uns.functions)+' functions ('+kb(uns.bytes)+') still unsorted</span></div>'
|
||||
+'<div class="lb-stack" role="img" aria-label="Code bytes by category">'+LB_CATS.map(([k,n])=>{const sh=total?100*cat(k).bytes/total:0;return '<i class="'+(k==='unsorted'?'lb-hatch':'')+'" style="flex:0 0 '+sh.toFixed(3)+'%;background:var(--lb-'+k+')" title="'+esc(n)+': '+sh.toFixed(1)+'% of bytes"></i>'}).join('')+'</div>'
|
||||
+'<div class="lb-axis"><span>0</span><span>sorted '+pct(sorted)+' · unsorted '+pct(100-sorted)+'</span><span>'+kb(total)+'</span></div>'
|
||||
+LB_CATS.map(([k,n,plan])=>{const c=cat(k),sh=total?100*c.bytes/total:0;return '<div class="lb-cat"><span class="sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></span><div><b>'+esc(n)+'</b><div class="plan">'+esc(plan)+'</div></div><div class="nums"><b>'+sh.toFixed(1)+'%</b> of bytes<br>'+fmt(c.functions)+' fn · '+kb(c.bytes)+'</div>'
|
||||
+(k==='game'?'<div class="sub"><div class="tierbar" style="height:8px"><span class="tier-readable" style="flex:'+(s.game_bytes_decompiled_percent||0)+'"></span><span style="flex:'+Math.max(0,100-(s.game_bytes_decompiled_percent||0))+';background:transparent"></span></div><div class="note">'+pct(s.game_bytes_decompiled_percent)+' of game bytes decompiled</div></div>':'')+'</div>'}).join('')
|
||||
+'<div class="note" style="margin-top:10px">Bar and shares are by bytes of code; counts are functions. Only game code is decompiled and ported; the rest is named and replaced.</div>'+tiers)}
|
||||
function drawLbMap(){const m=LB&&LB.map,c=el('lbMap');if(!c||!m||!c.clientWidth)return;const r=window.devicePixelRatio||1,w=c.clientWidth,h=c.clientHeight;
|
||||
c.width=Math.round(w*r);c.height=Math.round(h*r);const g=c.getContext('2d');g.setTransform(r,0,0,r,0,0);g.clearRect(0,0,w,h);
|
||||
const cells=m.cells.map(x=>x.split(',').map(Number)),max=Math.max(1,...cells.map(x=>x.slice(0,5).reduce((a,b)=>a+b,0)));
|
||||
const colors=LB_CATS.map(([k])=>cssVar('--lb-'+k)),done=cssVar('--lb-done'),rule=cssVar('--line'),cw=w/cells.length,base=h-12;
|
||||
g.fillStyle=rule;g.fillRect(0,base,w,1);
|
||||
cells.forEach((x,i)=>{let y=base;for(let k=0;k<5;k++){const hh=x[k]/max*(base-4);if(hh<=0)continue;g.fillStyle=colors[k];g.fillRect(i*cw+.5,y-hh,Math.max(1,cw-1),hh);y-=hh}
|
||||
const tot=x.slice(0,5).reduce((a,b)=>a+b,0);if(tot>0&&x[5]>0){g.fillStyle=done;g.fillRect(i*cw+.5,base+4,Math.max(1,(cw-1)*Math.min(1,x[5]/tot)),6)}});
|
||||
el('lbLow').textContent=m.low;el('lbHigh').textContent=m.high}
|
||||
function renderLbTrend(points){const svg=el('lbTrend');if(!points.length){svg.innerHTML='';el('lbTrendNote').textContent='The trend starts with the second report.';return}
|
||||
const n=points.length,x=i=>n===1?150:i*300/(n-1),y=v=>104-Math.min(100,v||0)/100*96,path=k=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+','+y(p[k]).toFixed(1)).join(' ');
|
||||
svg.innerHTML=[0,25,50,75,100].map(v=>'<line x1="0" x2="300" y1="'+y(v)+'" y2="'+y(v)+'" stroke="var(--line)" stroke-width="0.5"/>').join('')
|
||||
+[['classified','--lb-game'],['decompiled','--lb-platform'],['weighted','--lb-done']].map(([k,c])=>'<path d="'+path(k)+'" fill="none" stroke="var('+c+')" stroke-width="2" vector-effect="non-scaling-stroke"/>').join('');
|
||||
el('lbTrendNote').textContent=n+' reports since '+new Date(points[0].at).toLocaleDateString()+'. Scale 0–100%.'}
|
||||
function renderLbMilestones(list){let last='';put('lbMilestones',list.length?list.map(m=>{const head=m.phase&&m.phase!==last?'<div class="lb-phase">'+esc(m.phase)+'</div>':'';last=m.phase||last;
|
||||
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
|
||||
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
|
||||
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
|
||||
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
|
||||
el('refreshDecomp').addEventListener('click',renderDecomp);
|
||||
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});
|
||||
@@ -1304,7 +1417,7 @@ document.addEventListener('visibilitychange',()=>{if(!document.hidden){unseen=0;
|
||||
const startQ=new URLSearchParams(location.search).get('q');
|
||||
const onBoard=/\/board\/?$/.test(location.pathname);
|
||||
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
|
||||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||||
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
|
||||
setInterval(refresh,3000);
|
||||
setInterval(()=>{if(current==='overview')renderDigest()},30000);
|
||||
setInterval(()=>{if(current==='activity')renderPulse()},60000);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The Logbook store and its automatic commit log."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from helpers import HarborCase
|
||||
from harbor import logbook, logbook_auto
|
||||
from harbor.util import HarborError
|
||||
|
||||
|
||||
class LogbookStore(HarborCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.h.project.logbook = {"dir": str(self.dir / "logbook")}
|
||||
|
||||
def test_entries_stats_and_milestones(self):
|
||||
e = logbook.add_entry(self.h.project, "First", "Something happened.", kind="tooling", at="2026-10-05T10:00:00Z")
|
||||
self.assertEqual(e["id"], "20261005-1000-1")
|
||||
with self.assertRaises(HarborError):
|
||||
logbook.add_entry(self.h.project, "x", "y", kind="gossip")
|
||||
logbook.set_stats(self.h.project, {"functions": 10, "classified_percent": 50.0, "weighted_percent": 5.0})
|
||||
logbook.upsert_milestone(self.h.project, {"id": "m1", "order": 1, "code": "M1", "title": "T", "items": []})
|
||||
data = logbook.read_all(self.h.project)
|
||||
self.assertEqual((data["log"][0]["title"], data["stats"]["functions"], data["history"][0]["classified"],
|
||||
data["milestones"][0]["code"]), ("First", 10, 50.0, "M1"))
|
||||
|
||||
|
||||
class LogbookAuto(HarborCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.repo = self.dir / "repo"
|
||||
self.repo.mkdir()
|
||||
git = lambda *a: subprocess.run(["git", "-C", str(self.repo), *a], check=True, capture_output=True)
|
||||
git("init", "-q")
|
||||
git("config", "user.name", "T")
|
||||
git("config", "user.email", "t@example.com")
|
||||
(self.repo / "a.txt").write_text("1", encoding="utf-8")
|
||||
git("add", "-A")
|
||||
git("commit", "-q", "-m", "first")
|
||||
self.first = subprocess.run(["git", "-C", str(self.repo), "rev-parse", "HEAD"], capture_output=True,
|
||||
text=True).stdout.strip()
|
||||
(self.repo / "annotations").mkdir()
|
||||
(self.repo / "annotations" / "names.csv").write_text("x", encoding="utf-8")
|
||||
git("add", "-A")
|
||||
git("commit", "-q", "-m", "Name things", "-m", "Body line one.\nCo-Authored-By: someone")
|
||||
self.h.project.logbook = {"dir": str(self.dir / "logbook"), "decomp": str(self.repo)}
|
||||
|
||||
def test_new_commits_become_entries_once(self):
|
||||
logbook_auto._save_state(self.h.project, {"last_head": self.first})
|
||||
out = logbook_auto.refresh(self.h.project)
|
||||
self.assertEqual(out["entries"], 1)
|
||||
entry = logbook.read_all(self.h.project)["log"][0]
|
||||
self.assertEqual((entry["title"], entry["kind"]), ("Name things", "naming"))
|
||||
self.assertIn("Body line one.", entry["detail"])
|
||||
self.assertNotIn("Co-Authored-By", entry["detail"])
|
||||
self.assertTrue(entry["at"].endswith("Z"))
|
||||
self.assertFalse(logbook_auto.refresh(self.h.project)["changed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user