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,275 @@
|
|
|
1
|
+
"""Reading and writing a loop's folder, `.loops/<loop-id>/` in its repository.
|
|
2
|
+
|
|
3
|
+
The files are the truth; nothing here keeps state. A line the models refuse is reported, never
|
|
4
|
+
fatal: a loop that writes one bad line must not blank its own page.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import stat
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from loopcaptain.protocol.models import (
|
|
20
|
+
EVENT,
|
|
21
|
+
EVENTS_FILE,
|
|
22
|
+
FOLDER,
|
|
23
|
+
INBOX_FILE,
|
|
24
|
+
MANIFEST_FILE,
|
|
25
|
+
AnyEvent,
|
|
26
|
+
InboxEntry,
|
|
27
|
+
InboxKind,
|
|
28
|
+
InboxRead,
|
|
29
|
+
Manifest,
|
|
30
|
+
is_slug,
|
|
31
|
+
now,
|
|
32
|
+
)
|
|
33
|
+
from loopcaptain.protocol.signing import sign
|
|
34
|
+
|
|
35
|
+
#: The most the board reads of one file. A summary log of a long loop is a few hundred kilobytes.
|
|
36
|
+
MAX_LOG_BYTES = 20_000_000
|
|
37
|
+
MAX_MANIFEST_BYTES = 1_000_000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StoreError(Exception):
|
|
41
|
+
"""A loop folder that cannot be read or written as asked."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Problem:
|
|
46
|
+
"""One thing in a loop's folder the board could not read."""
|
|
47
|
+
|
|
48
|
+
file: str
|
|
49
|
+
line: int # 0 for the whole file
|
|
50
|
+
message: str
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
where = f"{self.file}:{self.line}" if self.line else self.file
|
|
54
|
+
return f"{where}: {self.message}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def loop_dir(repo: Path, loop_id: str) -> Path:
|
|
58
|
+
if not is_slug(loop_id):
|
|
59
|
+
raise StoreError(f"{loop_id!r} is not a loop id")
|
|
60
|
+
return repo / FOLDER / loop_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def speaks_protocol(manifest: Path) -> bool:
|
|
64
|
+
"""Whether a loop.json is this protocol's: it carries the `protocol` field.
|
|
65
|
+
|
|
66
|
+
Other tools keep their own files in a folder called `.loops/`; those are skipped quietly.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(read_bounded(manifest, MAX_MANIFEST_BYTES))
|
|
70
|
+
except (FileNotFoundError, StoreError, ValueError):
|
|
71
|
+
return False
|
|
72
|
+
return isinstance(data, dict) and "protocol" in data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def loop_ids(repo: Path) -> list[str]:
|
|
76
|
+
"""The loops a repository holds: every sub-folder of `.loops/` whose loop.json speaks the protocol."""
|
|
77
|
+
folder = repo / FOLDER
|
|
78
|
+
if folder.is_symlink() or not folder.is_dir():
|
|
79
|
+
return [] # a `.loops` that is a link could show another repository's loops as this one's
|
|
80
|
+
return sorted(
|
|
81
|
+
child.name
|
|
82
|
+
for child in folder.iterdir()
|
|
83
|
+
if is_slug(child.name) and not child.is_symlink() and speaks_protocol(child / MANIFEST_FILE)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def short_error(error: ValidationError) -> str:
|
|
88
|
+
"""A validation error in one line, naming the field."""
|
|
89
|
+
parts = []
|
|
90
|
+
for item in error.errors()[:3]:
|
|
91
|
+
where = ".".join(str(part) for part in item["loc"])
|
|
92
|
+
parts.append(f"{where}: {item['msg']}" if where else item["msg"])
|
|
93
|
+
return "; ".join(parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- the manifest ----
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_manifest(repo: Path, loop_id: str) -> Manifest:
|
|
100
|
+
path = loop_dir(repo, loop_id) / MANIFEST_FILE
|
|
101
|
+
try:
|
|
102
|
+
manifest = Manifest.model_validate_json(read_bounded(path, MAX_MANIFEST_BYTES))
|
|
103
|
+
except FileNotFoundError as error:
|
|
104
|
+
raise StoreError(f"{MANIFEST_FILE}: {error.strerror or error}") from error
|
|
105
|
+
except ValidationError as error:
|
|
106
|
+
raise StoreError(f"{MANIFEST_FILE}: {short_error(error)}") from error
|
|
107
|
+
if manifest.id != loop_id:
|
|
108
|
+
raise StoreError(f"{MANIFEST_FILE}: id {manifest.id!r} does not match its folder {loop_id!r}")
|
|
109
|
+
return manifest
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def contained(repo: Path, folder: Path, loop_id: str) -> None:
|
|
113
|
+
"""Refuse a loop folder that resolves outside its repository, through a link on the way."""
|
|
114
|
+
if not folder.resolve().is_relative_to(repo.resolve()):
|
|
115
|
+
raise StoreError(f"the folder of {loop_id!r} leads out of {repo.name}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_manifest(repo: Path, manifest: Manifest) -> Path:
|
|
119
|
+
"""Write `loop.json` whole or not at all: a reader never sees half a file."""
|
|
120
|
+
folder = loop_dir(repo, manifest.id)
|
|
121
|
+
contained(repo, folder, manifest.id)
|
|
122
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
contained(repo, folder, manifest.id)
|
|
124
|
+
path = folder / MANIFEST_FILE
|
|
125
|
+
tmp = folder / f"{MANIFEST_FILE}.{secrets.token_hex(4)}.tmp"
|
|
126
|
+
tmp.write_text(manifest.model_dump_json(indent=2, exclude_none=True) + "\n", encoding="utf-8")
|
|
127
|
+
os.replace(tmp, path)
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---- events and inbox: one JSON object per line ----
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def append_line(path: Path, payload: str) -> None:
|
|
135
|
+
"""One write call on a file opened for append: lines from two writers do not interleave.
|
|
136
|
+
|
|
137
|
+
A symbolic link is refused: a repository must not be able to point its inbox at another file.
|
|
138
|
+
"""
|
|
139
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
try:
|
|
141
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
142
|
+
except OSError as error:
|
|
143
|
+
raise StoreError(f"{path.name}: cannot be written ({error.strerror or error})") from error
|
|
144
|
+
try:
|
|
145
|
+
os.write(descriptor, (payload + "\n").encode("utf-8"))
|
|
146
|
+
finally:
|
|
147
|
+
os.close(descriptor)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_bounded(path: Path, limit: int) -> str:
|
|
151
|
+
"""A regular file's text, up to `limit` bytes; anything else, a link too, is refused before it is opened.
|
|
152
|
+
|
|
153
|
+
The board reads these files on every request, so a pipe that never ends or a log of gigabytes
|
|
154
|
+
in one repository must not stall or fill it.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
info = path.lstat()
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
raise
|
|
160
|
+
except OSError as error:
|
|
161
|
+
raise StoreError(f"{path.name}: {error.strerror or error}") from error
|
|
162
|
+
if not stat.S_ISREG(info.st_mode):
|
|
163
|
+
raise StoreError(f"{path.name}: not a regular file")
|
|
164
|
+
if info.st_size > limit:
|
|
165
|
+
raise StoreError(
|
|
166
|
+
f"{path.name}: {info.st_size // 1_000_000} MB, over the {limit // 1_000_000} MB the board reads"
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
# Bytes that are not text become a marker, so one bad line is reported and the rest reads.
|
|
170
|
+
return path.read_bytes().decode("utf-8", errors="replace")
|
|
171
|
+
except OSError as error:
|
|
172
|
+
raise StoreError(f"{path.name}: {error.strerror or error}") from error
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def complete_lines(path: Path) -> list[tuple[int, str]]:
|
|
176
|
+
"""The complete lines of a file with their numbers. A last line still being written is left out."""
|
|
177
|
+
try:
|
|
178
|
+
text = read_bounded(path, MAX_LOG_BYTES)
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
return []
|
|
181
|
+
whole = text if text.endswith("\n") else text[: text.rfind("\n") + 1]
|
|
182
|
+
return [(n, line) for n, line in enumerate(whole.splitlines(), start=1) if line.strip()]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_event(data: dict[str, Any]) -> AnyEvent:
|
|
186
|
+
return EVENT.validate_python(data)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def append_event(repo: Path, loop_id: str, event: AnyEvent) -> None:
|
|
190
|
+
append_line(loop_dir(repo, loop_id) / EVENTS_FILE, event.model_dump_json(exclude_none=True))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_events(repo: Path, loop_id: str) -> tuple[list[AnyEvent], list[Problem]]:
|
|
194
|
+
events: list[AnyEvent] = []
|
|
195
|
+
problems: list[Problem] = []
|
|
196
|
+
for number, line in complete_lines(loop_dir(repo, loop_id) / EVENTS_FILE):
|
|
197
|
+
try:
|
|
198
|
+
events.append(EVENT.validate_json(line))
|
|
199
|
+
except ValidationError as error:
|
|
200
|
+
problems.append(Problem(EVENTS_FILE, number, short_error(error)))
|
|
201
|
+
return events, problems
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def append_inbox(
|
|
205
|
+
repo: Path,
|
|
206
|
+
loop_id: str,
|
|
207
|
+
kind: InboxKind,
|
|
208
|
+
text: str = "",
|
|
209
|
+
round_: int | None = None,
|
|
210
|
+
key: bytes | None = None,
|
|
211
|
+
question: str | None = None,
|
|
212
|
+
choice: str | None = None,
|
|
213
|
+
) -> InboxEntry:
|
|
214
|
+
"""What the person sends, signed with `key` when given. The loop's folder must exist: the board
|
|
215
|
+
never creates a loop."""
|
|
216
|
+
folder = loop_dir(repo, loop_id)
|
|
217
|
+
if not (folder / MANIFEST_FILE).is_file():
|
|
218
|
+
raise StoreError(f"no loop {loop_id!r} in {repo.name}")
|
|
219
|
+
contained(repo, folder, loop_id)
|
|
220
|
+
stamp = now()
|
|
221
|
+
entry = InboxEntry(
|
|
222
|
+
id=f"{stamp:%Y%m%dT%H%M%S}-{secrets.token_hex(2)}",
|
|
223
|
+
ts=stamp,
|
|
224
|
+
kind=kind,
|
|
225
|
+
text=text.strip(),
|
|
226
|
+
round=round_,
|
|
227
|
+
question=question,
|
|
228
|
+
choice=choice,
|
|
229
|
+
loop=loop_id,
|
|
230
|
+
)
|
|
231
|
+
if key is not None:
|
|
232
|
+
entry = entry.model_copy(update={"sig": sign(entry.body(), key)})
|
|
233
|
+
append_line(folder / INBOX_FILE, entry.model_dump_json(exclude_none=True))
|
|
234
|
+
return entry
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def read_inbox(repo: Path, loop_id: str) -> tuple[list[InboxEntry], list[Problem]]:
|
|
238
|
+
entries: list[InboxEntry] = []
|
|
239
|
+
problems: list[Problem] = []
|
|
240
|
+
for number, line in complete_lines(loop_dir(repo, loop_id) / INBOX_FILE):
|
|
241
|
+
try:
|
|
242
|
+
entries.append(InboxEntry.model_validate_json(line))
|
|
243
|
+
except ValidationError as error:
|
|
244
|
+
problems.append(Problem(INBOX_FILE, number, short_error(error)))
|
|
245
|
+
return entries, problems
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def take_inbox(repo: Path, loop_id: str) -> list[InboxEntry]:
|
|
249
|
+
"""What the person sent that the loop has not taken yet, oldest first; marks it read.
|
|
250
|
+
|
|
251
|
+
The loop's side of the inbox: it appends `inbox_read` to the loop's own events.
|
|
252
|
+
"""
|
|
253
|
+
events, _ = read_events(repo, loop_id)
|
|
254
|
+
taken = {entry_id for event in events if isinstance(event, InboxRead) for entry_id in event.ids}
|
|
255
|
+
entries, _ = read_inbox(repo, loop_id)
|
|
256
|
+
fresh = [entry for entry in entries if entry.id not in taken]
|
|
257
|
+
if fresh:
|
|
258
|
+
append_event(repo, loop_id, InboxRead(ts=now(), ids=[entry.id for entry in fresh]))
|
|
259
|
+
return fresh
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fingerprint(repo: Path, loop_id: str) -> str:
|
|
263
|
+
"""Changes whenever any of the loop's files does; the page polls it to know when to reload."""
|
|
264
|
+
parts = []
|
|
265
|
+
for name in (MANIFEST_FILE, EVENTS_FILE, INBOX_FILE):
|
|
266
|
+
try:
|
|
267
|
+
stat = (loop_dir(repo, loop_id) / name).stat()
|
|
268
|
+
parts.append(f"{stat.st_mtime_ns}-{stat.st_size}")
|
|
269
|
+
except OSError:
|
|
270
|
+
parts.append("0")
|
|
271
|
+
return ".".join(parts)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def dump(data: object) -> str:
|
|
275
|
+
return json.dumps(data, ensure_ascii=False)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""`loopcaptain validate`: check a repository's `.loops` folders against the protocol.
|
|
2
|
+
|
|
3
|
+
A problem breaks the text of LOOPS.md or makes the board show something false; a warning is a sign
|
|
4
|
+
that a loop is wired in a way that hides what really happens (a round written after the fact, an
|
|
5
|
+
inbox nobody reads). `--strict` counts warnings as problems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from loopcaptain.protocol import store
|
|
15
|
+
from loopcaptain.protocol.models import (
|
|
16
|
+
FOLDER,
|
|
17
|
+
MANIFEST_FILE,
|
|
18
|
+
AnyEvent,
|
|
19
|
+
InboxEntry,
|
|
20
|
+
InboxRead,
|
|
21
|
+
Manifest,
|
|
22
|
+
RoundFinished,
|
|
23
|
+
RoundStarted,
|
|
24
|
+
StepFinished,
|
|
25
|
+
StepStarted,
|
|
26
|
+
)
|
|
27
|
+
from loopcaptain.protocol.store import StoreError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Report:
|
|
32
|
+
"""What `validate` found, problems and warnings apart, one line each."""
|
|
33
|
+
|
|
34
|
+
problems: list[str] = field(default_factory=list)
|
|
35
|
+
warnings: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def extend(self, prefix: str, other: Report) -> None:
|
|
38
|
+
self.problems.extend(f"{prefix}{line}" for line in other.problems)
|
|
39
|
+
self.warnings.extend(f"{prefix}{line}" for line in other.warnings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_repo(repo: Path) -> list[str]:
|
|
43
|
+
"""Every problem found, one line each; empty when the folders are sound."""
|
|
44
|
+
return validate_report(repo).problems
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_report(repo: Path) -> Report:
|
|
48
|
+
"""Problems and warnings of every loop in a repository.
|
|
49
|
+
|
|
50
|
+
Only folders whose loop.json carries the `protocol` field are loops; anything else another
|
|
51
|
+
tool keeps under the same name is not this protocol's business.
|
|
52
|
+
"""
|
|
53
|
+
folder = repo / FOLDER
|
|
54
|
+
if not folder.is_dir():
|
|
55
|
+
return Report(problems=[f"{repo}: no {FOLDER} folder"])
|
|
56
|
+
loops = store.loop_ids(repo)
|
|
57
|
+
if not loops:
|
|
58
|
+
return Report(
|
|
59
|
+
problems=[
|
|
60
|
+
f"{FOLDER}: no loop inside "
|
|
61
|
+
f"(a loop is a sub-folder whose {MANIFEST_FILE} has a protocol field)"
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
report = Report()
|
|
65
|
+
for loop_id in loops:
|
|
66
|
+
report.extend(f"{loop_id}/", validate_loop(repo, loop_id))
|
|
67
|
+
return report
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_loop(repo: Path, loop_id: str) -> Report:
|
|
71
|
+
try:
|
|
72
|
+
manifest = store.read_manifest(repo, loop_id)
|
|
73
|
+
events, event_problems = store.read_events(repo, loop_id)
|
|
74
|
+
inbox, inbox_problems = store.read_inbox(repo, loop_id)
|
|
75
|
+
except StoreError as error:
|
|
76
|
+
return Report(problems=[str(error)])
|
|
77
|
+
report = Report(problems=[str(problem) for problem in (*event_problems, *inbox_problems)])
|
|
78
|
+
report.problems += order_problems(manifest, events)
|
|
79
|
+
report.problems += missing_paths(repo, manifest, events)
|
|
80
|
+
report.problems += time_problems(events)
|
|
81
|
+
report.problems += verdict_problems(events)
|
|
82
|
+
report.warnings += back_produced(events)
|
|
83
|
+
report.warnings += missing_outputs(repo, manifest)
|
|
84
|
+
report.warnings += unread_inbox(events, inbox)
|
|
85
|
+
report.warnings += restarted_count(events)
|
|
86
|
+
return Report(list(dict.fromkeys(report.problems)), list(dict.fromkeys(report.warnings)))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def unread_inbox(events: list[AnyEvent], inbox: list[InboxEntry]) -> list[str]:
|
|
90
|
+
"""Entries the person sent before a round began that the loop never took: it does not listen."""
|
|
91
|
+
taken = {entry_id for event in events if isinstance(event, InboxRead) for entry_id in event.ids}
|
|
92
|
+
starts = [event.ts for event in events if isinstance(event, RoundStarted)]
|
|
93
|
+
missed = [entry for entry in inbox if entry.id not in taken and starts and entry.ts < max(starts)]
|
|
94
|
+
if not missed:
|
|
95
|
+
return []
|
|
96
|
+
count = f"{len(missed)} entr{'y' if len(missed) == 1 else 'ies'}"
|
|
97
|
+
return [f"inbox.jsonl: {count} sent before a round began and never taken; does the loop read it?"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def order_problems(manifest: Manifest, events: list[AnyEvent]) -> list[str]:
|
|
101
|
+
declared = {step.id for step in manifest.steps}
|
|
102
|
+
started: set[int] = set()
|
|
103
|
+
problems: list[str] = []
|
|
104
|
+
unknown: list[str] = []
|
|
105
|
+
for event in events:
|
|
106
|
+
if isinstance(event, RoundStarted):
|
|
107
|
+
started.add(event.round)
|
|
108
|
+
elif isinstance(event, RoundFinished):
|
|
109
|
+
if event.round not in started:
|
|
110
|
+
problems.append(f"events.jsonl: round {event.round} finished without round_started")
|
|
111
|
+
elif isinstance(event, StepStarted | StepFinished):
|
|
112
|
+
if event.round not in started:
|
|
113
|
+
problems.append(
|
|
114
|
+
f"events.jsonl: step {event.step!r} reported for round {event.round} before it started"
|
|
115
|
+
)
|
|
116
|
+
if declared and event.step not in declared and event.step not in unknown:
|
|
117
|
+
unknown.append(event.step)
|
|
118
|
+
return problems + [f"events.jsonl: step {step!r} is not in loop.json" for step in unknown]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def missing_paths(repo: Path, manifest: Manifest, events: list[AnyEvent]) -> list[str]:
|
|
122
|
+
"""Paths the loop names as written that are not there: the board would link to nothing."""
|
|
123
|
+
problems = [
|
|
124
|
+
f"loop.json: {path} does not exist"
|
|
125
|
+
for path in (manifest.brief.path, *(step.prompt for step in manifest.steps))
|
|
126
|
+
if path and not (repo / path).exists()
|
|
127
|
+
]
|
|
128
|
+
for event in events:
|
|
129
|
+
if isinstance(event, StepFinished) and event.artifact and not (repo / event.artifact).exists():
|
|
130
|
+
problems.append(
|
|
131
|
+
f"events.jsonl: round {event.round} step {event.step!r}: {event.artifact} does not exist"
|
|
132
|
+
)
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def missing_outputs(repo: Path, manifest: Manifest) -> list[str]:
|
|
137
|
+
"""Outputs may not exist before the loop is done, so a missing one is a warning."""
|
|
138
|
+
return [
|
|
139
|
+
f"loop.json: output {output.name!r} at {output.path} does not exist yet"
|
|
140
|
+
for output in manifest.outputs
|
|
141
|
+
if not (repo / output.path).exists()
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def time_problems(events: list[AnyEvent]) -> list[str]:
|
|
146
|
+
problems = []
|
|
147
|
+
latest: datetime | None = None
|
|
148
|
+
for number, event in enumerate(events, start=1):
|
|
149
|
+
if latest and event.ts < latest:
|
|
150
|
+
problems.append(f"events.jsonl: event {number} ({event.event}) is earlier than the one before it")
|
|
151
|
+
latest = max(latest, event.ts) if latest else event.ts
|
|
152
|
+
return problems
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def verdict_problems(events: list[AnyEvent]) -> list[str]:
|
|
156
|
+
verdicts: dict[int, str] = {}
|
|
157
|
+
problems = []
|
|
158
|
+
for event in events:
|
|
159
|
+
if not isinstance(event, RoundFinished):
|
|
160
|
+
continue
|
|
161
|
+
earlier = verdicts.get(event.round)
|
|
162
|
+
verdicts[event.round] = event.verdict
|
|
163
|
+
# `wait` is a pause, not an outcome: a round that waited may finish again.
|
|
164
|
+
if earlier and earlier != "wait" and earlier != event.verdict:
|
|
165
|
+
problems.append(
|
|
166
|
+
f"events.jsonl: round {event.round} finished twice, as {earlier} and as {event.verdict}"
|
|
167
|
+
)
|
|
168
|
+
return problems
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def back_produced(events: list[AnyEvent]) -> list[str]:
|
|
172
|
+
"""A round whose steps all carry one time was written after the fact: its durations are invented."""
|
|
173
|
+
times: dict[int, set[datetime]] = {}
|
|
174
|
+
counts: dict[int, int] = {}
|
|
175
|
+
for event in events:
|
|
176
|
+
if isinstance(event, StepFinished):
|
|
177
|
+
times.setdefault(event.round, set()).add(event.when)
|
|
178
|
+
counts[event.round] = counts.get(event.round, 0) + 1
|
|
179
|
+
return [
|
|
180
|
+
f"events.jsonl: round {number}: all {counts[number]} steps share one time; written after the fact?"
|
|
181
|
+
for number, stamps in sorted(times.items())
|
|
182
|
+
if counts[number] > 1 and len(stamps) == 1
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def restarted_count(events: list[AnyEvent]) -> list[str]:
|
|
187
|
+
"""A round started below one already started: a second run counting from the start in one folder."""
|
|
188
|
+
highest = 0
|
|
189
|
+
warnings = []
|
|
190
|
+
for event in events:
|
|
191
|
+
if not isinstance(event, RoundStarted):
|
|
192
|
+
continue
|
|
193
|
+
if event.round < highest:
|
|
194
|
+
warnings.append(
|
|
195
|
+
f"events.jsonl: round {event.round} started after round {highest}; "
|
|
196
|
+
"a new run? give each run its own loop id"
|
|
197
|
+
)
|
|
198
|
+
highest = max(highest, event.round)
|
|
199
|
+
return warnings
|