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
raidlock/web/export.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from html import escape
|
|
2
|
+
|
|
3
|
+
LANES = (
|
|
4
|
+
("review", "Review"),
|
|
5
|
+
("blocked", "Blocked"),
|
|
6
|
+
("doing", "In progress"),
|
|
7
|
+
("todo", "To do"),
|
|
8
|
+
("done", "Done"),
|
|
9
|
+
("cancelled", "Cancelled"),
|
|
10
|
+
)
|
|
11
|
+
STYLE = (
|
|
12
|
+
"body{font:14px/1.45 system-ui,sans-serif;margin:0 auto;max-width:1100px;padding:16px;"
|
|
13
|
+
"color:#1d1d24;background:#fafafa}"
|
|
14
|
+
"h1{font-size:22px}h2{font-size:16px;margin-top:24px}table{border-collapse:collapse;width:100%}"
|
|
15
|
+
"td,th{border-bottom:1px solid #ddd;padding:4px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}"
|
|
16
|
+
".m{color:#666;font-size:12px}.wrap{overflow-x:auto}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _usd(v):
|
|
21
|
+
return "-" if v is None else f"${float(v):.2f}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _rows(items, cols):
|
|
25
|
+
head = "".join(f"<th>{escape(c)}</th>" for c, _ in cols)
|
|
26
|
+
body = "".join("<tr>" + "".join(f"<td>{escape(str(f(i)))}</td>" for _, f in cols) + "</tr>" for i in items)
|
|
27
|
+
return f'<div class="wrap"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _trust(agent):
|
|
31
|
+
inputs = agent.get("reputation_inputs") or {}
|
|
32
|
+
measured = any(v for k, v in inputs.items() if not k.endswith("_rate") and isinstance(v, (int, float)))
|
|
33
|
+
return agent.get("reputation") if measured and agent.get("reputation") is not None else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _billing(agent):
|
|
37
|
+
mode = agent.get("billing") or ""
|
|
38
|
+
return "" if mode == "unknown" else mode
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _known(value):
|
|
42
|
+
return "not reported" if value in (None, "", "unknown") else value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _usage(p):
|
|
46
|
+
parts = []
|
|
47
|
+
ctx = p.get("context") or {}
|
|
48
|
+
if ctx.get("used_percent") is not None:
|
|
49
|
+
parts.append(f"context {ctx['used_percent']:g}%")
|
|
50
|
+
parts += [f"{w.get('window') or 'window'} {w.get('used_percent')}%" for w in p.get("windows") or []]
|
|
51
|
+
return "; ".join(parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render(payload, records):
|
|
55
|
+
ws = payload.get("workspace", "")
|
|
56
|
+
out = [
|
|
57
|
+
f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
58
|
+
f'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
59
|
+
f"<title>raidlock snapshot: {escape(ws)}</title><style>{STYLE}</style></head><body>",
|
|
60
|
+
f'<h1>{escape(ws)}</h1><p class="m">Snapshot taken {escape(payload.get("now", ""))}. '
|
|
61
|
+
f"Secrets and local paths are redacted.</p>",
|
|
62
|
+
]
|
|
63
|
+
total = payload.get("treasury", {}).get("total", {})
|
|
64
|
+
out.append(
|
|
65
|
+
f"<p>Spend {_usd(total.get('usd'))} over {total.get('tokens', 0)} tokens in {total.get('runs', 0)} runs.</p>"
|
|
66
|
+
)
|
|
67
|
+
out.append("<h2>Agents</h2>")
|
|
68
|
+
out.append(
|
|
69
|
+
_rows(
|
|
70
|
+
sorted(payload.get("agents", {}).items()),
|
|
71
|
+
[
|
|
72
|
+
("Agent", lambda a: a[0]),
|
|
73
|
+
("Status", lambda a: a[1].get("label", "")),
|
|
74
|
+
("Card", lambda a: a[1].get("card", "")),
|
|
75
|
+
("Model", lambda a: " · ".join(x for x in (a[1].get("model"), a[1].get("runtime")) if x)),
|
|
76
|
+
("Billing", lambda a: _billing(a[1])),
|
|
77
|
+
("Trust", lambda a: _trust(a[1])),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
limits = [p for p in (payload.get("limits") or {}).get("providers", []) if _usage(p)]
|
|
82
|
+
if limits:
|
|
83
|
+
out.append("<h2>Provider usage</h2>")
|
|
84
|
+
out.append(
|
|
85
|
+
_rows(
|
|
86
|
+
limits,
|
|
87
|
+
[
|
|
88
|
+
("Provider", lambda p: p.get("provider", "")),
|
|
89
|
+
("Plan", lambda p: p.get("plan") or ""),
|
|
90
|
+
("Usage", _usage),
|
|
91
|
+
("Updated", lambda p: p.get("observed_at") or ""),
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
cards = payload.get("cards", [])
|
|
96
|
+
for lane, label in LANES:
|
|
97
|
+
mine = [c for c in cards if c.get("lane") == lane]
|
|
98
|
+
if not mine:
|
|
99
|
+
continue
|
|
100
|
+
out.append(f"<h2>{escape(label)} ({len(mine)})</h2>")
|
|
101
|
+
out.append(
|
|
102
|
+
_rows(
|
|
103
|
+
mine,
|
|
104
|
+
[
|
|
105
|
+
("Card", lambda c: c["id"]),
|
|
106
|
+
("Title", lambda c: c.get("title", "")),
|
|
107
|
+
("Owner", lambda c: c.get("owner", "")),
|
|
108
|
+
("Team", lambda c: c.get("team", "")),
|
|
109
|
+
("Cost", lambda c: _usd((c.get("cost") or {}).get("usd"))),
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
out.append("<h2>By provider and model</h2>")
|
|
114
|
+
out.append(
|
|
115
|
+
_rows(
|
|
116
|
+
payload.get("scorecard", []),
|
|
117
|
+
[
|
|
118
|
+
("Provider", lambda g: _known(g["provider"])),
|
|
119
|
+
("Model", lambda g: _known(g["label"])),
|
|
120
|
+
("Done", lambda g: g["done"]),
|
|
121
|
+
("Review pass", lambda g: "-" if g["pass_rate"] is None else f"{round(g['pass_rate'] * 100)}%"),
|
|
122
|
+
("Cost", lambda g: _usd(g["usd"])),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
if payload.get("model_reputation"):
|
|
127
|
+
out.append("<h2>Model trust scores</h2>")
|
|
128
|
+
out.append(
|
|
129
|
+
_rows(
|
|
130
|
+
payload["model_reputation"],
|
|
131
|
+
[
|
|
132
|
+
("Workspace", lambda r: r["workspace"]),
|
|
133
|
+
("Model", lambda r: _known(r.get("label", r["key"]))),
|
|
134
|
+
("Score / 100", lambda r: r["score"]),
|
|
135
|
+
("Inputs", lambda r: r["inputs"]),
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
out.append("<h2>Recent activity</h2>")
|
|
140
|
+
out.append(
|
|
141
|
+
_rows(
|
|
142
|
+
list(reversed(records)),
|
|
143
|
+
[
|
|
144
|
+
("Time", lambda r: r["ts"]),
|
|
145
|
+
("From", lambda r: r["from"]["agent"]),
|
|
146
|
+
("Kind", lambda r: r["kind"]),
|
|
147
|
+
("Card", lambda r: r["task"]),
|
|
148
|
+
("Text", lambda r: r["text"][:500]),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
out.append("</body></html>")
|
|
153
|
+
return "".join(out)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.frames{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:10px}
|
|
2
|
+
.frame{position:relative;background:var(--panel);border:1px solid var(--edge);border-left:5px solid var(--agc,var(--edge));border-radius:12px;padding:8px 10px;min-width:0;display:flex;flex-direction:column;gap:6px}
|
|
3
|
+
.frame.lvl-warning{border-color:var(--s-stalled)}
|
|
4
|
+
.frame.lvl-critical{border-color:var(--blocked)}
|
|
5
|
+
.fhead{display:flex;gap:8px;align-items:center;min-width:0}
|
|
6
|
+
.fhead .av{flex:none;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:var(--pill);border:1px solid var(--agc,var(--edge));font-size:14px}
|
|
7
|
+
.fname{display:flex;flex-direction:column;min-width:0;overflow-wrap:anywhere;font-size:14px}
|
|
8
|
+
.fname .cls{font-size:12px}
|
|
9
|
+
.fwho{display:flex;flex-wrap:wrap;gap:4px 6px;align-items:center;min-width:0}
|
|
10
|
+
.prole{flex:none;padding:1px 8px;border-radius:999px;border:1px solid var(--edge);border-left-width:4px;font-size:12px;font-weight:700;color:var(--ink);background:var(--pill)}
|
|
11
|
+
.prole-tank{border-color:var(--todo)}
|
|
12
|
+
.prole-healer{border-color:var(--done)}
|
|
13
|
+
.prole-dps{border-color:var(--blocked)}
|
|
14
|
+
.fbar{display:grid;grid-template-columns:minmax(0,7.5rem) minmax(0,1fr);gap:2px 8px;align-items:center;font-size:14px}
|
|
15
|
+
.fbar .fl{color:var(--muted)}
|
|
16
|
+
.fbar .fv{grid-column:1/-1;font-size:12px;color:var(--muted);overflow-wrap:anywhere}
|
|
17
|
+
.fbar .track{height:12px;border-radius:6px;background:var(--pill);border:1px solid var(--edge);overflow:hidden}
|
|
18
|
+
.fbar .track i{display:block;height:100%;transform-origin:left center}
|
|
19
|
+
.fbar.hp .track i{background:var(--done)}
|
|
20
|
+
.fbar.hp.lvl-warning .track i{background:var(--s-stalled)}
|
|
21
|
+
.fbar.hp.lvl-critical .track i{background:var(--blocked)}
|
|
22
|
+
.fbar.mp .track i{background:var(--todo)}
|
|
23
|
+
.handoff{margin:0;font-size:14px;padding:6px 8px;border-radius:8px;border:1px solid var(--s-stalled)}
|
|
24
|
+
.handoff.lvl-critical{border-color:var(--blocked)}
|
|
25
|
+
.handoff-sum{font-size:14px;padding:6px 10px;border:1px solid var(--blocked);border-radius:8px;margin:6px 0 10px}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
const ROLES = {tank: ["Tank", "holds the most leases and the hardest quests"], healer: ["Healer", "reviews, verifies and lifts curses for others"],
|
|
5
|
+
dps: ["DPS", "closes the most quests"]};
|
|
6
|
+
|
|
7
|
+
function role(c, f, raid) {
|
|
8
|
+
const r = raid && ROLES[f.party];
|
|
9
|
+
return r ? c.h("span", {class: `prole prole-${f.party}`, title: `${r[0]}: ${r[1]}`}, c.h("span", {class: "sr-only", text: "Party role: "}), r[0]) : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function bar(c, kind, pct, label, valuetext, level) {
|
|
13
|
+
const fill = c.h("i");
|
|
14
|
+
fill.classList.add("width-" + Math.round(Math.max(0, Math.min(100, pct || 0))));
|
|
15
|
+
return c.h("div", {class: `fbar ${kind} lvl-${level || "ok"}`},
|
|
16
|
+
c.h("span", {class: "fl", text: label}),
|
|
17
|
+
c.h("div", {class: "track", role: "meter", "aria-label": label, "aria-valuemin": "0", "aria-valuemax": "100",
|
|
18
|
+
"aria-valuenow": String(Math.round(Math.max(0, Math.min(100, pct || 0)))), "aria-valuetext": valuetext}, fill),
|
|
19
|
+
c.h("span", {class: "fv", text: valuetext}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function frame(c, f, d, raid) {
|
|
23
|
+
const ctx = f.context, burn = f.burn;
|
|
24
|
+
const pct = ctx.pct;
|
|
25
|
+
const pctText = pct == null ? "not measured" : pct > 100 ? "over 100%" : `${Math.round(pct)}%`;
|
|
26
|
+
const tags = [];
|
|
27
|
+
if (ctx.source === "estimate") tags.push("estimate");
|
|
28
|
+
if (ctx.window_source !== "pricing") tags.push("assumed window");
|
|
29
|
+
const ctxText = `${pctText} of ${c.ktok(ctx.window)}${tags.length ? " (" + tags.join(", ") + ")" : ""}`;
|
|
30
|
+
const rate = burn.tokens_per_hour ? `${c.ktok(burn.tokens_per_hour)} tok/h · ${c.usd(burn.usd_per_hour)}/h` : "no timed runs yet";
|
|
31
|
+
const warn = f.handoff ? c.h("p", {class: `handoff lvl-${ctx.level}`, role: "status"},
|
|
32
|
+
c.h("b", {text: ctx.level === "critical" ? "Hand off now: " : "Hand off soon: "}),
|
|
33
|
+
`context is ${pctText} full. Write a handback note and start a fresh session.`) : null;
|
|
34
|
+
return c.h("div", {class: `frame ${c.agentClass(f.agent)} lvl-${ctx.level}`, "data-agent": f.agent},
|
|
35
|
+
c.h("div", {class: "fhead"}, c.h("span", {class: "av", "aria-hidden": "true", text: c.avatar(f.agent)}),
|
|
36
|
+
c.h("div", {class: "fname"}, c.h("span", {class: "fwho"}, c.h("b", {text: f.agent}), role(c, f, raid)),
|
|
37
|
+
c.h("span", {class: "cls", text: [c.modelName(f.model), f.status, f.card].filter(Boolean).join(" · ")}))),
|
|
38
|
+
bar(c, "hp", pct, "Context", ctxText, ctx.level),
|
|
39
|
+
bar(c, "mp", 100 * (burn.share || 0), "Burn rate", rate, "ok"),
|
|
40
|
+
ctx.source === "estimate" ? c.h("p", {class: "note-sm", text: "No recorded run yet: estimated from the transcript and card brief at about 4 characters per token."}) : null,
|
|
41
|
+
warn);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
views.push({
|
|
45
|
+
id: "frames",
|
|
46
|
+
endpoint: "frames",
|
|
47
|
+
label: {raid: "Raid frames", plain: "Context"},
|
|
48
|
+
render(c, d, pane) {
|
|
49
|
+
const raid = c.theme === "raid";
|
|
50
|
+
const all = (d.agents || []).slice().sort((a, b) => (b.handoff - a.handoff) || ((b.context.pct || 0) - (a.context.pct || 0)) || a.agent.localeCompare(b.agent));
|
|
51
|
+
const list = all.filter(f => f.card || (f.status && f.status !== "idle") || f.handoff);
|
|
52
|
+
const idle = all.filter(f => !list.includes(f));
|
|
53
|
+
c.setKids(pane, [
|
|
54
|
+
c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Raid frames" : "Context and burn rate", c.raid && c.raidSub ? c.raidSub("Context and burn rate") : null),
|
|
55
|
+
c.h("p", {class: "cls", text: `Context fill uses the prompt tokens of each agent's newest recorded run; burn rate is tokens per hour of run time. Warnings at ${d.warn_pct}% and ${d.critical_pct}%.`})),
|
|
56
|
+
(d.handoffs || []).length ? c.h("p", {class: "handoff-sum", role: "status", text: `${d.handoffs.length} ${d.handoffs.length === 1 ? "agent is" : "agents are"} near the context limit: ${d.handoffs.join(", ")}`}) : null,
|
|
57
|
+
list.length ? c.h("div", {class: "frames"}, list.map(f => frame(c, f, d, raid))) : c.h("p", {class: "empty", text: all.length ? "No agent holds a card right now." : "No agents joined yet. A worker joins with raid up --join."}),
|
|
58
|
+
idle.length ? c.h("details", {class: "sub-fold"}, c.h("summary", {text: `${idle.length} idle ${idle.length === 1 ? "agent" : "agents"}`}),
|
|
59
|
+
c.h("div", {class: "frames"}, idle.map(f => frame(c, f, d, raid)))) : null],
|
|
60
|
+
JSON.stringify([d, c.theme]));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
})();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Card dependency graph and a lock-contention heatmap (lock path by day)."""
|
|
2
|
+
|
|
3
|
+
from raidlock.core.errors import RaidlockError
|
|
4
|
+
from raidlock.core.paths import overlaps
|
|
5
|
+
|
|
6
|
+
from ..raidmap import lock_events, territory
|
|
7
|
+
from ..replay import data_of, text_of
|
|
8
|
+
|
|
9
|
+
DAYS = 14
|
|
10
|
+
MAX_ROWS = 40
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_NODES = 5000
|
|
14
|
+
MAX_EDGES = 20000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphTooLarge(RaidlockError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def layers(cards):
|
|
22
|
+
if len(cards) > MAX_NODES or sum(len(c.get("depends", [])) for c in cards) > MAX_EDGES:
|
|
23
|
+
raise GraphTooLarge("graph too large")
|
|
24
|
+
ids = {c["id"] for c in cards}
|
|
25
|
+
deps = {c["id"]: [d for d in c.get("depends", []) if d in ids] for c in cards}
|
|
26
|
+
depth, cycles = {}, []
|
|
27
|
+
active = set()
|
|
28
|
+
for root in sorted(ids):
|
|
29
|
+
if root in depth:
|
|
30
|
+
continue
|
|
31
|
+
stack = [(root, iter(deps[root]))]
|
|
32
|
+
active.add(root)
|
|
33
|
+
while stack:
|
|
34
|
+
cid, children = stack[-1]
|
|
35
|
+
child = next(children, None)
|
|
36
|
+
if child is None:
|
|
37
|
+
depth[cid] = 1 + max((depth.get(d, -1) for d in deps[cid]), default=-1)
|
|
38
|
+
active.remove(cid)
|
|
39
|
+
stack.pop()
|
|
40
|
+
elif child in active:
|
|
41
|
+
trail = [node for node, _ in stack]
|
|
42
|
+
cycles.append(trail[trail.index(child) :])
|
|
43
|
+
elif child not in depth:
|
|
44
|
+
active.add(child)
|
|
45
|
+
stack.append((child, iter(deps[child])))
|
|
46
|
+
return depth, cycles
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dependency_graph(cards):
|
|
50
|
+
depth, cycles = layers(cards)
|
|
51
|
+
ids = {c["id"] for c in cards}
|
|
52
|
+
rows = {}
|
|
53
|
+
nodes = []
|
|
54
|
+
order = ("doing", "review", "blocked", "todo", "done", "cancelled")
|
|
55
|
+
|
|
56
|
+
def sort_key(c):
|
|
57
|
+
lane = c.get("lane", "todo")
|
|
58
|
+
return depth[c["id"]], order.index(lane) if lane in order else len(order), c["id"]
|
|
59
|
+
|
|
60
|
+
for c in sorted(cards, key=sort_key):
|
|
61
|
+
d = depth[c["id"]]
|
|
62
|
+
rows[d] = rows.get(d, -1) + 1
|
|
63
|
+
nodes.append(
|
|
64
|
+
{
|
|
65
|
+
"id": c["id"],
|
|
66
|
+
"title": c.get("title", ""),
|
|
67
|
+
"lane": c.get("lane", ""),
|
|
68
|
+
"owner": c.get("owner", ""),
|
|
69
|
+
"team": c.get("team", ""),
|
|
70
|
+
"repos": c.get("repos") or sorted({p.split("/")[0] for p in c.get("locks", [])}),
|
|
71
|
+
"depth": d,
|
|
72
|
+
"row": rows[d],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
edges = []
|
|
76
|
+
for c in cards:
|
|
77
|
+
for dep in c.get("depends", []):
|
|
78
|
+
edges.append({"from": dep, "to": c["id"], "missing": dep not in ids})
|
|
79
|
+
return {"nodes": nodes, "edges": edges, "cycles": cycles, "columns": 1 + max(depth.values(), default=-1)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def heatmap(board, cards):
|
|
83
|
+
by_id = {c["id"]: c for c in cards}
|
|
84
|
+
hits = {}
|
|
85
|
+
takeovers = []
|
|
86
|
+
for r in board.transcript.read(kind="status", limit=5000):
|
|
87
|
+
if "taken over from" in text_of(r) and data_of(r).get("a2a_state") == "working":
|
|
88
|
+
takeovers.append({"ts": r.get("ts", ""), "paths": by_id.get(r.get("taskId", ""), {}).get("locks", [])})
|
|
89
|
+
events = [{"ts": e["ts"], "paths": e["paths"], "event": e["event"]} for e in lock_events(board)]
|
|
90
|
+
events += [{"ts": t["ts"], "paths": t["paths"], "event": "takeover"} for t in takeovers]
|
|
91
|
+
locked = {p for c in cards for p in c.get("locks", [])}
|
|
92
|
+
loose = {p for e in events for p in e["paths"] if not any(overlaps(p, q) for q in locked)}
|
|
93
|
+
paths = sorted(locked | loose)
|
|
94
|
+
days = sorted({e["ts"][:10] for e in events if e["ts"]})[-DAYS:]
|
|
95
|
+
for e in events:
|
|
96
|
+
day = e["ts"][:10]
|
|
97
|
+
if day not in days:
|
|
98
|
+
continue
|
|
99
|
+
for p in paths:
|
|
100
|
+
if any(overlaps(p, q) for q in e["paths"]):
|
|
101
|
+
row = hits.setdefault(p, {})
|
|
102
|
+
cell = row.setdefault(day, {"lock_denied": 0, "lock_violation": 0, "takeover": 0})
|
|
103
|
+
cell[e["event"]] += 1
|
|
104
|
+
waiting = {e["path"]: len(e["waiting"]) for repo in territory(board)["repos"] for e in repo["paths"]}
|
|
105
|
+
rows = []
|
|
106
|
+
for p in paths:
|
|
107
|
+
cells = [hits.get(p, {}).get(d, {"lock_denied": 0, "lock_violation": 0, "takeover": 0}) for d in days]
|
|
108
|
+
total = sum(sum(c.values()) for c in cells)
|
|
109
|
+
if not total and not waiting.get(p):
|
|
110
|
+
continue
|
|
111
|
+
rows.append({"path": p, "cells": cells, "total": total, "waiting": waiting.get(p, 0)})
|
|
112
|
+
rows.sort(key=lambda r: (-(r["total"] + r["waiting"]), r["path"]))
|
|
113
|
+
peak = max([sum(c.values()) for r in rows for c in r["cells"]] + [0])
|
|
114
|
+
return {"days": days, "rows": rows[:MAX_ROWS], "peak": peak, "paths": len(paths)}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build(board):
|
|
118
|
+
cards = board.cards()
|
|
119
|
+
return {**dependency_graph(cards), "heat": heatmap(board, cards)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def payload(board, query):
|
|
123
|
+
return build(board)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _handle(ctx, args):
|
|
127
|
+
result = build(ctx.board)
|
|
128
|
+
lines = [f"{len(result['nodes'])} cards, {len(result['edges'])} dependencies"]
|
|
129
|
+
for e in result["edges"]:
|
|
130
|
+
lines.append(f" {e['from']} -> {e['to']}" + (" (missing)" if e["missing"] else ""))
|
|
131
|
+
for c in result["cycles"]:
|
|
132
|
+
lines.append(" cycle: " + " -> ".join(c))
|
|
133
|
+
heat = result["heat"]
|
|
134
|
+
if heat["rows"]:
|
|
135
|
+
lines.append("Contention:")
|
|
136
|
+
for r in heat["rows"]:
|
|
137
|
+
lines.append(f" {r['path']}: {r['total']} events, {r['waiting']} waiting")
|
|
138
|
+
return result, "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def register_cli(add_command):
|
|
142
|
+
add_command("graph", "card dependency graph and lock-contention heatmap", None, _handle)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#viewPane>h3{font-size:14px;color:var(--accent);margin:14px 0 4px}
|
|
2
|
+
.dep-svg .edge{fill:none;stroke:var(--muted);stroke-width:1.6}
|
|
3
|
+
.dep-svg .arrow{fill:var(--muted)}
|
|
4
|
+
.dep-svg .gnode rect{fill:var(--card1);stroke:var(--lc,var(--edge));stroke-width:2}
|
|
5
|
+
.dep-svg .gid{fill:var(--ink);font-size:14px;font-weight:bold;font-family:var(--font)}
|
|
6
|
+
.dep-svg .gsub{fill:var(--muted);font-size:12px;font-family:var(--font)}
|
|
7
|
+
.gnode.lane-todo,.lane-sw.lane-todo{--lc:var(--todo)}.gnode.lane-doing,.lane-sw.lane-doing{--lc:var(--doing)}.gnode.lane-review,.lane-sw.lane-review{--lc:var(--review)}
|
|
8
|
+
.gnode.lane-blocked,.lane-sw.lane-blocked{--lc:var(--blocked)}.gnode.lane-done,.lane-sw.lane-done{--lc:var(--done)}
|
|
9
|
+
#viewPane .sw.lane-sw{background:var(--lc)}
|
|
10
|
+
.heat-svg .hday,.heat-svg .hpath{fill:var(--muted);font-size:12px;font-family:var(--font)}
|
|
11
|
+
.heat-svg .hpath{fill:var(--ink)}
|
|
12
|
+
.heat-svg .hnum{fill:var(--ink);font-size:14px;font-weight:bold;font-family:var(--font);pointer-events:none}
|
|
13
|
+
.heat-svg .hcell{stroke:var(--edge);fill:var(--card2)}
|
|
14
|
+
.heat-svg .heat-1{fill:color-mix(in srgb,var(--blocked) 25%,var(--card2))}
|
|
15
|
+
.heat-svg .heat-2{fill:color-mix(in srgb,var(--blocked) 45%,var(--card2))}
|
|
16
|
+
.heat-svg .heat-3{fill:color-mix(in srgb,var(--blocked) 65%,var(--card2))}
|
|
17
|
+
.heat-svg .heat-4{fill:color-mix(in srgb,var(--blocked) 85%,var(--card2))}
|
|
18
|
+
.heat-svg .wait-1{fill:color-mix(in srgb,var(--s-stalled) 30%,var(--card2))}
|
|
19
|
+
.heat-svg .wait-2{fill:color-mix(in srgb,var(--s-stalled) 55%,var(--card2))}
|
|
20
|
+
.heat-svg .wait-3{fill:color-mix(in srgb,var(--s-stalled) 80%,var(--card2))}
|
|
21
|
+
.as-text{margin-top:8px;font-size:14px}
|
|
22
|
+
.as-text summary{cursor:pointer;color:var(--accent)}
|
|
23
|
+
.as-text .warn{color:var(--blocked)}
|
|
24
|
+
|
|
25
|
+
#viewPane .scrollbox{box-sizing:border-box;width:100%;min-width:0;max-width:100%}
|
|
26
|
+
#viewPane .dep-scroll{max-height:70vh;overflow:auto;overscroll-behavior:contain}
|
|
27
|
+
.dep-svg .gtitle{fill:var(--ink);font-size:14px;font-family:var(--font)}
|
|
28
|
+
.dep-svg .gnode{cursor:pointer;text-decoration:none}
|
|
29
|
+
.dep-svg .gnode:focus rect{stroke:var(--accent);stroke-width:4}
|
|
30
|
+
.dep-svg .lane-missing rect,.dep-svg .missing{stroke-dasharray:5 3}
|
|
31
|
+
.dep-svg .lane-cancelled rect{stroke:var(--muted)}
|
|
32
|
+
#viewPane .as-text{overflow-wrap:anywhere}
|
|
33
|
+
@media(prefers-reduced-motion:reduce){.dep-svg *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
const NW = 224, NH = 66, CG = 56, RG = 12, PAD = 12, LW = 230, CELL = 36, HEAD = 34;
|
|
5
|
+
|
|
6
|
+
function clip(t, n) { t = String(t || ""); return t.length > n ? t.slice(0, n - 1) + "…" : t; }
|
|
7
|
+
|
|
8
|
+
function depGraph(c, d) {
|
|
9
|
+
const nodes = [...(d.nodes || [])];
|
|
10
|
+
if (!nodes.length) return c.h("p", {class: "empty", text: "No cards yet. Add a card to see its dependencies."});
|
|
11
|
+
const known = new Set(nodes.map(n => n.id));
|
|
12
|
+
for (const e of d.edges || []) for (const id of [e.from, e.to]) {
|
|
13
|
+
if (!known.has(id)) {
|
|
14
|
+
known.add(id);
|
|
15
|
+
nodes.push({id, title: "Missing card", lane: "missing", depth: 0, missing: true});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const linked = new Set([...(d.edges || []).flatMap(e => [e.from, e.to]), ...(d.cycles || []).flat()]);
|
|
19
|
+
const drawn = nodes.filter(n => linked.has(n.id));
|
|
20
|
+
const loose = nodes.length - drawn.length;
|
|
21
|
+
const looseNote = loose ? c.h("p", {class: "cls", text: `${loose} ${loose === 1 ? "card has" : "cards have"} no dependencies; they are listed under Dependencies as text.`}) : null;
|
|
22
|
+
if (!drawn.length) return c.h("div", null, c.h("p", {class: "empty", text: c.raid ? "No quest chains yet. Link quests with raid edit <card> --depends <other>." : "No dependencies between cards yet. Link cards with raid edit <card> --depends <other>."}), looseNote);
|
|
23
|
+
const at = new Map(), rowAt = {};
|
|
24
|
+
let rows = 0;
|
|
25
|
+
for (const n of drawn) {
|
|
26
|
+
const col = Math.max(0, n.depth || 0), row = rowAt[col] = (rowAt[col] ?? -1) + 1;
|
|
27
|
+
rows = Math.max(rows, row + 1);
|
|
28
|
+
at.set(n.id, [PAD + col * (NW + CG), PAD + row * (NH + RG)]);
|
|
29
|
+
}
|
|
30
|
+
const width = PAD * 2 + Math.max(...drawn.map(n => Math.max(0, n.depth || 0))) * (NW + CG) + NW + CG;
|
|
31
|
+
const height = PAD * 2 + rows * (NH + RG);
|
|
32
|
+
const s = c.svg;
|
|
33
|
+
const defs = s("defs", null, s("marker", {id: "rlArrow", viewBox: "0 0 8 8", refX: "8", refY: "4", markerWidth: "8", markerHeight: "8", orient: "auto"},
|
|
34
|
+
s("path", {d: "M0,0 L8,4 L0,8 z", class: "arrow"})));
|
|
35
|
+
const edges = (d.edges || []).map(e => {
|
|
36
|
+
const [x1, y1] = at.get(e.from), [x2, y2] = at.get(e.to);
|
|
37
|
+
const start = x1 + NW, end = x2 > x1 ? x2 : x2 + NW;
|
|
38
|
+
const track = start + CG / 2;
|
|
39
|
+
const path = e.from === e.to
|
|
40
|
+
? `M${start},${y1 + 18} h20 v30 h-20`
|
|
41
|
+
: `M${start},${y1 + NH / 2} H${track} V${y2 + NH / 2} H${end}`;
|
|
42
|
+
return s("path", {class: `edge${e.missing ? " missing" : ""}`, "data-from": e.from, "data-to": e.to, d: path, "marker-end": "url(#rlArrow)"},
|
|
43
|
+
s("title", {text: `${e.from} → ${e.to}${e.missing ? " (missing card)" : ""}`}));
|
|
44
|
+
});
|
|
45
|
+
const boxes = drawn.map(n => {
|
|
46
|
+
const [x, y] = at.get(n.id);
|
|
47
|
+
const label = `${n.id}: ${n.title} (${n.lane}${n.owner ? ", " + n.owner : ""})`;
|
|
48
|
+
const box = s(n.missing ? "g" : "a", {class: `gnode lane-${n.lane}`, "data-id": n.id,
|
|
49
|
+
"data-card": n.missing ? null : n.id, href: n.missing ? null : `#card-${encodeURIComponent(n.id)}`,
|
|
50
|
+
tabindex: n.missing ? null : "0", "aria-label": label, transform: `translate(${x},${y})`},
|
|
51
|
+
s("title", {text: label}),
|
|
52
|
+
s("rect", {width: NW, height: NH, rx: "8"}),
|
|
53
|
+
s("text", {x: "10", y: "18", class: "gid", text: clip(n.id, 27)}),
|
|
54
|
+
s("text", {x: "10", y: "37", class: "gtitle", text: clip(n.title, 30)}),
|
|
55
|
+
s("text", {x: "10", y: "55", class: "gsub", text: clip(`${n.lane}${n.owner ? " · " + n.owner : ""}`, 30)}));
|
|
56
|
+
if (!n.missing) box.addEventListener("keydown", e => {
|
|
57
|
+
if (e.key === "Enter") { e.preventDefault(); box.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true})); }
|
|
58
|
+
});
|
|
59
|
+
return box;
|
|
60
|
+
});
|
|
61
|
+
return c.h("div", null, looseNote, c.h("div", {class: "scrollbox dep-scroll", tabindex: "0", role: "region", "aria-label": "Dependency graph, scrollable"},
|
|
62
|
+
s("svg", {width, height, viewBox: `0 0 ${width} ${height}`, role: "group", "aria-label": "Dependencies: arrows point to waiting cards", class: "dep-svg"}, defs, edges, boxes)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function heat(c, d) {
|
|
66
|
+
const hm = d.heat || {};
|
|
67
|
+
const rows = hm.rows || [], days = hm.days || [];
|
|
68
|
+
if (!rows.length) return c.h("p", {class: "empty", text: "No contention recorded: no lock denials, write violations, takeovers or waiting cards."});
|
|
69
|
+
const s = c.svg;
|
|
70
|
+
const cols = days.length + 1;
|
|
71
|
+
const width = LW + cols * CELL + PAD, height = HEAD + rows.length * CELL + PAD;
|
|
72
|
+
const peak = Math.max(1, hm.peak || 0);
|
|
73
|
+
const kids = [];
|
|
74
|
+
days.forEach((day, i) => kids.push(s("text", {class: "hday", x: LW + i * CELL + CELL / 2, y: HEAD - 10, "text-anchor": "middle", text: day.slice(5)})));
|
|
75
|
+
kids.push(s("text", {class: "hday", x: LW + days.length * CELL + CELL / 2, y: HEAD - 10, "text-anchor": "middle", text: "wait"}));
|
|
76
|
+
rows.forEach((r, j) => {
|
|
77
|
+
const y = HEAD + j * CELL;
|
|
78
|
+
kids.push(s("text", {class: "hpath", x: LW - 8, y: y + CELL / 2 + 4, "text-anchor": "end", text: clip(r.path, 30)}, s("title", {text: r.path})));
|
|
79
|
+
r.cells.forEach((cell, i) => {
|
|
80
|
+
const n = cell.lock_denied + cell.lock_violation + cell.takeover;
|
|
81
|
+
const lvl = n ? Math.min(4, 1 + Math.floor(3 * n / peak)) : 0;
|
|
82
|
+
kids.push(s("rect", {class: `hcell heat-${lvl}`, x: LW + i * CELL + 2, y: y + 2, width: CELL - 4, height: CELL - 4, rx: "4", "data-path": r.path, "data-day": days[i]},
|
|
83
|
+
s("title", {text: `${r.path} on ${days[i]}: ${cell.lock_denied} denied, ${cell.lock_violation} write violations, ${cell.takeover} takeovers`})));
|
|
84
|
+
if (n) kids.push(s("text", {class: "hnum", x: LW + i * CELL + CELL / 2, y: y + CELL / 2 + 4, "text-anchor": "middle", text: String(n)}));
|
|
85
|
+
});
|
|
86
|
+
const w = r.waiting;
|
|
87
|
+
kids.push(s("rect", {class: `hcell wait-${w ? Math.min(3, w) : 0}`, x: LW + days.length * CELL + 2, y: y + 2, width: CELL - 4, height: CELL - 4, rx: "4"},
|
|
88
|
+
s("title", {text: `${r.path}: ${w} cards waiting now`})));
|
|
89
|
+
if (w) kids.push(s("text", {class: "hnum", x: LW + days.length * CELL + CELL / 2, y: y + CELL / 2 + 4, "text-anchor": "middle", text: String(w)}));
|
|
90
|
+
});
|
|
91
|
+
return c.h("div", {class: "scrollbox", tabindex: "0", role: "region", "aria-label": "Lock contention heatmap, scrollable"},
|
|
92
|
+
s("svg", {width, height, viewBox: `0 0 ${width} ${height}`, role: "img", class: "heat-svg",
|
|
93
|
+
"aria-label": `Lock contention by path and day: ${rows.length} paths, busiest ${rows[0].path}`}, kids));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function asText(c, d) {
|
|
97
|
+
const titles = Object.fromEntries((d.nodes || []).map(n => [n.id, n.title]));
|
|
98
|
+
const name = id => titles[id] ? `${id}: ${titles[id]}` : `${id}: Missing card`;
|
|
99
|
+
const items = (d.edges || []).map(e => c.h("li", {text: `${name(e.from)} → ${name(e.to)}${e.missing ? " (missing card)" : ""}`}));
|
|
100
|
+
for (const n of d.nodes || []) if (!(d.edges || []).some(e => e.from === n.id || e.to === n.id)) items.push(c.h("li", {text: `${name(n.id)} (no dependencies)`}));
|
|
101
|
+
for (const cy of d.cycles || []) items.push(c.h("li", {class: "warn", text: `cycle: ${cy.join(" → ")}`}));
|
|
102
|
+
return c.h("details", {class: "as-text"}, c.h("summary", {text: "Dependencies as text"}), items.length ? c.h("ul", null, items) : c.h("p", {class: "cls", text: "No dependencies."}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
views.push({
|
|
106
|
+
id: "graph",
|
|
107
|
+
endpoint: "graph",
|
|
108
|
+
label: {raid: "Quest web", plain: "Dependencies"},
|
|
109
|
+
render(c, d, pane) {
|
|
110
|
+
const repo = document.querySelector('#repos [data-repo][aria-pressed="true"]')?.dataset.repo || "";
|
|
111
|
+
if (repo) {
|
|
112
|
+
const ids = new Set((d.nodes || []).filter(n => (n.repos || []).includes(repo)).map(n => n.id));
|
|
113
|
+
const edges = (d.edges || []).filter(e => ids.has(e.to));
|
|
114
|
+
const needed = new Set([...ids, ...edges.map(e => e.from)]);
|
|
115
|
+
d = {...d, nodes: (d.nodes || []).filter(n => needed.has(n.id)), edges,
|
|
116
|
+
cycles: (d.cycles || []).filter(cy => cy.every(id => needed.has(id)))};
|
|
117
|
+
}
|
|
118
|
+
const active = pane.contains(document.activeElement) ? document.activeElement.closest(".gnode")?.dataset.id : null;
|
|
119
|
+
const oldScroll = pane.querySelector(".dep-scroll");
|
|
120
|
+
const scroll = oldScroll ? [oldScroll.scrollLeft, oldScroll.scrollTop] : [0, 0];
|
|
121
|
+
const cycles = (d.cycles || []).length;
|
|
122
|
+
c.setKids(pane, [
|
|
123
|
+
c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Quest web" : "Dependencies", c.raid && c.raidSub ? c.raidSub("Dependencies") : null),
|
|
124
|
+
c.h("p", {class: "cls", text: `${(d.nodes || []).length} cards, ${(d.edges || []).length} ${(d.edges || []).length === 1 ? "dependency" : "dependencies"}${cycles ? `, ${cycles} cycle${cycles === 1 ? "" : "s"}` : ""}. Arrows point from a card to the cards that wait on it.`})),
|
|
125
|
+
c.h("div", {class: "legend-row"}, ["todo", "doing", "review", "blocked", "done"].map(l => c.h("span", null, c.h("i", {class: "sw lane-sw lane-" + l}), l))),
|
|
126
|
+
depGraph(c, d),
|
|
127
|
+
asText(c, d),
|
|
128
|
+
c.h("h3", {text: "Lock contention"}),
|
|
129
|
+
c.h("p", {class: "cls", text: "Lock denials, write violations and takeovers per path and day, plus cards waiting on a held path now. Darker means more."}),
|
|
130
|
+
heat(c, d)], JSON.stringify([d, c.theme, c.ws, repo]));
|
|
131
|
+
const nextScroll = pane.querySelector(".dep-scroll");
|
|
132
|
+
if (nextScroll) { nextScroll.scrollLeft = scroll[0]; nextScroll.scrollTop = scroll[1]; }
|
|
133
|
+
if (active) [...pane.querySelectorAll(".gnode")].find(n => n.dataset.id === active)?.focus({preventScroll: true});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
})();
|