loopcaptain 0.3.1__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.
- loopcaptain/__init__.py +0 -0
- loopcaptain/agents.py +162 -0
- loopcaptain/board/AGENTS.md +39 -0
- loopcaptain/board/__init__.py +0 -0
- loopcaptain/board/app.py +430 -0
- loopcaptain/board/discover.py +249 -0
- loopcaptain/board/files.py +94 -0
- loopcaptain/board/filters.py +48 -0
- loopcaptain/board/graph.py +139 -0
- loopcaptain/board/overview.py +59 -0
- loopcaptain/board/static/board.js +41 -0
- loopcaptain/board/static/fonts/OFL.txt +94 -0
- loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
- loopcaptain/board/static/mark.svg +5 -0
- loopcaptain/board/static/style.css +425 -0
- loopcaptain/board/steer.py +207 -0
- loopcaptain/board/templates/base.html +27 -0
- loopcaptain/board/templates/connect.html +35 -0
- loopcaptain/board/templates/file.html +9 -0
- loopcaptain/board/templates/loop.html +357 -0
- loopcaptain/board/templates/overview.html +108 -0
- loopcaptain/board/templates/unreadable.html +17 -0
- loopcaptain/board/trend.py +185 -0
- loopcaptain/cli.py +204 -0
- loopcaptain/demo/__init__.py +59 -0
- loopcaptain/demo/loop.py +311 -0
- loopcaptain/demo/loops.py +76 -0
- loopcaptain/mcp_server/AGENTS.md +23 -0
- loopcaptain/mcp_server/__init__.py +0 -0
- loopcaptain/mcp_server/server.py +181 -0
- loopcaptain/prompt_texts/build-a-loop.md +6 -0
- loopcaptain/prompt_texts/watch-my-loops.md +6 -0
- loopcaptain/prompts.py +27 -0
- loopcaptain/protocol/AGENTS.md +24 -0
- loopcaptain/protocol/LOOPS.md +402 -0
- loopcaptain/protocol/__init__.py +17 -0
- loopcaptain/protocol/doctor.py +79 -0
- loopcaptain/protocol/models.py +496 -0
- loopcaptain/protocol/signing.py +105 -0
- loopcaptain/protocol/state.py +595 -0
- loopcaptain/protocol/store.py +275 -0
- loopcaptain/protocol/validate.py +199 -0
- loopcaptain-0.3.1.dist-info/METADATA +220 -0
- loopcaptain-0.3.1.dist-info/RECORD +50 -0
- loopcaptain-0.3.1.dist-info/WHEEL +4 -0
- loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
- loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
- loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
- loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Which loops the board shows: the repositories named in the config, read fresh on every request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from loopcaptain.protocol import store
|
|
14
|
+
from loopcaptain.protocol.models import FOLDER, Manifest, now
|
|
15
|
+
from loopcaptain.protocol.signing import untrusted_ids
|
|
16
|
+
from loopcaptain.protocol.state import LoopState, fold
|
|
17
|
+
from loopcaptain.protocol.store import Problem, StoreError
|
|
18
|
+
|
|
19
|
+
#: Where the config is looked for, first hit wins. The file names this laptop's folders, so it is
|
|
20
|
+
#: never committed (`*.local.yaml` is ignored).
|
|
21
|
+
CONFIG_ENV = "LOOPCAPTAIN_CONFIG"
|
|
22
|
+
CONFIG_NAMES = ("loopcaptain.local.yaml",)
|
|
23
|
+
USER_CONFIG = Path("~/.config/loopcaptain/config.yaml")
|
|
24
|
+
GIT_TIMEOUT_S = 3
|
|
25
|
+
DEMO_FOLDER = "demo"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Config:
|
|
30
|
+
"""`roots`: folders whose direct sub-folders are repositories. `repos`: single repositories."""
|
|
31
|
+
|
|
32
|
+
roots: tuple[Path, ...] = ()
|
|
33
|
+
repos: tuple[Path, ...] = ()
|
|
34
|
+
source: Path | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Repo:
|
|
39
|
+
key: str # unique on the board, the folder name unless two repositories share it
|
|
40
|
+
path: Path # of worktrees of one repository, the one whose `.loops` was written last
|
|
41
|
+
others: tuple[Path, ...] = () # the other worktrees of the same repository that have `.loops`
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git_common_dir(path: Path) -> Path:
|
|
45
|
+
"""The `.git` folder a checkout shares with its worktrees, read from disk without running git.
|
|
46
|
+
|
|
47
|
+
A worktree's `.git` is a file, `gitdir: <common>/worktrees/<name>`; a main checkout's is the folder
|
|
48
|
+
itself. Anything else (no git, an unreadable file) stands for itself.
|
|
49
|
+
"""
|
|
50
|
+
dot_git = path / ".git"
|
|
51
|
+
if dot_git.is_dir():
|
|
52
|
+
return dot_git.resolve()
|
|
53
|
+
try:
|
|
54
|
+
first = dot_git.read_text(encoding="utf-8").splitlines()[0]
|
|
55
|
+
except (OSError, IndexError, UnicodeDecodeError):
|
|
56
|
+
return path.resolve()
|
|
57
|
+
if not first.startswith("gitdir:"):
|
|
58
|
+
return path.resolve()
|
|
59
|
+
gitdir = (path / first.removeprefix("gitdir:").strip()).resolve()
|
|
60
|
+
return gitdir.parent.parent if gitdir.parent.name == "worktrees" else gitdir
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def last_written(path: Path) -> int:
|
|
64
|
+
"""When anything in a repository's `.loops` was last written, in nanoseconds; 0 if never."""
|
|
65
|
+
folder = path / FOLDER
|
|
66
|
+
stamps = [0]
|
|
67
|
+
for loop_id in store.loop_ids(path):
|
|
68
|
+
for name in ("loop.json", "events.jsonl", "inbox.jsonl"):
|
|
69
|
+
try:
|
|
70
|
+
stamps.append((folder / loop_id / name).lstat().st_mtime_ns)
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
return max(stamps)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def one_per_repository(paths: list[Path]) -> list[tuple[Path, tuple[Path, ...]]]:
|
|
77
|
+
"""Worktrees of one repository become one entry: the last written, and the others beside it."""
|
|
78
|
+
groups: dict[Path, list[Path]] = {}
|
|
79
|
+
for path in paths:
|
|
80
|
+
groups.setdefault(git_common_dir(path), []).append(path)
|
|
81
|
+
grouped = []
|
|
82
|
+
for members in groups.values():
|
|
83
|
+
ordered = sorted(members, key=last_written, reverse=True)
|
|
84
|
+
grouped.append((ordered[0], tuple(ordered[1:])))
|
|
85
|
+
return grouped
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class LoopView:
|
|
90
|
+
"""One loop as the board shows it: where it lives, where it stands, and what could not be read."""
|
|
91
|
+
|
|
92
|
+
repo: Repo
|
|
93
|
+
loop_id: str
|
|
94
|
+
state: LoopState | None
|
|
95
|
+
problems: tuple[Problem, ...] = ()
|
|
96
|
+
error: str = ""
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def title(self) -> str:
|
|
100
|
+
return (self.state.manifest.title if self.state else "") or self.loop_id
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_config(cwd: Path | None = None) -> Config:
|
|
104
|
+
base = cwd or Path.cwd()
|
|
105
|
+
candidates = [Path(os.environ[CONFIG_ENV])] if os.environ.get(CONFIG_ENV) else []
|
|
106
|
+
candidates += [base / name for name in CONFIG_NAMES]
|
|
107
|
+
candidates.append(USER_CONFIG.expanduser())
|
|
108
|
+
for path in candidates:
|
|
109
|
+
if path.is_file():
|
|
110
|
+
return parse_config(path)
|
|
111
|
+
# No config yet: show the example loop if `make demo` has written it, so a fresh clone shows something.
|
|
112
|
+
demo = base / DEMO_FOLDER
|
|
113
|
+
return Config(repos=(demo.resolve(),) if demo.is_dir() else ())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_config(path: Path) -> Config:
|
|
117
|
+
try:
|
|
118
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
119
|
+
except (OSError, yaml.YAMLError) as error:
|
|
120
|
+
raise StoreError(f"{path.name}: {error}") from error
|
|
121
|
+
if not isinstance(data, dict):
|
|
122
|
+
raise StoreError(f"{path.name}: expected `roots:` and `repos:` lists")
|
|
123
|
+
|
|
124
|
+
def paths(key: str) -> tuple[Path, ...]:
|
|
125
|
+
value = data.get(key) or []
|
|
126
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
127
|
+
raise StoreError(f"{path.name}: `{key}` must be a list of paths")
|
|
128
|
+
return tuple((path.parent / Path(item).expanduser()).resolve() for item in value)
|
|
129
|
+
|
|
130
|
+
return Config(roots=paths("roots"), repos=paths("repos"), source=path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_repos(config: Config) -> list[Repo]:
|
|
134
|
+
"""Every configured repository that has a `.loops` folder, each once, in a stable order."""
|
|
135
|
+
found: list[Path] = []
|
|
136
|
+
for root in config.roots:
|
|
137
|
+
if not root.is_dir():
|
|
138
|
+
continue
|
|
139
|
+
found.extend(child for child in sorted(root.iterdir()) if (child / FOLDER).is_dir())
|
|
140
|
+
found.extend(repo for repo in config.repos if (repo / FOLDER).is_dir())
|
|
141
|
+
grouped = one_per_repository(list(dict.fromkeys(found)))
|
|
142
|
+
names = [path.name for path, _ in grouped]
|
|
143
|
+
repos = []
|
|
144
|
+
for path, others in grouped:
|
|
145
|
+
key = path.name if names.count(path.name) == 1 else f"{path.parent.name}.{path.name}"
|
|
146
|
+
repos.append(Repo(key=safe_key(key), path=path, others=others))
|
|
147
|
+
return repos
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def safe_key(name: str) -> str:
|
|
151
|
+
"""A repository key is a URL part: anything else in a folder name becomes a dash."""
|
|
152
|
+
return "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in name) or "repo"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_loop(repo: Repo, loop_id: str, at: datetime | None = None, process_alive: bool = False) -> LoopView:
|
|
156
|
+
try:
|
|
157
|
+
manifest = store.read_manifest(repo.path, loop_id)
|
|
158
|
+
events, event_problems = store.read_events(repo.path, loop_id)
|
|
159
|
+
inbox, inbox_problems = store.read_inbox(repo.path, loop_id)
|
|
160
|
+
except StoreError as error:
|
|
161
|
+
return LoopView(repo, loop_id, None, error=str(error))
|
|
162
|
+
state = fold(manifest, events, inbox, at or now(), process_alive, untrusted_ids(inbox, loop_id))
|
|
163
|
+
return LoopView(repo, loop_id, state, (*event_problems, *inbox_problems))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_all(config: Config) -> list[LoopView]:
|
|
167
|
+
return [load_loop(repo, loop_id) for repo in find_repos(config) for loop_id in store.loop_ids(repo.path)]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_repo(config: Config, repo_key: str, loop_id: str) -> Repo | None:
|
|
171
|
+
"""The repository behind a URL if it lists this loop, readable or not."""
|
|
172
|
+
return next(
|
|
173
|
+
(
|
|
174
|
+
repo
|
|
175
|
+
for repo in find_repos(config)
|
|
176
|
+
if repo.key == repo_key and loop_id in store.loop_ids(repo.path)
|
|
177
|
+
),
|
|
178
|
+
None,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find_loop(config: Config, repo_key: str, loop_id: str) -> tuple[Repo, Manifest] | None:
|
|
183
|
+
"""The repository and manifest behind a URL, or None. Only configured repositories are reachable."""
|
|
184
|
+
for repo in find_repos(config):
|
|
185
|
+
if repo.key == repo_key and loop_id in store.loop_ids(repo.path):
|
|
186
|
+
try:
|
|
187
|
+
return repo, store.read_manifest(repo.path, loop_id)
|
|
188
|
+
except StoreError:
|
|
189
|
+
return None
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def git_branch(repo: Path) -> str:
|
|
194
|
+
"""The checked-out branch, or "" where git cannot say (no repository, detached, too slow)."""
|
|
195
|
+
try:
|
|
196
|
+
done = subprocess.run(
|
|
197
|
+
["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
198
|
+
capture_output=True,
|
|
199
|
+
text=True,
|
|
200
|
+
timeout=GIT_TIMEOUT_S,
|
|
201
|
+
check=False,
|
|
202
|
+
)
|
|
203
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
204
|
+
return ""
|
|
205
|
+
branch = done.stdout.strip()
|
|
206
|
+
return branch if done.returncode == 0 and branch != "HEAD" else ""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def config_target() -> Path:
|
|
210
|
+
"""The config `watch` and `unwatch` edit: the one `LOOPCAPTAIN_CONFIG` names, else the user's."""
|
|
211
|
+
named = os.environ.get(CONFIG_ENV)
|
|
212
|
+
return Path(named).expanduser() if named else USER_CONFIG.expanduser()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def as_written(path: Path) -> str:
|
|
216
|
+
"""A path as it goes into the config: absolute, the home folder as `~`."""
|
|
217
|
+
resolved = path.expanduser().resolve()
|
|
218
|
+
try:
|
|
219
|
+
return f"~/{resolved.relative_to(Path.home())}"
|
|
220
|
+
except ValueError:
|
|
221
|
+
return str(resolved)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def edit_config(path: Path, *, add: tuple[str, str] | None = None, remove: str | None = None) -> list[str]:
|
|
225
|
+
"""Add `(key, path)` to the config or remove a path from both lists; returns what changed, in words."""
|
|
226
|
+
target = config_target()
|
|
227
|
+
data: dict[str, list[str]] = {"roots": [], "repos": []}
|
|
228
|
+
if target.is_file():
|
|
229
|
+
loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
|
230
|
+
if not isinstance(loaded, dict):
|
|
231
|
+
raise StoreError(f"{target}: expected `roots:` and `repos:` lists")
|
|
232
|
+
data.update({key: list(loaded.get(key) or []) for key in ("roots", "repos")})
|
|
233
|
+
changes: list[str] = []
|
|
234
|
+
if add is not None:
|
|
235
|
+
key, entry = add
|
|
236
|
+
if entry in data[key]:
|
|
237
|
+
return [f"{entry} is already under {key}"]
|
|
238
|
+
data[key].append(entry)
|
|
239
|
+
changes.append(f"added {entry} to {key}")
|
|
240
|
+
if remove is not None:
|
|
241
|
+
for key in ("roots", "repos"):
|
|
242
|
+
if remove in data[key]:
|
|
243
|
+
data[key].remove(remove)
|
|
244
|
+
changes.append(f"removed {remove} from {key}")
|
|
245
|
+
if not changes:
|
|
246
|
+
return [f"{remove} is not in {target}"]
|
|
247
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
target.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
249
|
+
return [f"{target}: {change}" for change in changes]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Which files of a watched repository the board opens, and which it never shows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException
|
|
9
|
+
|
|
10
|
+
#: The largest file the board renders. A round's plan or review is a few kilobytes.
|
|
11
|
+
MAX_FILE_BYTES = 400_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
#: Never shown, though inside the repository: git's own folder and files that hold secrets.
|
|
15
|
+
PRIVATE_PARTS = frozenset(
|
|
16
|
+
{".git", ".ssh", ".aws", ".gnupg", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".docker", ".kube"}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#: Name patterns of files that hold keys or credentials, matched against every part of the path.
|
|
21
|
+
PRIVATE_PATTERNS = (
|
|
22
|
+
".env*",
|
|
23
|
+
"*.pem",
|
|
24
|
+
"*.key",
|
|
25
|
+
"*.p12",
|
|
26
|
+
"*.pfx",
|
|
27
|
+
"*.keystore",
|
|
28
|
+
"*.jks",
|
|
29
|
+
"*.tfstate",
|
|
30
|
+
"*.tfstate.*",
|
|
31
|
+
".htpasswd",
|
|
32
|
+
"id_rsa*",
|
|
33
|
+
"id_ed25519*",
|
|
34
|
+
"id_ecdsa*",
|
|
35
|
+
"credentials*.json",
|
|
36
|
+
"service-account*.json",
|
|
37
|
+
"*-sa-key*.json",
|
|
38
|
+
"secrets.*",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: The board shows a file's content only for these; anything else is named, not opened.
|
|
43
|
+
TEXT_SUFFIXES = frozenset(
|
|
44
|
+
{
|
|
45
|
+
".md",
|
|
46
|
+
".markdown",
|
|
47
|
+
".txt",
|
|
48
|
+
".rst",
|
|
49
|
+
".json",
|
|
50
|
+
".jsonl",
|
|
51
|
+
".yaml",
|
|
52
|
+
".yml",
|
|
53
|
+
".toml",
|
|
54
|
+
".csv",
|
|
55
|
+
".tsv",
|
|
56
|
+
".log",
|
|
57
|
+
".py",
|
|
58
|
+
".sh",
|
|
59
|
+
".js",
|
|
60
|
+
".ts",
|
|
61
|
+
".html",
|
|
62
|
+
".css",
|
|
63
|
+
".sql",
|
|
64
|
+
".r",
|
|
65
|
+
".tex",
|
|
66
|
+
".bib",
|
|
67
|
+
".diff",
|
|
68
|
+
".patch",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_private(part: str) -> bool:
|
|
74
|
+
"""Whether one part of a path names git's folder or a file that holds keys or credentials."""
|
|
75
|
+
lowered = part.lower()
|
|
76
|
+
return lowered in PRIVATE_PARTS or any(
|
|
77
|
+
fnmatch.fnmatchcase(lowered, pattern) for pattern in PRIVATE_PATTERNS
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def inside(repo: Path, relative: str) -> Path:
|
|
82
|
+
"""A path inside the repository, or 404: no link on the board may lead out of it."""
|
|
83
|
+
root = repo.resolve()
|
|
84
|
+
try:
|
|
85
|
+
target = (root / relative).resolve()
|
|
86
|
+
found = bool(relative) and target.is_relative_to(root) and target.is_file()
|
|
87
|
+
except (ValueError, OSError): # a null byte, a name too long
|
|
88
|
+
found = False
|
|
89
|
+
if not found:
|
|
90
|
+
raise HTTPException(404, "no such file in this repository")
|
|
91
|
+
parts = target.relative_to(root).parts
|
|
92
|
+
if any(is_private(part) for part in parts):
|
|
93
|
+
raise HTTPException(404, "no such file in this repository")
|
|
94
|
+
return target
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""How numbers, times and places read on a page: the template filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loopcaptain.protocol.models import now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ago(value: datetime | None, at: datetime | None = None) -> str:
|
|
12
|
+
if value is None:
|
|
13
|
+
return ""
|
|
14
|
+
seconds = int(((at or now()) - value).total_seconds())
|
|
15
|
+
if seconds < 60:
|
|
16
|
+
return "just now"
|
|
17
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "min")):
|
|
18
|
+
if seconds >= size:
|
|
19
|
+
return f"{seconds // size} {unit} ago"
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def usd(value: float) -> str:
|
|
24
|
+
return f"${value:,.2f}" if value else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def minutes(value: int | None) -> str:
|
|
28
|
+
if not value:
|
|
29
|
+
return ""
|
|
30
|
+
return f"{value} min" if value < 120 else f"{value / 60:.1f} h"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tokens(value: int) -> str:
|
|
34
|
+
if not value:
|
|
35
|
+
return ""
|
|
36
|
+
return f"{value / 1_000_000:.1f}M" if value >= 1_000_000 else f"{value / 1000:.0f}k"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stamp(value: datetime | None) -> str:
|
|
40
|
+
return value.astimezone().strftime("%Y-%m-%d %H:%M") if value else ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def place(path: Path) -> str:
|
|
44
|
+
"""Where a repository is, as the person would type it: the home folder as `~`."""
|
|
45
|
+
try:
|
|
46
|
+
return f"~/{path.resolve().relative_to(Path.home())}"
|
|
47
|
+
except ValueError:
|
|
48
|
+
return str(path)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The picture of one round: the manifest's steps laid out left to right, as numbers a template draws.
|
|
2
|
+
|
|
3
|
+
No drawing library: each step gets a column (one further right than the last step it waits for)
|
|
4
|
+
and a row within it, and every edge is one curve.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from loopcaptain.protocol.models import Manifest
|
|
12
|
+
from loopcaptain.protocol.state import RoundState
|
|
13
|
+
|
|
14
|
+
NODE_W = 148
|
|
15
|
+
NODE_H = 46
|
|
16
|
+
GAP_X = 44
|
|
17
|
+
GAP_Y = 14
|
|
18
|
+
PAD = 14
|
|
19
|
+
#: Room under the nodes for the arrow that leads from the last step back to the first.
|
|
20
|
+
RETURN_H = 34
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Node:
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
kind: str
|
|
28
|
+
x: int
|
|
29
|
+
y: int
|
|
30
|
+
#: pending, running, ok, failed, skipped: the step in the round the picture shows.
|
|
31
|
+
state: str = "pending"
|
|
32
|
+
runs: int = 0
|
|
33
|
+
findings: int = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def cx(self) -> int:
|
|
37
|
+
return self.x + NODE_W // 2
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def cy(self) -> int:
|
|
41
|
+
return self.y + NODE_H // 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Graph:
|
|
46
|
+
nodes: tuple[Node, ...]
|
|
47
|
+
edges: tuple[str, ...] # SVG path data
|
|
48
|
+
back: str # the path from the last column back to the first, "" for a single column
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
node_w: int = NODE_W
|
|
52
|
+
node_h: int = NODE_H
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def columns(manifest: Manifest) -> dict[str, int]:
|
|
56
|
+
"""Each step's column: 0 for a step that waits for nothing, else one past its latest predecessor.
|
|
57
|
+
|
|
58
|
+
In waves, without recursion: a loop.json with thousands of chained steps must not exhaust the
|
|
59
|
+
stack. A step left over after as many waves as there are steps sits in a cycle and goes to 0.
|
|
60
|
+
"""
|
|
61
|
+
before: dict[str, list[str]] = {step.id: [] for step in manifest.steps}
|
|
62
|
+
for source, target in manifest.edges():
|
|
63
|
+
before[target].append(source)
|
|
64
|
+
column: dict[str, int] = {}
|
|
65
|
+
for _ in range(len(before)):
|
|
66
|
+
ready = [
|
|
67
|
+
step_id
|
|
68
|
+
for step_id, sources in before.items()
|
|
69
|
+
if step_id not in column and all(name in column for name in sources)
|
|
70
|
+
]
|
|
71
|
+
if not ready:
|
|
72
|
+
break
|
|
73
|
+
for step_id in ready:
|
|
74
|
+
column[step_id] = 1 + max((column[name] for name in before[step_id]), default=-1)
|
|
75
|
+
return {step_id: column.get(step_id, 0) for step_id in before}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def step_state(round_: RoundState | None, step_id: str) -> tuple[str, int, int]:
|
|
79
|
+
"""How a step stands in a round: its state, how many runs it had, how many open material findings."""
|
|
80
|
+
runs = round_.runs_of(step_id) if round_ else ()
|
|
81
|
+
if not runs:
|
|
82
|
+
return "pending", 0, 0
|
|
83
|
+
findings = sum(run.material_open for run in runs)
|
|
84
|
+
statuses = {run.status for run in runs}
|
|
85
|
+
for state in ("running", "failed", "ok"):
|
|
86
|
+
if state in statuses:
|
|
87
|
+
return state, len(runs), findings
|
|
88
|
+
return "skipped", len(runs), findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def layout(manifest: Manifest, round_: RoundState | None = None) -> Graph:
|
|
92
|
+
column = columns(manifest)
|
|
93
|
+
by_column: dict[int, list[str]] = {}
|
|
94
|
+
for step in manifest.steps:
|
|
95
|
+
by_column.setdefault(column[step.id], []).append(step.id)
|
|
96
|
+
tallest = max((len(ids) for ids in by_column.values()), default=1)
|
|
97
|
+
inner_h = tallest * NODE_H + (tallest - 1) * GAP_Y
|
|
98
|
+
|
|
99
|
+
nodes: dict[str, Node] = {}
|
|
100
|
+
for col, ids in by_column.items():
|
|
101
|
+
block = len(ids) * NODE_H + (len(ids) - 1) * GAP_Y
|
|
102
|
+
top = PAD + (inner_h - block) // 2
|
|
103
|
+
for row, step_id in enumerate(ids):
|
|
104
|
+
declared = manifest.step(step_id)
|
|
105
|
+
state, runs, findings = step_state(round_, step_id)
|
|
106
|
+
nodes[step_id] = Node(
|
|
107
|
+
id=step_id,
|
|
108
|
+
name=(declared.name if declared else "") or step_id,
|
|
109
|
+
kind=declared.kind if declared else "other",
|
|
110
|
+
x=PAD + col * (NODE_W + GAP_X),
|
|
111
|
+
y=top + row * (NODE_H + GAP_Y),
|
|
112
|
+
state=state,
|
|
113
|
+
runs=runs,
|
|
114
|
+
findings=findings,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
edges = tuple(curve(nodes[source], nodes[target]) for source, target in manifest.edges())
|
|
118
|
+
last_col = max(by_column, default=0)
|
|
119
|
+
width = 2 * PAD + (last_col + 1) * NODE_W + last_col * GAP_X
|
|
120
|
+
height = 2 * PAD + inner_h
|
|
121
|
+
back = ""
|
|
122
|
+
if last_col > 0:
|
|
123
|
+
floor = height + RETURN_H - 12
|
|
124
|
+
start_x = PAD + last_col * (NODE_W + GAP_X) + NODE_W // 2
|
|
125
|
+
end_x = PAD + NODE_W // 2
|
|
126
|
+
low = PAD + inner_h
|
|
127
|
+
back = (
|
|
128
|
+
f"M {start_x} {low + 4} V {floor - 10} Q {start_x} {floor} {start_x - 10} {floor} "
|
|
129
|
+
f"H {end_x + 10} Q {end_x} {floor} {end_x} {floor - 10} V {low + 8}"
|
|
130
|
+
)
|
|
131
|
+
height += RETURN_H
|
|
132
|
+
return Graph(tuple(nodes.values()), edges, back, width, height)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def curve(source: Node, target: Node) -> str:
|
|
136
|
+
x1, y1 = source.x + NODE_W, source.cy
|
|
137
|
+
x2, y2 = target.x, target.cy
|
|
138
|
+
mid = (x1 + x2) // 2
|
|
139
|
+
return f"M {x1} {y1} C {mid} {y1}, {mid} {y2}, {x2 - 5} {y2}"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The first page: loops grouped by repository, and the filters over them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from loopcaptain.board.discover import LoopView, Repo, git_branch
|
|
8
|
+
from loopcaptain.board.filters import place
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Project:
|
|
13
|
+
"""One repository on the first page, with the loops that live in it."""
|
|
14
|
+
|
|
15
|
+
repo: Repo
|
|
16
|
+
place: str
|
|
17
|
+
branch: str
|
|
18
|
+
views: tuple[LoopView, ...]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def waiting(self) -> int:
|
|
22
|
+
return sum(1 for view in self.views if view.state and view.state.question)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def projects_of(views: list[LoopView]) -> list[Project]:
|
|
26
|
+
"""The loops grouped by repository, a repository where its first loop stands in `views`."""
|
|
27
|
+
grouped: dict[str, list[LoopView]] = {}
|
|
28
|
+
for view in views:
|
|
29
|
+
grouped.setdefault(view.repo.key, []).append(view)
|
|
30
|
+
return [
|
|
31
|
+
Project(found[0].repo, place(found[0].repo.path), git_branch(found[0].repo.path), tuple(found))
|
|
32
|
+
for found in grouped.values()
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Statuses that are over; the overview folds them away unless asked.
|
|
37
|
+
FINISHED = frozenset({"done", "stopped"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def filter_views(
|
|
41
|
+
views: list[LoopView], text: str, status: str, *, show_finished: bool
|
|
42
|
+
) -> tuple[list[LoopView], int]:
|
|
43
|
+
"""The loops the table shows, and how many finished ones were folded away."""
|
|
44
|
+
needle = text.strip().lower()
|
|
45
|
+
|
|
46
|
+
def status_of(view: LoopView) -> str:
|
|
47
|
+
return view.state.status if view.state else "failed"
|
|
48
|
+
|
|
49
|
+
def matches(view: LoopView) -> bool:
|
|
50
|
+
haystack = f"{view.title} {view.loop_id} {view.repo.key}".lower()
|
|
51
|
+
if view.state:
|
|
52
|
+
haystack += " " + view.state.manifest.brief.goal.lower()
|
|
53
|
+
return (not needle or needle in haystack) and (not status or status_of(view) == status)
|
|
54
|
+
|
|
55
|
+
picked = [view for view in views if matches(view)]
|
|
56
|
+
if show_finished or status in FINISHED:
|
|
57
|
+
return picked, 0
|
|
58
|
+
kept = [view for view in picked if status_of(view) not in FINISHED]
|
|
59
|
+
return kept, len(picked) - len(kept)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Two small things, no library: reload the page when a loop's files change, and copy the protocol.
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
var body = document.body;
|
|
5
|
+
var url = body.getAttribute("data-pulse-url");
|
|
6
|
+
var seen = body.getAttribute("data-pulse");
|
|
7
|
+
var POLL_MS = 4000;
|
|
8
|
+
|
|
9
|
+
function busy() {
|
|
10
|
+
// Never reload under someone who is typing an answer or reading a check's history.
|
|
11
|
+
var typed = Array.prototype.some.call(document.querySelectorAll("textarea[name]"), function (field) {
|
|
12
|
+
return field.value.trim() !== "" || field === document.activeElement;
|
|
13
|
+
});
|
|
14
|
+
return typed || document.querySelector("table.items details[open]") !== null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function poll() {
|
|
18
|
+
if (document.hidden) return;
|
|
19
|
+
fetch(url, { cache: "no-store" })
|
|
20
|
+
.then(function (response) { return response.ok ? response.json() : null; })
|
|
21
|
+
.then(function (data) {
|
|
22
|
+
if (data && data.pulse !== seen && !busy()) window.location.reload();
|
|
23
|
+
})
|
|
24
|
+
.catch(function () { /* the board is restarting; the next poll tries again */ });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (url) window.setInterval(poll, POLL_MS);
|
|
28
|
+
|
|
29
|
+
// Every copy button copies its own source and says so beside itself.
|
|
30
|
+
if (navigator.clipboard) {
|
|
31
|
+
document.querySelectorAll("[data-copy-from]").forEach(function (button) {
|
|
32
|
+
button.addEventListener("click", function () {
|
|
33
|
+
var source = document.getElementById(button.getAttribute("data-copy-from"));
|
|
34
|
+
navigator.clipboard.writeText(source.value).then(function () {
|
|
35
|
+
var done = button.parentNode.querySelector("[data-copied]");
|
|
36
|
+
if (done) done.hidden = false;
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
})();
|