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/treasury.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Treasury forecast: burn rate, a forecast to the end of the calendar month (UTC) and budget alerts.
|
|
2
|
+
|
|
3
|
+
Spend is measured the way budget checks measure it: paid USD, plus the API-equivalent of plan usage unless
|
|
4
|
+
`budget.count_plan_usage` is false. The forecast adds the trailing seven-day daily rate for the rest of the month.
|
|
5
|
+
Alerts fire at ALERT_PCT of a cap, when a cap is reached, and when the forecast passes the monthly cap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from . import budgets
|
|
12
|
+
from .core import RaidlockError, clock
|
|
13
|
+
|
|
14
|
+
ALERT_PCT = 80
|
|
15
|
+
TRAIL_DAYS = 7
|
|
16
|
+
HISTORY_DAYS = 14
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _period(now):
|
|
20
|
+
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
21
|
+
end = (start + datetime.timedelta(days=32)).replace(day=1)
|
|
22
|
+
return start, end
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _measure(rows, count_plan):
|
|
26
|
+
for r in rows:
|
|
27
|
+
for key in ("usd", "usd_equiv"):
|
|
28
|
+
value = r.get(key)
|
|
29
|
+
if value is not None and (not math.isfinite(float(value)) or float(value) < 0):
|
|
30
|
+
raise ValueError("invalid-data: nonfinite or negative money")
|
|
31
|
+
paid = sum(float(r.get("usd") or 0) for r in rows)
|
|
32
|
+
plan = sum(float(r.get("usd_equiv") or 0) for r in rows if r.get("key") == "plan")
|
|
33
|
+
equiv = sum(float(r.get("usd_equiv") or 0) for r in rows)
|
|
34
|
+
total = paid + (plan if count_plan else 0)
|
|
35
|
+
if not math.isfinite(total) or not math.isfinite(equiv):
|
|
36
|
+
raise ValueError("invalid-data: money aggregation overflow")
|
|
37
|
+
return round(total, 6), round(equiv, 6)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _spent(board, since, until, count_plan):
|
|
41
|
+
rows = board.spend(by="billing", since=clock.iso(since), until=clock.iso(until))["rows"]
|
|
42
|
+
return _measure(rows, count_plan)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _settings(board):
|
|
46
|
+
try:
|
|
47
|
+
return budgets._settings(board)
|
|
48
|
+
except (RaidlockError, ValueError, TypeError):
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _alert(workspace, level, kind, text):
|
|
53
|
+
return {"workspace": workspace, "level": level, "kind": kind, "text": text}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def forecast(board, now=None):
|
|
57
|
+
try:
|
|
58
|
+
return _forecast(board, now)
|
|
59
|
+
except (RaidlockError, ValueError, TypeError, OverflowError):
|
|
60
|
+
return {"workspace": board.name, "error": "invalid-data", "alerts": [], "by_day": []}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _forecast(board, now=None):
|
|
64
|
+
"""Forecast for one workspace."""
|
|
65
|
+
now = now or clock.now()
|
|
66
|
+
start, end = _period(now)
|
|
67
|
+
settings = _settings(board)
|
|
68
|
+
count_plan = settings.get(budgets.COUNT_PLAN)
|
|
69
|
+
count_plan = True if count_plan is None else count_plan
|
|
70
|
+
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
71
|
+
until = now + datetime.timedelta(seconds=1)
|
|
72
|
+
month, month_equiv = _spent(board, start, until, count_plan)
|
|
73
|
+
today, _ = _spent(board, day_start, until, count_plan)
|
|
74
|
+
trail, _ = _spent(board, now - datetime.timedelta(days=TRAIL_DAYS), until, count_plan)
|
|
75
|
+
elapsed = max((now - start).total_seconds() / 86400.0, 1 / 24)
|
|
76
|
+
remaining = max((end - now).total_seconds() / 86400.0, 0.0)
|
|
77
|
+
rate = trail / TRAIL_DAYS
|
|
78
|
+
projected = round(month + rate * remaining, 6)
|
|
79
|
+
history_from = clock.iso(day_start - datetime.timedelta(days=HISTORY_DAYS - 1))
|
|
80
|
+
by_day = {r["key"]: r for r in board.spend(by="day", since=history_from)["rows"]}
|
|
81
|
+
days = []
|
|
82
|
+
for i in range(HISTORY_DAYS):
|
|
83
|
+
key = (day_start - datetime.timedelta(days=HISTORY_DAYS - 1 - i)).strftime("%Y-%m-%d")
|
|
84
|
+
row = by_day.get(key) or {}
|
|
85
|
+
days.append({"day": key, "usd": round(float(row.get("usd") or 0), 6), "usd_equiv": row.get("usd_equiv") or 0})
|
|
86
|
+
daily_cap = settings.get(budgets.DAILY)
|
|
87
|
+
monthly_cap = settings.get(budgets.MONTHLY)
|
|
88
|
+
name = board.name
|
|
89
|
+
alerts = []
|
|
90
|
+
if daily_cap is not None:
|
|
91
|
+
if today >= daily_cap:
|
|
92
|
+
alerts.append(
|
|
93
|
+
_alert(name, "critical", "daily", f"today ${today:.2f} reached the daily cap ${daily_cap:.2f}")
|
|
94
|
+
)
|
|
95
|
+
elif daily_cap and today >= daily_cap * ALERT_PCT / 100:
|
|
96
|
+
alerts.append(
|
|
97
|
+
_alert(name, "warning", "daily", f"today ${today:.2f} is over {ALERT_PCT}% of ${daily_cap:.2f}")
|
|
98
|
+
)
|
|
99
|
+
if monthly_cap is not None:
|
|
100
|
+
if month >= monthly_cap:
|
|
101
|
+
alerts.append(
|
|
102
|
+
_alert(name, "critical", "monthly", f"month ${month:.2f} reached the monthly cap ${monthly_cap:.2f}")
|
|
103
|
+
)
|
|
104
|
+
elif projected > monthly_cap:
|
|
105
|
+
alerts.append(
|
|
106
|
+
_alert(
|
|
107
|
+
name,
|
|
108
|
+
"warning",
|
|
109
|
+
"forecast",
|
|
110
|
+
f"on track for ${projected:.2f} by {end.strftime('%Y-%m-%d')}, over the ${monthly_cap:.2f} cap",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
elif monthly_cap and month >= monthly_cap * ALERT_PCT / 100:
|
|
114
|
+
alerts.append(
|
|
115
|
+
_alert(name, "warning", "monthly", f"month ${month:.2f} is over {ALERT_PCT}% of ${monthly_cap:.2f}")
|
|
116
|
+
)
|
|
117
|
+
return {
|
|
118
|
+
"workspace": name,
|
|
119
|
+
"now": clock.iso(now),
|
|
120
|
+
"period": {
|
|
121
|
+
"start": clock.iso(start),
|
|
122
|
+
"end": clock.iso(end),
|
|
123
|
+
"days_elapsed": round(elapsed, 3),
|
|
124
|
+
"days_left": round(remaining, 3),
|
|
125
|
+
},
|
|
126
|
+
"count_plan": count_plan,
|
|
127
|
+
"month": month,
|
|
128
|
+
"month_api_equiv": month_equiv,
|
|
129
|
+
"today": today,
|
|
130
|
+
"burn": {
|
|
131
|
+
"per_day_month": round(month / elapsed, 6),
|
|
132
|
+
"per_day_trailing": round(rate, 6),
|
|
133
|
+
"trail_days": TRAIL_DAYS,
|
|
134
|
+
},
|
|
135
|
+
"forecast": projected,
|
|
136
|
+
"caps": {"daily": daily_cap, "monthly": monthly_cap},
|
|
137
|
+
"by_day": days,
|
|
138
|
+
"alerts": alerts,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def combine(items, now=None):
|
|
143
|
+
"""Sum per-workspace forecasts into one view across workspaces."""
|
|
144
|
+
now = now or clock.now()
|
|
145
|
+
if any(f.get("error") for f in items):
|
|
146
|
+
return {"workspace": "*", "error": "invalid-data", "alerts": [], "by_day": []}
|
|
147
|
+
start, end = _period(now)
|
|
148
|
+
days = {}
|
|
149
|
+
for f in items:
|
|
150
|
+
for d in f["by_day"]:
|
|
151
|
+
row = days.setdefault(d["day"], {"day": d["day"], "usd": 0.0, "usd_equiv": 0.0})
|
|
152
|
+
row["usd"] = round(row["usd"] + d["usd"], 6)
|
|
153
|
+
row["usd_equiv"] = round(row["usd_equiv"] + float(d["usd_equiv"] or 0), 6)
|
|
154
|
+
|
|
155
|
+
def total(key):
|
|
156
|
+
return round(sum(f[key] for f in items), 6)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"workspace": "*",
|
|
160
|
+
"now": clock.iso(now),
|
|
161
|
+
"period": {"start": clock.iso(start), "end": clock.iso(end)},
|
|
162
|
+
"month": total("month"),
|
|
163
|
+
"month_api_equiv": total("month_api_equiv"),
|
|
164
|
+
"today": total("today"),
|
|
165
|
+
"burn": {
|
|
166
|
+
"per_day_month": round(sum(f["burn"]["per_day_month"] for f in items), 6),
|
|
167
|
+
"per_day_trailing": round(sum(f["burn"]["per_day_trailing"] for f in items), 6),
|
|
168
|
+
"trail_days": TRAIL_DAYS,
|
|
169
|
+
},
|
|
170
|
+
"forecast": total("forecast"),
|
|
171
|
+
"by_day": [days[k] for k in sorted(days)],
|
|
172
|
+
"alerts": [a for f in items for a in f["alerts"]],
|
|
173
|
+
"workspaces": [{k: f[k] for k in ("workspace", "month", "today", "forecast", "caps", "alerts")} for f in items],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def everywhere(now=None):
|
|
178
|
+
"""Forecasts for every registered workspace, combined."""
|
|
179
|
+
from .core import Board, registry
|
|
180
|
+
from .core.db import Index
|
|
181
|
+
|
|
182
|
+
home = registry.home()
|
|
183
|
+
items = []
|
|
184
|
+
for w in registry.workspaces(Index(home)):
|
|
185
|
+
try:
|
|
186
|
+
items.append(forecast(Board.open(w["board"], home=home), now))
|
|
187
|
+
except (RaidlockError, OSError, ValueError):
|
|
188
|
+
continue
|
|
189
|
+
return combine(items, now)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _setup(parser):
|
|
193
|
+
parser.add_argument("--all", action="store_true", help="every registered workspace, with alerts per workspace")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _handle(ctx, args):
|
|
197
|
+
f = everywhere() if args.all else forecast(ctx.board)
|
|
198
|
+
if f.get("error"):
|
|
199
|
+
return f, "invalid-data: money could not be aggregated"
|
|
200
|
+
lines = [
|
|
201
|
+
f"{f['workspace']}: ${f['month']:.2f} this month, ${f['today']:.2f} today",
|
|
202
|
+
f"burn ${f['burn']['per_day_trailing']:.2f}/day over {TRAIL_DAYS} days; "
|
|
203
|
+
f"forecast ${f['forecast']:.2f} by {f['period']['end'][:10]}",
|
|
204
|
+
]
|
|
205
|
+
lines += [f"{a['level'].upper()} {a['workspace']}: {a['text']}" for a in f["alerts"]]
|
|
206
|
+
return f, "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def register_cli(add_command):
|
|
210
|
+
add_command("treasury", "burn rate, month-end forecast and budget alerts", _setup, _handle)
|
raidlock/tui.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Live terminal board for supported Linux/macOS hosts; native Windows is unverified."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import selectors
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from raidlock.core import Board, Precondition, db
|
|
13
|
+
|
|
14
|
+
LANES = ("todo", "doing", "review", "blocked", "done", "cancelled")
|
|
15
|
+
STATUSES = {
|
|
16
|
+
"working": "Working",
|
|
17
|
+
"quiet": "Working",
|
|
18
|
+
"review": "Waiting",
|
|
19
|
+
"blocked": "Blocked",
|
|
20
|
+
"stalled": "Stalled",
|
|
21
|
+
"idle": "Idle",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _text(value):
|
|
26
|
+
return "".join(c if c.isprintable() else " " for c in str(value))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _countdown(lease, now):
|
|
30
|
+
if not lease:
|
|
31
|
+
return "-"
|
|
32
|
+
if lease.get("expires") is None:
|
|
33
|
+
return "held"
|
|
34
|
+
expires = datetime.fromisoformat(lease["expires"].replace("Z", "+00:00"))
|
|
35
|
+
seconds = max(0, math.ceil((expires - now).total_seconds()))
|
|
36
|
+
if not seconds:
|
|
37
|
+
return "expired"
|
|
38
|
+
minutes, seconds = divmod(seconds, 60)
|
|
39
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render(snapshot, color=False):
|
|
43
|
+
now = datetime.fromisoformat(snapshot["now"].replace("Z", "+00:00"))
|
|
44
|
+
color = color and "NO_COLOR" not in os.environ
|
|
45
|
+
|
|
46
|
+
def heading(text):
|
|
47
|
+
return f"\033[1;36m{text}\033[0m" if color else text
|
|
48
|
+
|
|
49
|
+
total = snapshot["costs"]["workspace"]
|
|
50
|
+
lines = [heading(f"Raidlock | {_text(snapshot['workspace'])} | Spend ${total['usd']:.4f}")]
|
|
51
|
+
if total.get("unpriced_runs"):
|
|
52
|
+
lines.append(f"Unpriced runs: {total['unpriced_runs']}")
|
|
53
|
+
for lane in LANES:
|
|
54
|
+
cards = sorted((c for c in snapshot["cards"] if c["lane"] == lane), key=lambda c: c["id"])
|
|
55
|
+
if lane == "cancelled" and not cards:
|
|
56
|
+
continue
|
|
57
|
+
lines.extend(["", heading(f"{lane.upper()} ({len(cards)})")])
|
|
58
|
+
for card in cards:
|
|
59
|
+
title = _text(card["title"])
|
|
60
|
+
title = title if len(title) <= 44 else title[:41] + "..."
|
|
61
|
+
lease = snapshot.get("leases", {}).get(card["id"], card.get("lease"))
|
|
62
|
+
lines.append(
|
|
63
|
+
f" {_text(card['id'])} | {title} | {_text(card.get('owner') or '-')} | lock {_countdown(lease, now)}"
|
|
64
|
+
)
|
|
65
|
+
if not cards:
|
|
66
|
+
lines.append(" (empty)")
|
|
67
|
+
agents = snapshot["agents"]
|
|
68
|
+
lines.extend(["", heading(f"AGENTS ({len(agents)})")])
|
|
69
|
+
for name, agent in sorted(agents.items()):
|
|
70
|
+
status = STATUSES.get(agent.get("status"), "Idle")
|
|
71
|
+
lines.append(
|
|
72
|
+
f" {_text(name)} | {status} | [{_text(agent.get('model') or 'unknown')}] | "
|
|
73
|
+
f"{_text(agent.get('card') or '-')}"
|
|
74
|
+
)
|
|
75
|
+
if not agents:
|
|
76
|
+
lines.append(" (none)")
|
|
77
|
+
lines.extend(["", "q / Ctrl-C to exit | countdowns sampled on board changes"])
|
|
78
|
+
return "\n".join(lines) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def _terminal(output):
|
|
83
|
+
enabled = output.isatty()
|
|
84
|
+
restore = None
|
|
85
|
+
if enabled and os.name == "nt":
|
|
86
|
+
import ctypes
|
|
87
|
+
import msvcrt
|
|
88
|
+
|
|
89
|
+
kernel = ctypes.windll.kernel32
|
|
90
|
+
handle = ctypes.c_void_p(msvcrt.get_osfhandle(output.fileno()))
|
|
91
|
+
mode = ctypes.c_ulong()
|
|
92
|
+
enabled = bool(kernel.GetConsoleMode(handle, ctypes.byref(mode)))
|
|
93
|
+
if enabled:
|
|
94
|
+
enabled = bool(kernel.SetConsoleMode(handle, mode.value | 4))
|
|
95
|
+
if enabled:
|
|
96
|
+
|
|
97
|
+
def restore():
|
|
98
|
+
return kernel.SetConsoleMode(handle, mode.value)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
if enabled:
|
|
102
|
+
output.write("\033[?25l")
|
|
103
|
+
yield enabled
|
|
104
|
+
finally:
|
|
105
|
+
if enabled:
|
|
106
|
+
output.write("\033[0m\033[?25h")
|
|
107
|
+
output.flush()
|
|
108
|
+
if restore:
|
|
109
|
+
restore()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def _keyboard(stream):
|
|
114
|
+
saved = None
|
|
115
|
+
if os.name != "nt" and stream.isatty():
|
|
116
|
+
import termios
|
|
117
|
+
import tty
|
|
118
|
+
|
|
119
|
+
saved = termios.tcgetattr(stream.fileno())
|
|
120
|
+
tty.setcbreak(stream.fileno())
|
|
121
|
+
try:
|
|
122
|
+
yield
|
|
123
|
+
finally:
|
|
124
|
+
if saved is not None:
|
|
125
|
+
termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, saved)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _quit(stream, delay):
|
|
129
|
+
deadline = time.monotonic() + delay
|
|
130
|
+
while True:
|
|
131
|
+
remaining = deadline - time.monotonic()
|
|
132
|
+
if remaining <= 0:
|
|
133
|
+
return False
|
|
134
|
+
if os.name == "nt":
|
|
135
|
+
import msvcrt
|
|
136
|
+
|
|
137
|
+
if stream.isatty() and msvcrt.kbhit() and msvcrt.getwch() in ("q", "Q", "\x03"):
|
|
138
|
+
return True
|
|
139
|
+
time.sleep(min(0.05, remaining))
|
|
140
|
+
else:
|
|
141
|
+
try:
|
|
142
|
+
with selectors.DefaultSelector() as selector:
|
|
143
|
+
selector.register(stream, selectors.EVENT_READ)
|
|
144
|
+
ready = selector.select(remaining)
|
|
145
|
+
except (ValueError, OSError):
|
|
146
|
+
time.sleep(remaining)
|
|
147
|
+
return False
|
|
148
|
+
if not ready:
|
|
149
|
+
return False
|
|
150
|
+
char = os.read(stream.fileno(), 1)
|
|
151
|
+
if char in (b"q", b"Q", b"\x03"):
|
|
152
|
+
return True
|
|
153
|
+
if not char:
|
|
154
|
+
time.sleep(remaining)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def watch(board, interval=3, output=None, stdin=None):
|
|
159
|
+
if not math.isfinite(interval) or interval <= 0:
|
|
160
|
+
raise Precondition("interval must be a positive finite number")
|
|
161
|
+
output = sys.stdout if output is None else output
|
|
162
|
+
stdin = sys.stdin if stdin is None else stdin
|
|
163
|
+
version = None
|
|
164
|
+
try:
|
|
165
|
+
with _terminal(output) as ansi, _keyboard(stdin):
|
|
166
|
+
while True:
|
|
167
|
+
current = board.version()["workspace"]
|
|
168
|
+
if current != version:
|
|
169
|
+
snapshot = board.snapshot(transcript_limit=0, messages_limit=0)
|
|
170
|
+
output.write(("\033[H\033[2J" if ansi else "") + render(snapshot, color=ansi))
|
|
171
|
+
output.flush()
|
|
172
|
+
version = snapshot["version"]["workspace"]
|
|
173
|
+
db.close_all()
|
|
174
|
+
if _quit(stdin, interval):
|
|
175
|
+
break
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
pass
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _open(workspace=None, path=None):
|
|
182
|
+
return Board.open(workspace if workspace is not None else path)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _interval(value):
|
|
186
|
+
try:
|
|
187
|
+
number = float(value)
|
|
188
|
+
except ValueError:
|
|
189
|
+
raise argparse.ArgumentTypeError("interval must be a positive finite number") from None
|
|
190
|
+
if not math.isfinite(number) or number <= 0:
|
|
191
|
+
raise argparse.ArgumentTypeError("interval must be a positive finite number")
|
|
192
|
+
return number
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _setup(parser):
|
|
196
|
+
parser.add_argument("--workspace", metavar="NAME", help="workspace folder name or path")
|
|
197
|
+
parser.add_argument("--interval", type=_interval, default=3)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _command(ctx, args):
|
|
201
|
+
if args.json:
|
|
202
|
+
raise Precondition("watch does not support --json")
|
|
203
|
+
board = _open(args.workspace, args.board) if args.workspace else ctx.board
|
|
204
|
+
return {"_exit": watch(board, args.interval)}, ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def register_cli(add_command):
|
|
208
|
+
add_command("watch", "live terminal board", _setup, _command)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main(argv=None):
|
|
212
|
+
from raidlock.cli import add_command
|
|
213
|
+
from raidlock.cli import main as cli_main
|
|
214
|
+
|
|
215
|
+
register_cli(add_command)
|
|
216
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
217
|
+
if not args or args[0] != "watch":
|
|
218
|
+
args.insert(0, "watch")
|
|
219
|
+
return cli_main(args)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main())
|
raidlock/web/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.achs{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:10px}
|
|
2
|
+
.ach{display:flex;gap:10px;align-items:flex-start;background:var(--panel);border:1px solid var(--edge);border-radius:12px;padding:10px;min-width:0}
|
|
3
|
+
.ach.earned{border-color:var(--accent2)}
|
|
4
|
+
.ach-body{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:14px;overflow-wrap:anywhere}
|
|
5
|
+
.ach-body b{font-size:14px;color:var(--accent)}
|
|
6
|
+
.ach-body small{color:var(--muted)}
|
|
7
|
+
.ach-art{flex:none}
|
|
8
|
+
.ach-art .ring{fill:var(--pill);stroke:var(--edge);stroke-width:2}
|
|
9
|
+
.ach-art .fillc{fill:var(--muted)}
|
|
10
|
+
.ach-art .strokec{stroke:var(--muted);stroke-width:2.5;fill:none;stroke-linecap:round}
|
|
11
|
+
.ach-art .cut{stroke:var(--pill);stroke-width:3;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
12
|
+
.ach.earned .ach-art .ring{stroke:var(--accent2)}
|
|
13
|
+
.ach.earned .ach-art .fillc{fill:var(--accent)}
|
|
14
|
+
.ach.earned .ach-art .strokec{stroke:var(--accent)}
|
|
15
|
+
.ach.locked .ach-body b{color:var(--muted)}
|
|
16
|
+
.ach-meter{height:8px;border-radius:4px;background:var(--pill);border:1px solid var(--edge);overflow:hidden}
|
|
17
|
+
.ach-meter i{display:block;height:100%;background:var(--accent)}
|
|
18
|
+
|
|
19
|
+
.titles{margin:0 0 14px}.titles h3{margin:0 0 6px;font-size:15px;color:var(--accent)}.titles ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px 14px}.titles li{font-size:14px}.titles b{color:var(--ink)}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
|
|
5
|
+
const ART = {
|
|
6
|
+
sun: s => [s("circle", {cx: 24, cy: 24, r: 9, class: "fillc"}),
|
|
7
|
+
...[0, 45, 90, 135, 180, 225, 270, 315].map(a => s("line", {x1: 24, y1: 6, x2: 24, y2: 11, transform: `rotate(${a} 24 24)`, class: "strokec"}))],
|
|
8
|
+
seal: s => [s("path", {class: "fillc", d: "M24 6l4 3 5-1 2 5 5 2-1 5 3 4-3 4 1 5-5 2-2 5-5-1-4 3-4-3-5 1-2-5-5-2 1-5-3-4 3-4-1-5 5-2 2-5 5 1z"}),
|
|
9
|
+
s("path", {class: "cut", d: "M17 24l5 5 9-10"})],
|
|
10
|
+
eyes: s => [s("ellipse", {cx: 15, cy: 24, rx: 9, ry: 6, class: "strokec"}), s("ellipse", {cx: 33, cy: 24, rx: 9, ry: 6, class: "strokec"}),
|
|
11
|
+
s("circle", {cx: 16, cy: 24, r: 3, class: "fillc"}), s("circle", {cx: 34, cy: 24, r: 3, class: "fillc"})],
|
|
12
|
+
quill: s => [s("path", {class: "fillc", d: "M38 8C24 10 16 20 13 34l3 1c3-8 8-14 15-18-5 5-9 10-11 17 11-4 17-14 18-26z"}),
|
|
13
|
+
s("line", {x1: 13, y1: 34, x2: 10, y2: 41, class: "strokec"})],
|
|
14
|
+
chord: s => [10, 24, 38].flatMap((x, i) => [s("line", {x1: x, y1: 10, x2: x, y2: 34, class: "strokec"}), s("circle", {cx: x - 3, cy: 36 - i * 6, r: 4, class: "fillc"})]),
|
|
15
|
+
hands: s => [s("path", {class: "fillc", d: "M10 38V20a2 2 0 014 0v8V14a2 2 0 014 0v14V12a2 2 0 014 0v16-12a2 2 0 014 0v18c0 6-4 10-10 10h-1c-3 0-5-2-7-4z"}),
|
|
16
|
+
s("path", {class: "strokec", d: "M30 30l4-4a2 2 0 013 3l-6 7"})],
|
|
17
|
+
rune: s => [s("circle", {cx: 24, cy: 24, r: 15, class: "strokec"}), s("path", {class: "strokec", d: "M16 24l6 6 11-13"})],
|
|
18
|
+
shield: s => [s("path", {class: "fillc", d: "M24 6l15 5v11c0 10-7 17-15 20C16 39 9 32 9 22V11z"}), s("path", {class: "strokec", d: "M24 14v20M17 22h14"})],
|
|
19
|
+
blade: s => [s("path", {class: "fillc", d: "M34 6l8 0 0 8-18 18-8-8z"}), s("path", {class: "strokec", d: "M14 26l8 8M10 38l6-6"})],
|
|
20
|
+
moon: s => [s("path", {class: "fillc", d: "M30 8a16 16 0 1 0 10 26A13 13 0 0 1 30 8z"}), s("circle", {cx: 36, cy: 14, r: 2, class: "fillc"})]
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function art(c, a) {
|
|
24
|
+
const draw = ART[a.art] || ART.sun;
|
|
25
|
+
return c.svg("svg", {viewBox: "0 0 48 48", width: "48", height: "48", class: "ach-art", "aria-hidden": "true"},
|
|
26
|
+
c.svg("circle", {cx: 24, cy: 24, r: 22, class: "ring"}), draw(c.svg));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function badge(c, a) {
|
|
30
|
+
const p = a.progress;
|
|
31
|
+
const status = a.earned ? `Earned${a.by ? " by " + a.by : ""}${a.at ? ", " + c.ago(a.at) + " ago" : ""}${a.detail ? " · " + a.detail : ""}`
|
|
32
|
+
: p ? `Locked · ${p.value}/${p.goal}` : "Locked";
|
|
33
|
+
let meter = null;
|
|
34
|
+
if (!a.earned && p) {
|
|
35
|
+
const fill = c.h("i");
|
|
36
|
+
fill.classList.add("width-" + Math.round(Math.max(0, Math.min(100, 100 * p.value / Math.max(1, p.goal)))));
|
|
37
|
+
meter = c.h("div", {class: "ach-meter", role: "meter", "aria-label": `${a.name} progress`, "aria-valuemin": "0", "aria-valuemax": String(p.goal), "aria-valuenow": String(p.value)}, fill);
|
|
38
|
+
}
|
|
39
|
+
return c.h("div", {class: "ach " + (a.earned ? "earned" : "locked"), "data-id": a.id},
|
|
40
|
+
art(c, a), c.h("div", {class: "ach-body"}, c.h("b", {text: a.name}), c.h("span", {text: a.description}), meter, c.h("small", {text: status})));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
views.push({
|
|
44
|
+
id: "achievements",
|
|
45
|
+
endpoint: "achievements",
|
|
46
|
+
label: {raid: "Feats", plain: "Achievements"},
|
|
47
|
+
optIn: "achievements",
|
|
48
|
+
render(c, d, pane) {
|
|
49
|
+
const titles = Object.entries(d.titles || {});
|
|
50
|
+
c.setKids(pane, [
|
|
51
|
+
c.h("div", {class: "view-head"}, c.h("h2", null, c.raid ? "Feats" : "Achievements", c.raid && c.raidSub ? c.raidSub("Achievements") : null),
|
|
52
|
+
c.h("p", {class: "cls", text: `${d.incomplete_history ? "Incomplete history (latest sequence " + d.last + "). " : ""}${d.earned} of ${d.total} earned, from recorded events only. Turn them off in Settings.`})),
|
|
53
|
+
titles.length ? c.h("section", {class: "titles", "aria-label": "Titles earned"}, c.h("h3", {text: "Titles earned"}),
|
|
54
|
+
c.h("ul", null, titles.map(([who, title]) => c.h("li", null, c.h("b", {text: who}), " " + title)))) : null,
|
|
55
|
+
c.h("div", {class: "achs"}, (d.achievements || []).map(a => badge(c, a)))], JSON.stringify([d, c.theme, Math.floor(Date.now() / 60000)]));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
})();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.bosses,.bags,.drops{list-style:none;margin:0;padding:0}
|
|
2
|
+
.bosses,.bags{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:10px}
|
|
3
|
+
.boss,.bag{background:var(--panel);border:1px solid var(--edge);border-radius:12px;padding:10px;min-width:0;display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere;font-size:14px}
|
|
4
|
+
.boss{border-left:5px solid var(--blocked)}
|
|
5
|
+
.boss.slain{border-left-color:var(--done)}
|
|
6
|
+
.boss-head{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
7
|
+
.boss-head b{font-size:14px;color:var(--ink)}
|
|
8
|
+
.boss.slain .boss-head b{text-decoration:line-through}
|
|
9
|
+
.boss-hp{height:12px;border-radius:6px;background:var(--pill);border:1px solid var(--edge);overflow:hidden}
|
|
10
|
+
.boss-hp i{display:block;height:100%;background:var(--blocked);transition:width .6s ease-out}
|
|
11
|
+
.boss-note{margin:0;font-size:14px;color:var(--muted)}
|
|
12
|
+
.loot-head{margin:16px 0 8px;font-size:15px;color:var(--accent)}
|
|
13
|
+
.drops{display:flex;flex-direction:column;gap:6px}
|
|
14
|
+
.drop{display:flex;flex-direction:column;gap:1px;padding:4px 8px;border-left:4px solid var(--muted);background:var(--pill);border-radius:6px}
|
|
15
|
+
.drop-name{font-weight:700;color:var(--ink)}
|
|
16
|
+
.drop.r1{border-left-color:var(--done)}
|
|
17
|
+
.drop.r2{border-left-color:var(--todo)}
|
|
18
|
+
.drop.r3{border-left-color:var(--review)}
|
|
19
|
+
.drop.r4{border-left-color:var(--accent)}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(function () {
|
|
3
|
+
const views = window.RaidlockViews = window.RaidlockViews || [];
|
|
4
|
+
const BAG_CAP = 12;
|
|
5
|
+
|
|
6
|
+
function boss(c, b) {
|
|
7
|
+
const fill = c.h("i");
|
|
8
|
+
fill.classList.add("width-" + Math.max(0, Math.min(100, b.hp)));
|
|
9
|
+
const left = b.slain ? "slain" : `${b.open} of ${b.total} quests still open`;
|
|
10
|
+
return c.h("li", {class: "boss" + (b.slain ? " slain" : ""), "data-id": b.id},
|
|
11
|
+
c.h("div", {class: "boss-head"}, c.h("b", {text: b.title}), c.h("span", {class: "cls", text: `${b.id} · ${b.dependents} quests wait on it`})),
|
|
12
|
+
c.h("div", {class: "boss-hp", role: "meter", "aria-label": `${b.title} HP`, "aria-valuemin": "0", "aria-valuemax": "100",
|
|
13
|
+
"aria-valuenow": String(b.hp), "aria-valuetext": `${b.hp}%, ${left}`}, fill),
|
|
14
|
+
b.slain ? c.h("p", {class: "boss-note slain-at"}, "Slain ", b.slain_at) : c.h("p", {class: "boss-note", text: `HP ${b.hp}% · ${left}`}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function bag(c, g) {
|
|
18
|
+
return c.h("li", {class: "bag", "data-agent": g.agent}, c.h("b", {text: g.agent}),
|
|
19
|
+
c.h("ul", {class: "drops"}, g.items.map(i => c.h("li", {class: `drop r${i.tier}`, "data-card": i.card},
|
|
20
|
+
c.h("span", {class: "drop-name", text: i.item}), c.h("span", {class: "cls", text: `${i.rarity} · ${i.title}`})))));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
views.push({
|
|
24
|
+
id: "bosses",
|
|
25
|
+
endpoint: "bosses",
|
|
26
|
+
label: {raid: "Boss board", plain: "Big quests"},
|
|
27
|
+
raidOnly: true,
|
|
28
|
+
render(c, d, pane) {
|
|
29
|
+
const bosses = d.bosses || [], bags = d.bags || [];
|
|
30
|
+
const drops = bags.reduce((n, g) => n + g.items.length, 0);
|
|
31
|
+
const best = g => Math.max(...g.items.map(i => i.tier));
|
|
32
|
+
const ranked = bags.slice().sort((a, b) => best(b) - best(a) || b.items.length - a.items.length || a.agent.localeCompare(b.agent));
|
|
33
|
+
c.setKids(pane, [
|
|
34
|
+
c.h("div", {class: "view-head"}, c.h("h2", null, "Boss board", c.raidSub ? c.raidSub("Big quests and loot") : null),
|
|
35
|
+
c.h("p", {class: "cls", text: `A quest that ${d.boss_dependents} or more others wait on is a boss. Its HP is the share of it and its dependents still open. Every finished quest drops one item for the agent who closed it.`})),
|
|
36
|
+
bosses.length ? c.h("ul", {class: "bosses"}, bosses.map(b => boss(c, b)))
|
|
37
|
+
: c.h("p", {class: "empty", text: `No bosses roam this realm yet. A quest becomes a boss once ${d.boss_dependents} others wait on it, or when the raid leader names it one.`}),
|
|
38
|
+
c.h("h3", {class: "loot-head", text: `Loot bags · ${drops} ${drops === 1 ? "drop" : "drops"}`}),
|
|
39
|
+
bags.length ? c.h("ul", {class: "bags"}, ranked.slice(0, BAG_CAP).map(g => bag(c, g)))
|
|
40
|
+
: c.h("p", {class: "empty", text: "The bags are empty. Turn in a quest to earn the first drop."}),
|
|
41
|
+
ranked.length > BAG_CAP ? c.h("details", {class: "more-bags"}, c.h("summary", {text: `Show all ${ranked.length} bags`}),
|
|
42
|
+
c.h("ul", {class: "bags"}, ranked.slice(BAG_CAP).map(g => bag(c, g)))) : null],
|
|
43
|
+
JSON.stringify([d, c.theme]));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
})();
|
raidlock/web/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import webbrowser
|
|
3
|
+
|
|
4
|
+
from raidlock.core import find_board_dir
|
|
5
|
+
from raidlock.core.errors import Precondition
|
|
6
|
+
|
|
7
|
+
HOSTS = ("127.0.0.1", "0.0.0.0")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_host(host, env=None):
|
|
11
|
+
env = os.environ if env is None else env
|
|
12
|
+
if host != HOSTS[0] and env.get("RAIDLOCK_SANDBOX") != "container":
|
|
13
|
+
raise Precondition(
|
|
14
|
+
f"--host {host} is allowed only inside the raidlock container image",
|
|
15
|
+
hint="raidlock ui (binds 127.0.0.1)",
|
|
16
|
+
)
|
|
17
|
+
return host
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup(parser):
|
|
21
|
+
parser.add_argument("--port", type=int, default=8765, help="port on 127.0.0.1 (default 8765)")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--host",
|
|
24
|
+
choices=HOSTS,
|
|
25
|
+
default=HOSTS[0],
|
|
26
|
+
help="bind address; 0.0.0.0 only inside the raidlock container image",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--only", action="store_true", help="show only this board, not every registered workspace")
|
|
29
|
+
parser.add_argument("--open", action="store_true", help="open the page in your browser")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handler(ctx, args):
|
|
33
|
+
from .server import make_server
|
|
34
|
+
|
|
35
|
+
host = check_host(getattr(args, "host", "127.0.0.1"))
|
|
36
|
+
here = None
|
|
37
|
+
try:
|
|
38
|
+
here = find_board_dir(args.board)
|
|
39
|
+
except Precondition:
|
|
40
|
+
if args.board or args.only:
|
|
41
|
+
raise
|
|
42
|
+
if here:
|
|
43
|
+
from raidlock.core.migrate import reject_legacy
|
|
44
|
+
|
|
45
|
+
reject_legacy(here)
|
|
46
|
+
single = here if (args.only or args.board) else None
|
|
47
|
+
try:
|
|
48
|
+
srv = make_server(port=args.port, single=single, extra=None if single else here, host=host)
|
|
49
|
+
except OSError as e:
|
|
50
|
+
raise Precondition(
|
|
51
|
+
f"port {args.port} is busy ({e.strerror})", hint=f"raidlock ui --port {args.port + 1}"
|
|
52
|
+
) from e
|
|
53
|
+
if here:
|
|
54
|
+
caller = ctx.caller()
|
|
55
|
+
if caller.is_lead:
|
|
56
|
+
import time
|
|
57
|
+
|
|
58
|
+
previous = srv.service_actions
|
|
59
|
+
renewed = [time.monotonic()]
|
|
60
|
+
|
|
61
|
+
def heartbeat():
|
|
62
|
+
previous()
|
|
63
|
+
if time.monotonic() - renewed[0] >= 30:
|
|
64
|
+
from raidlock.core.errors import RaidlockError
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
ctx.board.lead_heartbeat(caller)
|
|
68
|
+
except RaidlockError as exc:
|
|
69
|
+
print(f"raidlock ui: lead heartbeat stopped: {exc}", flush=True)
|
|
70
|
+
srv.service_actions = previous
|
|
71
|
+
renewed[0] = time.monotonic()
|
|
72
|
+
|
|
73
|
+
srv.service_actions = heartbeat
|
|
74
|
+
port = srv.server_address[1]
|
|
75
|
+
url = f"http://127.0.0.1:{port}/"
|
|
76
|
+
where = os.path.basename(os.path.dirname(single)) if single else "every registered workspace"
|
|
77
|
+
print(f"raidlock ui: {where} at {url}\nNext: open {url} (Ctrl+C stops it)", flush=True)
|
|
78
|
+
if args.open:
|
|
79
|
+
webbrowser.open(url)
|
|
80
|
+
try:
|
|
81
|
+
srv.serve_forever()
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
pass
|
|
84
|
+
finally:
|
|
85
|
+
srv.server_close()
|
|
86
|
+
return {"url": url}, ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register_cli(add_command):
|
|
90
|
+
add_command("ui", "open the live board in your browser", setup, handler)
|