raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Territory map: every locked path, grouped by repo, with its current holder and contention."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from raidlock.core import leases as L
|
|
6
|
+
from raidlock.core.errors import Precondition
|
|
7
|
+
from raidlock.core.paths import overlaps
|
|
8
|
+
|
|
9
|
+
from ..replay import data_of, fold, records
|
|
10
|
+
|
|
11
|
+
OPEN = ("todo", "doing", "review", "blocked")
|
|
12
|
+
EVENTS = ("lock_violation", "lock_denied")
|
|
13
|
+
EVENT_SCAN = 5000
|
|
14
|
+
MAX_HISTORY_WORK = 100000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _relative(board, path):
|
|
18
|
+
if not path:
|
|
19
|
+
return ""
|
|
20
|
+
if not os.path.isabs(path):
|
|
21
|
+
return path.strip("/")
|
|
22
|
+
for root in (board.workspace, os.path.realpath(board.workspace)):
|
|
23
|
+
try:
|
|
24
|
+
rel = os.path.relpath(path, root)
|
|
25
|
+
except ValueError:
|
|
26
|
+
continue
|
|
27
|
+
if not rel.startswith(".."):
|
|
28
|
+
return rel.replace(os.sep, "/")
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def lock_events(board, until=None):
|
|
33
|
+
"""Recorded lock denials and violations: [{seq, ts, event, agent, card, paths}], newest EVENT_SCAN status
|
|
34
|
+
records only."""
|
|
35
|
+
out = []
|
|
36
|
+
source = records(board, until) if until is not None else board.transcript.read(kind="status", limit=EVENT_SCAN)
|
|
37
|
+
for r in source[-EVENT_SCAN:]:
|
|
38
|
+
if until is not None and r["seq"] > until:
|
|
39
|
+
continue
|
|
40
|
+
data = data_of(r)
|
|
41
|
+
if data.get("event") not in EVENTS:
|
|
42
|
+
continue
|
|
43
|
+
raw = data.get("paths") or ([data.get("path")] if data.get("path") else [])
|
|
44
|
+
paths = [p for p in (_relative(board, x) for x in raw) if p]
|
|
45
|
+
out.append(
|
|
46
|
+
{
|
|
47
|
+
"seq": r["seq"],
|
|
48
|
+
"ts": r.get("ts", ""),
|
|
49
|
+
"event": data["event"],
|
|
50
|
+
"agent": data.get("agent") or (r.get("from") or {}).get("agent", ""),
|
|
51
|
+
"card": r.get("taskId", "") or "",
|
|
52
|
+
"paths": paths,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _holders_now(board, cards):
|
|
59
|
+
out = []
|
|
60
|
+
for cid, lease in board.leases().items():
|
|
61
|
+
if not L.is_live(lease):
|
|
62
|
+
continue
|
|
63
|
+
card = cards.get(cid, {})
|
|
64
|
+
out.append(
|
|
65
|
+
{
|
|
66
|
+
"agent": lease.get("agent", ""),
|
|
67
|
+
"card": cid,
|
|
68
|
+
"lane": card.get("lane", ""),
|
|
69
|
+
"paths": lease.get("paths") or card.get("locks", []),
|
|
70
|
+
"expires": lease.get("expires"),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def territory(board, at=None, state=None):
|
|
77
|
+
cards = {c["id"]: c for c in board.cards()}
|
|
78
|
+
if at is None:
|
|
79
|
+
holders = _holders_now(board, cards)
|
|
80
|
+
lanes = {cid: c["lane"] for cid, c in cards.items()}
|
|
81
|
+
else:
|
|
82
|
+
state = state if state is not None else fold(board, at, marks=False)
|
|
83
|
+
cards = {c["id"]: c for c in state["cards"]}
|
|
84
|
+
holders = [dict(h, expires=None) for h in state["holders"]]
|
|
85
|
+
lanes = {c["id"]: c["lane"] for c in state["cards"]}
|
|
86
|
+
events = lock_events(board, until=at)
|
|
87
|
+
if at is not None:
|
|
88
|
+
path_count = sum(len(c.get("locks", [])) for c in cards.values())
|
|
89
|
+
comparisons = sum(len(h["paths"]) for h in holders) + sum(len(e["paths"]) for e in events)
|
|
90
|
+
if path_count * max(1, comparisons) > MAX_HISTORY_WORK:
|
|
91
|
+
return {
|
|
92
|
+
"at": at,
|
|
93
|
+
"trust": "unverified history",
|
|
94
|
+
"approximate": True,
|
|
95
|
+
"incomplete_history": True,
|
|
96
|
+
"error": "history work budget exceeded",
|
|
97
|
+
"repos": [],
|
|
98
|
+
"agents": [],
|
|
99
|
+
"held": 0,
|
|
100
|
+
"contested": 0,
|
|
101
|
+
"events": [],
|
|
102
|
+
}
|
|
103
|
+
held_cards = {h["card"] for h in holders}
|
|
104
|
+
paths = {}
|
|
105
|
+
|
|
106
|
+
def entry(path):
|
|
107
|
+
e = paths.get(path)
|
|
108
|
+
if e is None:
|
|
109
|
+
head, _, rest = path.partition("/")
|
|
110
|
+
e = paths[path] = {
|
|
111
|
+
"path": path,
|
|
112
|
+
"metadata_source": "current metadata" if at is not None else "live",
|
|
113
|
+
"repo": head,
|
|
114
|
+
"rest": rest,
|
|
115
|
+
"holder": "",
|
|
116
|
+
"card": "",
|
|
117
|
+
"lane": "",
|
|
118
|
+
"expires": None,
|
|
119
|
+
"cards": [],
|
|
120
|
+
"waiting": [],
|
|
121
|
+
"denials": 0,
|
|
122
|
+
"violations": 0,
|
|
123
|
+
}
|
|
124
|
+
return e
|
|
125
|
+
|
|
126
|
+
for h in holders:
|
|
127
|
+
for p in h["paths"]:
|
|
128
|
+
e = entry(p)
|
|
129
|
+
e.update(holder=h["agent"], card=h["card"], lane=h["lane"], expires=h["expires"])
|
|
130
|
+
if at is not None:
|
|
131
|
+
e["metadata_source"] = "unverified history"
|
|
132
|
+
for cid, c in sorted(cards.items()):
|
|
133
|
+
lane = lanes.get(cid)
|
|
134
|
+
if lane not in OPEN:
|
|
135
|
+
continue
|
|
136
|
+
for p in c.get("locks", []):
|
|
137
|
+
e = entry(p)
|
|
138
|
+
if cid not in e["cards"]:
|
|
139
|
+
e["cards"].append(cid)
|
|
140
|
+
for e in paths.values():
|
|
141
|
+
for cid in e["cards"]:
|
|
142
|
+
if cid in held_cards or lanes.get(cid) not in ("todo", "blocked"):
|
|
143
|
+
continue
|
|
144
|
+
for h in holders:
|
|
145
|
+
clash = h["card"] != cid and any(overlaps(e["path"], p) for p in h["paths"])
|
|
146
|
+
if clash and cid not in e["waiting"]:
|
|
147
|
+
e["waiting"].append(cid)
|
|
148
|
+
for ev in events:
|
|
149
|
+
if any(overlaps(e["path"], p) for p in ev["paths"]):
|
|
150
|
+
e["violations" if ev["event"] == "lock_violation" else "denials"] += 1
|
|
151
|
+
e["contested"] = bool(e["waiting"] or e["denials"] or e["violations"])
|
|
152
|
+
repos = {}
|
|
153
|
+
for e in sorted(paths.values(), key=lambda x: x["path"]):
|
|
154
|
+
repos.setdefault(e["repo"], []).append(e)
|
|
155
|
+
agents = sorted({e["holder"] for e in paths.values() if e["holder"]})
|
|
156
|
+
return {
|
|
157
|
+
"at": at,
|
|
158
|
+
"trust": "unverified history" if at is not None else "live",
|
|
159
|
+
"approximate": at is not None,
|
|
160
|
+
"incomplete_history": bool(state and state["incomplete_history"]),
|
|
161
|
+
"repos": [
|
|
162
|
+
{
|
|
163
|
+
"name": name,
|
|
164
|
+
"paths": items,
|
|
165
|
+
"held": sum(1 for i in items if i["holder"]),
|
|
166
|
+
"contested": sum(1 for i in items if i["contested"]),
|
|
167
|
+
}
|
|
168
|
+
for name, items in sorted(repos.items())
|
|
169
|
+
],
|
|
170
|
+
"agents": agents,
|
|
171
|
+
"held": sum(1 for e in paths.values() if e["holder"]),
|
|
172
|
+
"contested": sum(1 for e in paths.values() if e["contested"]),
|
|
173
|
+
"events": events[-50:],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def payload(board, query):
|
|
178
|
+
raw = (query.get("at") or [""])[0]
|
|
179
|
+
try:
|
|
180
|
+
at = int(raw) if raw else None
|
|
181
|
+
except ValueError:
|
|
182
|
+
raise Precondition("at must be an integer") from None
|
|
183
|
+
if at is not None and at < 0:
|
|
184
|
+
raise Precondition("at must be a nonnegative transcript sequence number")
|
|
185
|
+
return territory(board, at)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _setup(parser):
|
|
189
|
+
parser.add_argument("--at", type=int, default=None, help="transcript sequence number (default: live leases)")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _handle(ctx, args):
|
|
193
|
+
result = territory(ctx.board, args.at)
|
|
194
|
+
lines = []
|
|
195
|
+
for repo in result["repos"]:
|
|
196
|
+
lines.append(f"{repo['name']}: {repo['held']} held, {repo['contested']} contested")
|
|
197
|
+
for e in repo["paths"]:
|
|
198
|
+
who = f"{e['holder']} on {e['card']}" if e["holder"] else "free"
|
|
199
|
+
flag = " CONTESTED" if e["contested"] else ""
|
|
200
|
+
lines.append(f" {e['path']}: {who}{flag}")
|
|
201
|
+
return result, "\n".join(lines) or "No locked paths."
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def register_cli(add_command):
|
|
205
|
+
add_command("map", "territory map: which agent holds which locked paths, and where they clash", _setup, _handle)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.view-head{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:baseline}
|
|
2
|
+
.view-head p{margin:0;flex:1 1 220px}
|
|
3
|
+
.zones{display:flex;flex-direction:column;gap:12px}
|
|
4
|
+
.repo-zone{background:var(--panel);border:1px solid var(--edge);border-radius:14px;padding:8px 10px 10px;min-width:0}
|
|
5
|
+
.repo-zone h3{margin:0 0 8px;font-size:14px;color:var(--accent);overflow-wrap:anywhere}
|
|
6
|
+
.tiles{display:flex;flex-wrap:wrap;gap:6px}
|
|
7
|
+
.tile{flex:var(--w,1) 1 9rem;min-width:0;min-height:64px;display:flex;flex-direction:column;gap:3px;padding:6px 8px;border-radius:8px;border:1px solid var(--edge);border-left:6px solid var(--agc,var(--edge));background:color-mix(in srgb,var(--agc,var(--card2)) 22%,var(--card2));font-size:14px;overflow-wrap:anywhere}
|
|
8
|
+
.tile.free{background:var(--card2);border-left-color:var(--edge);color:var(--muted)}
|
|
9
|
+
.tile .p{color:var(--ink);font-size:14px}
|
|
10
|
+
.tile .who{color:var(--muted)}
|
|
11
|
+
.tile .lane-tag{align-self:flex-start;font-size:12px;border:1px solid var(--edge);border-radius:6px;padding:0 6px}
|
|
12
|
+
.tile .flag{align-self:flex-start;font-size:12px;color:var(--blocked);font-weight:bold}
|
|
13
|
+
.tile.contested{border-color:var(--blocked);background-image:repeating-linear-gradient(135deg,transparent 0 8px,color-mix(in srgb,var(--blocked) 18%,transparent) 8px 12px)}
|
|
14
|
+
#viewPane .sw.free-sw{background:var(--card2);border:1px solid var(--edge)}
|
|
15
|
+
#viewPane .sw.hatch{background:repeating-linear-gradient(135deg,var(--card2) 0 3px,var(--blocked) 3px 5px)}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
function tile(c, e) {
|
|
5
|
+
const held = !!e.holder;
|
|
6
|
+
const cls = ["tile", held ? c.agentClass(e.holder) : "free", e.contested ? "contested" : ""].filter(Boolean).join(" ");
|
|
7
|
+
const flags = [];
|
|
8
|
+
if (e.waiting.length) flags.push(c.h("span", {class: "flag", text: `${e.waiting.length} waiting: ${e.waiting.join(", ")}`}));
|
|
9
|
+
if (e.denials) flags.push(c.h("span", {class: "flag", text: `${e.denials} denied`}));
|
|
10
|
+
if (e.violations) flags.push(c.h("span", {class: "flag", text: `${e.violations} write ${e.violations === 1 ? "violation" : "violations"}`}));
|
|
11
|
+
const who = held ? `${e.holder} on ${e.card}` : (e.cards.length ? `free · ${e.cards.join(", ")}` : "free");
|
|
12
|
+
const label = `${e.path}${e.metadata_source ? " (" + e.metadata_source + ")" : ""}: ${who}${e.contested ? ", contested" : ""}`;
|
|
13
|
+
const el = c.h("div", {class: cls, "data-path": e.path, "data-holder": e.holder || "", title: label},
|
|
14
|
+
c.h("b", {class: "p", text: e.rest || e.path}),
|
|
15
|
+
c.h("span", {class: "who", text: who}),
|
|
16
|
+
e.lane && held ? c.h("span", {class: "lane-tag", text: e.lane}) : null,
|
|
17
|
+
flags);
|
|
18
|
+
el.classList.add("tile-weight-" + Math.min(10, 1 + e.cards.length + (held ? 1 : 0)));
|
|
19
|
+
return el;
|
|
20
|
+
}
|
|
21
|
+
function tiles(c, d) {
|
|
22
|
+
if (!(d.repos || []).length) return [c.h("div", {class: "empty", text: "No locked paths yet. A card's --locks paths show up here once the card exists."})];
|
|
23
|
+
return d.repos.map(r => c.h("div", {class: "repo-zone", "data-repo": r.name},
|
|
24
|
+
c.h("h3", null, r.name, c.h("span", {class: "cls", text: ` ${r.held} held · ${r.contested} contested · ${r.paths.length} ${r.paths.length === 1 ? "path" : "paths"}`})),
|
|
25
|
+
c.h("div", {class: "tiles"}, r.paths.map(e => tile(c, e)))));
|
|
26
|
+
}
|
|
27
|
+
function legend(c, d) {
|
|
28
|
+
return c.h("div", {class: "legend-row"},
|
|
29
|
+
(d.agents || []).map(a => c.h("span", {class: c.agentClass(a)}, c.h("i", {class: "sw"}), a)),
|
|
30
|
+
c.h("span", null, c.h("i", {class: "sw free-sw"}), "free"),
|
|
31
|
+
c.h("span", null, c.h("i", {class: "sw hatch"}), "contested: a waiting card overlaps a held path, or a lock denial or write violation was recorded on it"));
|
|
32
|
+
}
|
|
33
|
+
views.push({
|
|
34
|
+
id: "map",
|
|
35
|
+
endpoint: "raidmap",
|
|
36
|
+
label: {raid: "War map", plain: "Map"},
|
|
37
|
+
tiles, legend,
|
|
38
|
+
render(c, d, pane) {
|
|
39
|
+
const head = c.h("div", {class: "view-head"},
|
|
40
|
+
c.h("h2", null, c.raid ? "War map" : "Map", c.raid && c.raidSub ? c.raidSub("Map") : null),
|
|
41
|
+
c.h("p", {class: "cls", text: `Which agent holds which locked paths: ${d.held} held, ${d.contested} contested. Updates every few seconds.`}),
|
|
42
|
+
c.h("button", {type: "button", class: "tog", "data-go": "replay", text: c.raid ? "See how it changed in the Chronicle" : "See how it changed in Timeline"}));
|
|
43
|
+
head.querySelector("[data-go]").addEventListener("click", () => c.go("replay"));
|
|
44
|
+
c.setKids(pane, [head, legend(c, d), c.h("div", {class: "zones"}, tiles(c, d))], JSON.stringify([d, c.theme]));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
})();
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Rebuild board state at any transcript sequence number by folding the recorded events in order."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from raidlock.core.errors import Precondition
|
|
6
|
+
from raidlock.core.transcript import KINDS
|
|
7
|
+
|
|
8
|
+
LANES = ("todo", "doing", "review", "blocked", "done")
|
|
9
|
+
HOLD = ("doing", "review", "blocked")
|
|
10
|
+
PAGE = 5000
|
|
11
|
+
MAX_RECORDS = 200000
|
|
12
|
+
MARKS = 4000
|
|
13
|
+
RECENT = 8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data_of(record):
|
|
17
|
+
out = {}
|
|
18
|
+
parts = list(record.get("parts", []))
|
|
19
|
+
for artifact in record.get("artifacts", []):
|
|
20
|
+
parts.extend(artifact.get("parts", []))
|
|
21
|
+
for part in parts:
|
|
22
|
+
if part.get("kind") == "data" and isinstance(part.get("data"), dict):
|
|
23
|
+
out.update(part["data"])
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def text_of(record):
|
|
28
|
+
return "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def history(board):
|
|
35
|
+
version = board.version()
|
|
36
|
+
cached = getattr(board, "_history_cache", None)
|
|
37
|
+
if cached and cached[0] == version:
|
|
38
|
+
return cached[1]
|
|
39
|
+
transcript = board.transcript
|
|
40
|
+
latest = transcript.index.query_transcript(transcript.ws, limit=1)
|
|
41
|
+
last = latest[-1]["seq"] if latest else 0
|
|
42
|
+
out, cursor, size = [], 0, 0
|
|
43
|
+
incomplete = False
|
|
44
|
+
while cursor < last:
|
|
45
|
+
rows = transcript.index.query_transcript(transcript.ws, since=cursor, limit=PAGE)
|
|
46
|
+
selected = []
|
|
47
|
+
for row in rows:
|
|
48
|
+
if len(out) + len(selected) >= MAX_RECORDS or size + row["length"] > MAX_BYTES:
|
|
49
|
+
incomplete = True
|
|
50
|
+
break
|
|
51
|
+
selected.append(row)
|
|
52
|
+
size += row["length"]
|
|
53
|
+
out.extend(transcript._load(selected))
|
|
54
|
+
if incomplete or not rows:
|
|
55
|
+
break
|
|
56
|
+
cursor = rows[-1]["seq"]
|
|
57
|
+
result = (out, last, incomplete)
|
|
58
|
+
board._history_cache = (version, result)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def records(board, until=None):
|
|
63
|
+
every, _, _ = history(board)
|
|
64
|
+
return [r for r in every if until is None or r["seq"] <= until]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class State:
|
|
68
|
+
def __init__(self, titles):
|
|
69
|
+
self.titles = titles
|
|
70
|
+
self.cards = {}
|
|
71
|
+
self.locks = {}
|
|
72
|
+
self.non_core = False
|
|
73
|
+
self.events = {"lock_violation": 0, "lock_denied": 0, "stale_fence": 0}
|
|
74
|
+
|
|
75
|
+
def card(self, cid):
|
|
76
|
+
c = self.cards.get(cid)
|
|
77
|
+
if c is None:
|
|
78
|
+
c = self.cards[cid] = {
|
|
79
|
+
"id": cid,
|
|
80
|
+
"trust": "unverified history",
|
|
81
|
+
"title_source": "current metadata",
|
|
82
|
+
"lease_holder": "",
|
|
83
|
+
"title": self.titles.get(cid, cid),
|
|
84
|
+
"lane": "todo",
|
|
85
|
+
"owner": "",
|
|
86
|
+
"tokens": 0,
|
|
87
|
+
"usd_equiv": 0.0,
|
|
88
|
+
"notes": 0,
|
|
89
|
+
"since": "",
|
|
90
|
+
}
|
|
91
|
+
return c
|
|
92
|
+
|
|
93
|
+
def apply(self, r):
|
|
94
|
+
kind, task = r.get("kind"), r.get("taskId") or ""
|
|
95
|
+
if r.get("src") != "board" or kind not in KINDS:
|
|
96
|
+
self.non_core = True
|
|
97
|
+
return
|
|
98
|
+
sender = (r.get("from") or {}).get("agent", "")
|
|
99
|
+
text, data = text_of(r), data_of({"parts": r.get("parts", [])})
|
|
100
|
+
event = data.get("event")
|
|
101
|
+
if event in self.events:
|
|
102
|
+
self.events[event] += 1
|
|
103
|
+
if not task:
|
|
104
|
+
return
|
|
105
|
+
if kind == "status":
|
|
106
|
+
if text.startswith(f"created {task}:"):
|
|
107
|
+
c = self.card(task)
|
|
108
|
+
c["title"] = text.split(":", 1)[1].strip() or task
|
|
109
|
+
c["title_source"] = "unverified history"
|
|
110
|
+
if isinstance(data.get("locks"), list):
|
|
111
|
+
self.locks[task] = data["locks"]
|
|
112
|
+
c.update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
|
|
113
|
+
elif (
|
|
114
|
+
data.get("a2a_state") == "working"
|
|
115
|
+
and "fence" in data
|
|
116
|
+
and (text.startswith("claimed by ") or "taken over from" in text)
|
|
117
|
+
):
|
|
118
|
+
self.card(task).update(lane="doing", owner=sender, lease_holder=sender, since=r.get("ts", ""))
|
|
119
|
+
elif data.get("lane") in LANES and text.startswith(f"{task} -> {data['lane']}"):
|
|
120
|
+
c = self.card(task)
|
|
121
|
+
c.update(lane=data["lane"], since=r.get("ts", ""))
|
|
122
|
+
if data["lane"] in ("todo", "done"):
|
|
123
|
+
c["lease_holder"] = ""
|
|
124
|
+
if data["lane"] == "todo":
|
|
125
|
+
c["owner"] = ""
|
|
126
|
+
elif text == "lease released":
|
|
127
|
+
c = self.card(task)
|
|
128
|
+
c["lease_holder"] = ""
|
|
129
|
+
if c["lane"] == "doing":
|
|
130
|
+
c.update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
|
|
131
|
+
elif data.get("a2a_state") == "submitted" and text.endswith("reopened"):
|
|
132
|
+
self.card(task).update(lane="todo", owner="", lease_holder="", since=r.get("ts", ""))
|
|
133
|
+
elif text == "lease expired; reaped to blocked":
|
|
134
|
+
self.card(task).update(lane="blocked", lease_holder="", since=r.get("ts", ""))
|
|
135
|
+
elif text == "lease resumed after expiry":
|
|
136
|
+
self.card(task)["lease_holder"] = sender
|
|
137
|
+
if (
|
|
138
|
+
(text.startswith(f"created {task}:") or text.startswith("claimed by ") or text == "locks updated")
|
|
139
|
+
and isinstance(data.get("locks"), list)
|
|
140
|
+
and all(isinstance(p, str) for p in data["locks"])
|
|
141
|
+
):
|
|
142
|
+
self.locks[task] = data["locks"]
|
|
143
|
+
elif kind == "cost":
|
|
144
|
+
c = self.card(task)
|
|
145
|
+
c["tokens"] += int(data.get("tokens") or 0)
|
|
146
|
+
value = float(data.get("usd_equiv") or 0)
|
|
147
|
+
if math.isfinite(value):
|
|
148
|
+
c["usd_equiv"] = round(c["usd_equiv"] + value, 6)
|
|
149
|
+
elif kind == "note":
|
|
150
|
+
self.card(task)["notes"] += 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def mark(r):
|
|
154
|
+
text = text_of(r) or ", ".join(a.get("name", "") for a in r.get("artifacts", []))
|
|
155
|
+
return {
|
|
156
|
+
"seq": r["seq"],
|
|
157
|
+
"trust": "unverified history",
|
|
158
|
+
"source": r.get("src", "unknown"),
|
|
159
|
+
"ts": r.get("ts", ""),
|
|
160
|
+
"kind": r.get("kind", ""),
|
|
161
|
+
"task": r.get("taskId", "") or "",
|
|
162
|
+
"agent": (r.get("from") or {}).get("agent", ""),
|
|
163
|
+
"text": text[:160],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fold(board, at=None, marks=True):
|
|
168
|
+
"""Board state right after transcript record `at` (default: the newest)."""
|
|
169
|
+
if at is not None and (not isinstance(at, int) or at < 0):
|
|
170
|
+
raise Precondition("at must be a nonnegative transcript sequence number")
|
|
171
|
+
current = {c["id"]: c for c in board.cards()}
|
|
172
|
+
every, latest, incomplete = history(board)
|
|
173
|
+
if not every:
|
|
174
|
+
return {
|
|
175
|
+
"at": 0,
|
|
176
|
+
"ts": "",
|
|
177
|
+
"first": 0,
|
|
178
|
+
"last": latest,
|
|
179
|
+
"incomplete_history": incomplete,
|
|
180
|
+
"trust": "unverified history",
|
|
181
|
+
"non_core_records": False,
|
|
182
|
+
"total": 0,
|
|
183
|
+
"marks": [],
|
|
184
|
+
"truncated": False,
|
|
185
|
+
"cards": [],
|
|
186
|
+
"counts": dict.fromkeys(LANES, 0),
|
|
187
|
+
"holders": [],
|
|
188
|
+
"recent": [],
|
|
189
|
+
"events": {},
|
|
190
|
+
"untracked": len(current),
|
|
191
|
+
}
|
|
192
|
+
last = every[-1]["seq"]
|
|
193
|
+
point = last if at is None else max(every[0]["seq"], min(at, last))
|
|
194
|
+
state = State({k: v.get("title", k) for k, v in current.items()})
|
|
195
|
+
upto = []
|
|
196
|
+
for r in every:
|
|
197
|
+
if r["seq"] > point:
|
|
198
|
+
break
|
|
199
|
+
state.apply(r)
|
|
200
|
+
upto.append(r)
|
|
201
|
+
lock_of = {k: v.get("locks", []) for k, v in current.items()}
|
|
202
|
+
cards = sorted(state.cards.values(), key=lambda c: (LANES.index(c["lane"]), c["id"]))
|
|
203
|
+
for c in cards:
|
|
204
|
+
c["locks"] = state.locks.get(c["id"], lock_of.get(c["id"], []))
|
|
205
|
+
c["locks_source"] = "unverified history" if c["id"] in state.locks else "current metadata"
|
|
206
|
+
holders = [
|
|
207
|
+
{"agent": c["lease_holder"], "card": c["id"], "lane": c["lane"], "paths": c["locks"]}
|
|
208
|
+
for c in cards
|
|
209
|
+
if c["lease_holder"] and c["id"] in state.locks
|
|
210
|
+
]
|
|
211
|
+
shown = [mark(r) for r in every[-MARKS:]] if marks else []
|
|
212
|
+
return {
|
|
213
|
+
"at": point,
|
|
214
|
+
"ts": upto[-1].get("ts", "") if upto else "",
|
|
215
|
+
"first": every[0]["seq"],
|
|
216
|
+
"last": latest,
|
|
217
|
+
"incomplete_history": incomplete,
|
|
218
|
+
"trust": "unverified history",
|
|
219
|
+
"non_core_records": state.non_core,
|
|
220
|
+
"total": len(every),
|
|
221
|
+
"marks": shown,
|
|
222
|
+
"truncated": len(every) > MARKS,
|
|
223
|
+
"cards": cards,
|
|
224
|
+
"counts": {lane: sum(1 for c in cards if c["lane"] == lane) for lane in LANES},
|
|
225
|
+
"holders": holders,
|
|
226
|
+
"recent": [mark(r) for r in upto[-RECENT:]][::-1],
|
|
227
|
+
"events": dict(state.events),
|
|
228
|
+
"untracked": sum(1 for k in current if k not in state.cards),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def payload(board, query):
|
|
233
|
+
raw = (query.get("at") or [""])[0]
|
|
234
|
+
try:
|
|
235
|
+
at = int(raw) if raw else None
|
|
236
|
+
except ValueError:
|
|
237
|
+
raise Precondition("at must be an integer") from None
|
|
238
|
+
from ..raidmap import territory
|
|
239
|
+
|
|
240
|
+
state = fold(board, at, marks=(query.get("marks") or ["1"])[0] != "0")
|
|
241
|
+
return {**state, "territory": territory(board, state["at"], state=state)}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _setup(parser):
|
|
245
|
+
parser.add_argument("--at", type=int, default=None, help="transcript sequence number (default: newest)")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _handle(ctx, args):
|
|
249
|
+
result = fold(ctx.board, args.at, marks=False)
|
|
250
|
+
lines = [f"at seq {result['at']} of {result['first']}..{result['last']} ({result['ts'] or 'empty'})"]
|
|
251
|
+
lines.append(", ".join(f"{n} {lane}" for lane, n in result["counts"].items()))
|
|
252
|
+
for c in result["cards"]:
|
|
253
|
+
lines.append(f" {c['lane']:<8} {c['id']}" + (f" ({c['owner']})" if c["owner"] else ""))
|
|
254
|
+
return result, "\n".join(lines)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def register_cli(add_command):
|
|
258
|
+
add_command("replay", "board state rebuilt from the transcript at a point in time", _setup, _handle)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.rp-controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
|
|
2
|
+
.rp-controls input[type=range]{flex:1 1 200px;min-width:0;accent-color:var(--accent)}
|
|
3
|
+
.rp-out{display:block;font-size:14px;padding:6px 10px;border:1px solid var(--edge);border-radius:8px;background:var(--bubble);overflow-wrap:anywhere}
|
|
4
|
+
.rp-body h3{font-size:14px;color:var(--accent);margin:14px 0 6px}
|
|
5
|
+
.rp-sum{margin:8px 0}
|
|
6
|
+
.rp-lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
|
7
|
+
.rp-lane{background:var(--panel);border:1px solid var(--edge);border-top:3px solid var(--lc,var(--edge));border-radius:10px;padding:6px 8px;min-width:0}
|
|
8
|
+
.rp-lane[data-lane=todo]{--lc:var(--todo)}.rp-lane[data-lane=doing]{--lc:var(--doing)}.rp-lane[data-lane=review]{--lc:var(--review)}.rp-lane[data-lane=blocked]{--lc:var(--blocked)}.rp-lane[data-lane=done]{--lc:var(--done)}
|
|
9
|
+
.rp-lane h3{margin:0 0 6px;font-size:14px;display:flex;justify-content:space-between;gap:6px;color:var(--ink)}
|
|
10
|
+
.rp-chips{display:flex;flex-wrap:wrap;gap:4px}
|
|
11
|
+
.rp-chip{font-size:12px;border:1px solid var(--edge);border-left:4px solid var(--agc,var(--edge));border-radius:6px;padding:1px 6px;overflow-wrap:anywhere;max-width:100%}
|
|
12
|
+
.rp-chip small{color:var(--muted)}
|
|
13
|
+
.rp-recent{margin:0;padding-left:0;list-style:none;font-size:14px;display:flex;flex-direction:column;gap:3px;overflow-wrap:anywhere}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
const LANES = [["todo", "To do"], ["doing", "In progress"], ["review", "Review"], ["blocked", "Blocked"], ["done", "Done"]];
|
|
5
|
+
const STEP_MS = 700;
|
|
6
|
+
let st = null;
|
|
7
|
+
let inFlight = false;
|
|
8
|
+
|
|
9
|
+
function stop() {
|
|
10
|
+
if (st && st.timer) { clearInterval(st.timer); st.timer = 0; }
|
|
11
|
+
if (st && st.play) { st.play.setAttribute("aria-pressed", "false"); st.play.textContent = "Play"; }
|
|
12
|
+
}
|
|
13
|
+
function markText(m) {
|
|
14
|
+
if (!m) return "No records yet. The timeline fills as agents claim, note and move cards.";
|
|
15
|
+
return `#${m.seq} · ${st.c.localTime(m.ts)} · ${m.agent || "?"}: ${m.text || m.kind}`;
|
|
16
|
+
}
|
|
17
|
+
function label() {
|
|
18
|
+
const m = st.marks[st.idx];
|
|
19
|
+
st.c.setKids(st.out, [markText(m)]);
|
|
20
|
+
st.out.title = m ? m.ts : "";
|
|
21
|
+
st.range.setAttribute("aria-valuetext", m ? `record ${m.seq}, ${st.c.localTime(m.ts)}` : "empty");
|
|
22
|
+
st.play.disabled = !st.marks.length;
|
|
23
|
+
st.range.disabled = !st.marks.length;
|
|
24
|
+
st.prev.disabled = st.idx <= 0;
|
|
25
|
+
st.next.disabled = st.idx >= st.marks.length - 1;
|
|
26
|
+
}
|
|
27
|
+
function lanes(c, d) {
|
|
28
|
+
return c.h("div", {class: "rp-lanes"}, LANES.map(([id, name]) => {
|
|
29
|
+
const cards = (d.cards || []).filter(x => x.lane === id);
|
|
30
|
+
return c.h("section", {class: "rp-lane", "data-lane": id, "aria-label": name},
|
|
31
|
+
c.h("h3", null, name, c.h("span", {class: "count", text: String((d.counts || {})[id] || 0)})),
|
|
32
|
+
c.h("div", {class: "rp-chips"}, cards.length ? cards.map(x => c.h("span", {class: "rp-chip" + (x.owner ? " " + c.agentClass(x.owner) : ""), "data-id": x.id, title: x.title},
|
|
33
|
+
x.id, x.owner ? c.h("small", {text: " " + x.owner}) : null)) : c.h("span", {class: "cls", text: "none"})));
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
function show(c, d, map) {
|
|
37
|
+
st.cur = d;
|
|
38
|
+
const recent = (d.recent || []).map(m => c.h("li", {class: "k-" + m.kind}, c.h("b", {text: `#${m.seq} ${m.agent || "?"}`}), ` ${m.kind}${m.task ? " on " + m.task : ""}${m.source && m.source !== "board" ? " (" + m.source + ")" : ""}: `, m.text || ""));
|
|
39
|
+
const ev = d.events || {};
|
|
40
|
+
const notes = [];
|
|
41
|
+
if (d.untracked) notes.push(`${d.untracked} cards have no creation record in the transcript and appear once they are first mentioned.`);
|
|
42
|
+
if (d.truncated) notes.push(`Only the newest ${st.marks.length} records are on the slider; older slider entries are omitted.`);
|
|
43
|
+
if (d.incomplete_history) notes.push(`Incomplete history: reconstruction stops at ${d.at}; true latest sequence is ${d.last}.`);
|
|
44
|
+
if (d.non_core_records) notes.push("Non-core or unknown records were skipped.");
|
|
45
|
+
if (map && map.error) notes.push("Incomplete territory: " + map.error);
|
|
46
|
+
const mapDef = c.def("map");
|
|
47
|
+
c.setKids(st.body, [
|
|
48
|
+
c.h("p", {class: "cls rp-sum", text: `${d.total} records · lock denials ${ev.lock_denied || 0} · write violations ${ev.lock_violation || 0} so far`}),
|
|
49
|
+
lanes(c, d),
|
|
50
|
+
c.h("h3", {text: "Locks at this point"}),
|
|
51
|
+
map && mapDef ? mapDef.legend(c, map) : null,
|
|
52
|
+
map && mapDef ? c.h("div", {class: "zones"}, mapDef.tiles(c, map)) : null,
|
|
53
|
+
c.h("h3", {text: "Records up to this point"}),
|
|
54
|
+
recent.length ? c.h("ul", {class: "rp-recent"}, recent) : c.h("p", {class: "cls", text: "Nothing yet."}),
|
|
55
|
+
notes.length ? c.h("p", {class: "note-sm", text: notes.join(" ")}) : null]);
|
|
56
|
+
}
|
|
57
|
+
async function load(c) {
|
|
58
|
+
const m = st.marks[st.idx];
|
|
59
|
+
if (!m || inFlight) return;
|
|
60
|
+
inFlight = true;
|
|
61
|
+
const owner = st;
|
|
62
|
+
const token = ++st.token;
|
|
63
|
+
try {
|
|
64
|
+
const d = await c.api("replay", `?at=${m.seq}&marks=0`);
|
|
65
|
+
const map = d && d.territory;
|
|
66
|
+
if (st === owner && token === st.token && d) show(c, d, map);
|
|
67
|
+
} catch (_) {} finally {
|
|
68
|
+
inFlight = false;
|
|
69
|
+
if (st === owner && st.marks[st.idx] !== m) load(c);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function scrub(c, i, now) {
|
|
73
|
+
st.idx = Math.max(0, Math.min(st.marks.length - 1, i));
|
|
74
|
+
st.range.value = String(st.idx);
|
|
75
|
+
label();
|
|
76
|
+
clearTimeout(st.debounce);
|
|
77
|
+
if (now) load(c); else st.debounce = setTimeout(() => load(c), 120);
|
|
78
|
+
}
|
|
79
|
+
function build(c, d, pane) {
|
|
80
|
+
stop();
|
|
81
|
+
st = {ws: c.ws, marks: d.marks || [], idx: Math.max(0, (d.marks || []).length - 1), timer: 0, token: 0, debounce: 0};
|
|
82
|
+
const max = Math.max(0, st.marks.length - 1);
|
|
83
|
+
st.range = c.h("input", {type: "range", min: "0", max: String(max), step: "1", value: String(st.idx), id: "replayRange", "aria-label": "Position in the transcript"});
|
|
84
|
+
st.prev = c.h("button", {type: "button", class: "tog", text: "Previous", "aria-label": "Previous record"});
|
|
85
|
+
st.next = c.h("button", {type: "button", class: "tog", text: "Next", "aria-label": "Next record"});
|
|
86
|
+
st.play = c.h("button", {type: "button", class: "tog", "aria-pressed": "false", text: "Play"});
|
|
87
|
+
st.live = c.h("button", {type: "button", class: "tog", text: "Refresh"});
|
|
88
|
+
st.out = c.h("output", {class: "rp-out", for: "replayRange"});
|
|
89
|
+
st.body = c.h("div", {class: "rp-body"});
|
|
90
|
+
st.range.addEventListener("input", () => { stop(); scrub(st.c, +st.range.value, false); });
|
|
91
|
+
st.prev.addEventListener("click", () => { stop(); scrub(st.c, st.idx - 1, true); });
|
|
92
|
+
st.next.addEventListener("click", () => { stop(); scrub(st.c, st.idx + 1, true); });
|
|
93
|
+
st.live.addEventListener("click", () => { stop(); st.ws = null; st.c.reload(); });
|
|
94
|
+
st.play.addEventListener("click", () => {
|
|
95
|
+
if (st.timer) { stop(); return; }
|
|
96
|
+
if (st.idx >= st.marks.length - 1) scrub(st.c, 0, true);
|
|
97
|
+
st.play.setAttribute("aria-pressed", "true");
|
|
98
|
+
st.play.textContent = "Pause";
|
|
99
|
+
st.timer = setInterval(() => {
|
|
100
|
+
if (inFlight) return;
|
|
101
|
+
if (!st || st.idx >= st.marks.length - 1) { stop(); return; }
|
|
102
|
+
scrub(st.c, st.idx + 1, true);
|
|
103
|
+
}, STEP_MS);
|
|
104
|
+
});
|
|
105
|
+
pane.replaceChildren(
|
|
106
|
+
c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Chronicle" : "Timeline", c.raid && c.raidSub ? c.raidSub("Timeline") : null),
|
|
107
|
+
c.h("p", {class: "cls", text: "The board rebuilt from the transcript at any point. Transcript records are not signed, so treat this as unverified history; lock paths use today's repo layout."})),
|
|
108
|
+
c.h("div", {class: "rp-controls"}, st.prev, st.range, st.next, st.play, st.live),
|
|
109
|
+
st.out, st.body);
|
|
110
|
+
st.c = c;
|
|
111
|
+
label();
|
|
112
|
+
show(c, d, d.territory);
|
|
113
|
+
if (st.marks.length) load(c);
|
|
114
|
+
}
|
|
115
|
+
views.push({
|
|
116
|
+
id: "replay",
|
|
117
|
+
endpoint: "replay",
|
|
118
|
+
label: {raid: "Chronicle", plain: "Timeline"},
|
|
119
|
+
poll: false,
|
|
120
|
+
leave() { stop(); },
|
|
121
|
+
render(c, d, pane) {
|
|
122
|
+
if (c.fresh || !st || st.ws !== c.ws || !pane.contains(st.range)) { build(c, d, pane); return; }
|
|
123
|
+
st.c = c;
|
|
124
|
+
if (st.cur) load(c);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
})();
|