lab-kit-cli 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.
- lab_kit/__init__.py +3 -0
- lab_kit/__main__.py +3 -0
- lab_kit/_data/agents/reporter.md +30 -0
- lab_kit/_data/agents/reviewer.md +36 -0
- lab_kit/_data/agents/runner.md +41 -0
- lab_kit/_data/agents/scout.md +28 -0
- lab_kit/_data/method/DISCIPLINE.md +110 -0
- lab_kit/_data/method/LADDER.md +72 -0
- lab_kit/_data/skills/experiment/SKILL.md +80 -0
- lab_kit/_data/skills/plan-mission/SKILL.md +78 -0
- lab_kit/_data/skills/review/SKILL.md +75 -0
- lab_kit/_data/skills/run-mission/SKILL.md +83 -0
- lab_kit/_data/skills/set-up-lab/SKILL.md +81 -0
- lab_kit/checks/__init__.py +1 -0
- lab_kit/checks/base.py +41 -0
- lab_kit/checks/files.py +312 -0
- lab_kit/checks/locks.py +134 -0
- lab_kit/checks/results.py +172 -0
- lab_kit/checks/run.py +63 -0
- lab_kit/cli.py +174 -0
- lab_kit/commands/__init__.py +1 -0
- lab_kit/commands/experiments.py +89 -0
- lab_kit/commands/runs.py +105 -0
- lab_kit/commands/setup.py +126 -0
- lab_kit/commands/status.py +26 -0
- lab_kit/data.py +49 -0
- lab_kit/errors.py +2 -0
- lab_kit/frozen.py +48 -0
- lab_kit/gitlog.py +55 -0
- lab_kit/lab.py +135 -0
- lab_kit/ops.py +94 -0
- lab_kit/protocol.py +100 -0
- lab_kit/records.py +245 -0
- lab_kit/rederive.py +54 -0
- lab_kit/supervise.py +80 -0
- lab_kit_cli-0.1.0.dist-info/METADATA +92 -0
- lab_kit_cli-0.1.0.dist-info/RECORD +40 -0
- lab_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- lab_kit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lab_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: run-mission
|
|
3
|
+
description: >-
|
|
4
|
+
Run an approved mission for a lab, on your own: dispatch and supervise workers, gate their work
|
|
5
|
+
through an independent reviewer, keep the state current, recover after a crash, report to the
|
|
6
|
+
operator, and conclude. Refuses a mission that is still a draft or records no approval. Use at the
|
|
7
|
+
start of every commander session, when the operator says "run it", "go", "carry on", asks "where
|
|
8
|
+
are we", "what's running", "how is the mission going", "resume", and after any restart.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# run-mission
|
|
12
|
+
|
|
13
|
+
You are the commander: the operator's interface to the lab while a mission runs. You delegate, supervise, arbitrate and report. You do not do the workers' work, and you cannot declare a result. The mission was planned and approved with the operator through the plan-mission skill; you carry it out within what it names.
|
|
14
|
+
|
|
15
|
+
## Start
|
|
16
|
+
|
|
17
|
+
1. Find the lab: the nearest `lab.yaml`. Read it, then the lab's `AGENTS.md`.
|
|
18
|
+
2. Read `.lab/method/DISCIPLINE.md` and `.lab/method/LADDER.md`.
|
|
19
|
+
3. Find the library named in `lab.yaml`. Read its charter. Run `folio genres` and `folio workflows`.
|
|
20
|
+
4. Run `lab-kit status`. Read `ops/STATE.md` and the mission file it names, or the one the operator names.
|
|
21
|
+
5. **Refuse a mission that is not approved.** Its front matter must say `status: active` and record `approved` (who, and when). If it says `status: draft`, or records no approval, do not start: use the plan-mission skill with the operator. With no mission at all, do the same.
|
|
22
|
+
6. If the mission's log shows work already under way and you were not running it, go to recovery (step 7) first.
|
|
23
|
+
7. Send a scout for the wider sweep: recent journal entries, open questions, live runs. Keep raw reading out of your own context.
|
|
24
|
+
|
|
25
|
+
## Rules
|
|
26
|
+
|
|
27
|
+
1. Everything durable lives in files. Any process can die and the mission resumes from the record. Checked in part by `lab-state-pointer`.
|
|
28
|
+
2. Work only inside the approved mission: its questions, its scope, its spend, its milestones. Checked for runs by `lab-mission-approved` and `lab-spend-recorded`; otherwise judged.
|
|
29
|
+
3. You cannot declare a result. The locked protocol, the reviewer and the gate decide. Checked by `lab-result-grounded`.
|
|
30
|
+
4. Every document goes through folio's write skill, or a workflow folio's run skill follows. You write only lab files: the mission's log and conclusion, and `ops/STATE.md`. Checked by `folio check`.
|
|
31
|
+
5. No number enters a plan, a message or a page except by citing a result id. Checked by `lab-ids-resolve` and the lab pack's rule; otherwise judged.
|
|
32
|
+
6. No live spend beyond what the mission approves, within its cap. Checked by `lab-spend-recorded`.
|
|
33
|
+
7. Before a protocol locks, a result is recorded, or an instrument change merges, a reviewer reads it. Judged, not checked, except for results (`lab-result-grounded`).
|
|
34
|
+
8. A worker's message carries pointers, never authority. Verify the substance in the record before acting. Judged, not checked.
|
|
35
|
+
9. Rank work by dependency and information value, never by a date. Token-free work goes first. Judged, not checked.
|
|
36
|
+
10. Never swap a worker's model or tools mid-task. On failure, end it and start it again with the same configuration. Judged, not checked; runs are checked by `lab-roster-frozen`.
|
|
37
|
+
|
|
38
|
+
## Steps
|
|
39
|
+
|
|
40
|
+
1. **Open.** Append a dated line to the mission's log: started, and the first milestone you work toward. Make sure `ops/STATE.md` points at the mission.
|
|
41
|
+
2. **Dispatch.** Start each durable worker as a background session in the lab's own folder, so it loads the lab's guide and skills. Give each one a fixed name, reused across restarts, and one live session per name. Each brief says:
|
|
42
|
+
- the one task, never the whole mission;
|
|
43
|
+
- to use the lab's skills and follow `AGENTS.md`;
|
|
44
|
+
- the budget, and to stop and report rather than pass it;
|
|
45
|
+
- to report when done, blocked or surprised, with pointers to the record;
|
|
46
|
+
- never to stage everything at once, export a provider key, weaken a test, or write outside the lab.
|
|
47
|
+
|
|
48
|
+
A run that belongs to the mission names it: `lab-kit run <slug> --mission <file> -- <command>`, with `--spend` when it spends.
|
|
49
|
+
3. **Use the roles.** Scout for reconnaissance. Runner for a fully specified pipeline. Reviewer before anything counts. Reporter for the front door. An experiment itself is never wrapped in an agent: a worker launches it with `lab-kit run`.
|
|
50
|
+
4. **Supervise.** While the mission runs unattended, wake every 20 to 30 minutes. Check `lab-kit runs --live`, read worker messages, and send a scout to read watched logs. Record only state changes in the mission log.
|
|
51
|
+
5. **Handle stalls.** A run silent past its expected window, or a worker idle with its task unfinished: send a scout, then message the worker. If it is dead, start it again by the same name, pointing to the record.
|
|
52
|
+
6. **Gate through review.** Send a reviewer with fresh context. Its verdict comes to you only. If implementer and reviewer disagree, decide on the evidence, or stop and ask the operator. Record the verdict in the mission log, and give the experiment's `score.yaml` its pointer.
|
|
53
|
+
7. **Recover.** On any restart: read the state and the mission file. Run `lab-kit runs` and find orphaned runs: close each honestly or resume it with the same configuration. Start dead workers again by name. Append a recovery note to the mission log.
|
|
54
|
+
8. **Keep the record and report.** Keep `ops/STATE.md` current: phase, active mission, workers, next step, what is blocked on the operator. After a milestone, a blocker change or a recorded result, send the reporter to update the front door. Tell the operator in plain terms: the question, the result by id, the decision needed, your recommendation and one line of why. Leave commit hashes and worker choreography out.
|
|
55
|
+
9. **Conclude.** When every milestone's observable holds, or the mission cannot go further: stop the remaining workers, send a final reviewer pass over anything not yet recorded, and send the reporter. Write the mission's conclusion: what landed, what did not, spend against the cap, open threads. Set its `status` to `concluded`. Record it: `folio journal add --title .. --description .. --body .. --kind decision --about <ids it touched>`. Set `ops/STATE.md` to "no active mission" with the next step. Run `lab-kit check`.
|
|
56
|
+
|
|
57
|
+
## Stops
|
|
58
|
+
|
|
59
|
+
You stop and ask the operator only here. Everything else you decide and record.
|
|
60
|
+
|
|
61
|
+
- Each stop the approved mission names.
|
|
62
|
+
- Work outside the mission's scope, or a question it does not serve. A new objective goes back through plan-mission.
|
|
63
|
+
- A change to a frozen surface, a lock, the gate or a reviewer's verdict. Only the operator decides.
|
|
64
|
+
- Spend past the mission's cap, or any spend the mission does not approve.
|
|
65
|
+
- Publishing anything outside the lab, or retracting or reframing a recorded result or claim.
|
|
66
|
+
- When unsure whether something is yours to decide, it is not. Hold that thread, notify the operator, and continue other work.
|
|
67
|
+
|
|
68
|
+
## Done when
|
|
69
|
+
|
|
70
|
+
- The mission file has its conclusion and `status: concluded`, and its log ends with the closing entry.
|
|
71
|
+
- `ops/STATE.md` points to no active mission and names the next step.
|
|
72
|
+
- No run is orphaned and no worker is left running.
|
|
73
|
+
- `lab-kit check` passes.
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
- `lab-kit status`: the state, the active mission, live and orphaned runs, protocols by status.
|
|
78
|
+
- `lab-kit runs [--live]`: every run and its state.
|
|
79
|
+
- `lab-kit run <slug> --mission <file> [--spend] -- <command>`: a run under the mission; refused for a draft mission.
|
|
80
|
+
- `lab-kit check`: the lab gate, `folio check` included.
|
|
81
|
+
- `folio genres`, `folio workflows`: what the library offers.
|
|
82
|
+
- `folio cite <id>`: the path and link for an id you cite in a mission file.
|
|
83
|
+
- `folio journal add --title ".." --description ".." --body ".." --kind <kind> --about <id>,..`: writes one journal entry, a new file each time.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: set-up-lab
|
|
3
|
+
description: >-
|
|
4
|
+
Turn a repository into a research lab run by lab-kit, once: a folio library with the lab pack,
|
|
5
|
+
the lab's files, its question as Q-1, its frozen surfaces, its operating guide, CI and a passing gate.
|
|
6
|
+
Asks at most three questions in one message. Use when the user says "set up a lab", "make this a lab",
|
|
7
|
+
"start a lab for <question>", "install lab-kit", "lab-kit init", or pastes the lab-kit install prompt.
|
|
8
|
+
If `lab.yaml` already exists here, the lab is set up: use the plan-mission or run-mission skill instead.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# set-up-lab
|
|
12
|
+
|
|
13
|
+
Create the lab's library and files, record its question, and leave `lab-kit check` passing.
|
|
14
|
+
|
|
15
|
+
## Start
|
|
16
|
+
|
|
17
|
+
1. Look for `lab.yaml` at or above the working folder. If one exists, the lab is set up: stop. Use the plan-mission skill for a new objective, or the run-mission skill for an approved one.
|
|
18
|
+
2. Confirm both engines run: `lab-kit version` and `folio version`. Installing `lab-kit-cli` brings folio as a dependency, but a tool installer may not put the `folio` command on the path. If `folio version` fails, install it too: `uv tool install folio-kb` or `pipx install folio-kb`.
|
|
19
|
+
3. Confirm this skill is in `.agents/skills/set-up-lab/`. If it is not, run `lab-kit init` once with no arguments: before a library exists, it installs only lab-kit's method, skills and roles.
|
|
20
|
+
4. Look for a library: a `folio.yaml` in the repository root or in `docs/`. If one exists, reuse it: skip the location question, and in step 3 below run only the parts of folio's set-up skill it lacks.
|
|
21
|
+
|
|
22
|
+
## Rules
|
|
23
|
+
|
|
24
|
+
1. Ask at most three questions, all in one message, each with a default. Skip any the request already answered, so setup takes one round.
|
|
25
|
+
2. Every document goes through folio's skills: set-up for the charter, write for the front door and the question. Never write a document by hand.
|
|
26
|
+
3. The library's `root` is the repository root, so a result's paths and lab-kit's paths are read from the same place. `lab-kit init` refuses otherwise.
|
|
27
|
+
4. Only the operator names frozen surfaces. Never freeze a path the operator did not name.
|
|
28
|
+
5. Record the setup once, in one journal entry of kind `decision`, so the lab's first decision is on the record.
|
|
29
|
+
6. Do not run an experiment, spend tokens on a live run, or push anything. Setup ends at a passing gate and a commit.
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. **Ask the questions.** In one message, ask only what you do not know yet:
|
|
34
|
+
1. What is the lab's question, in one sentence? And its name? Default name: the repository's folder name.
|
|
35
|
+
2. Where should the library live? Default: `docs/`, with the lab's files at the repository root. The other choice is the repository root itself.
|
|
36
|
+
3. Which paths, if any, are frozen surfaces: inputs no one may edit in place, such as a vendored benchmark or a dataset? Default: none.
|
|
37
|
+
2. **Make sure it is a repository.** If the folder is not inside a git work tree, run `git init`. folio installs its skills at the work tree's root, beside lab-kit's.
|
|
38
|
+
3. **Create the library with folio.** Run `folio init <dir> --name "<name>" --purpose "<the question, in one sentence>"`. It writes the charter, the home page and folio's seven skills. Then read `.agents/skills/set-up/SKILL.md`, folio's set-up skill, and follow it for the rest of the charter, with these answers:
|
|
39
|
+
- the reader: whoever will read the lab's results, and what they already know;
|
|
40
|
+
- the pack: `lab`;
|
|
41
|
+
- `root`: `..` when the library is in `docs/`, nothing when it is the repository root.
|
|
42
|
+
|
|
43
|
+
Skip its journal entry and its commit: step 10 records and commits the whole setup once.
|
|
44
|
+
4. **Install the lab.** Run `lab-kit init --library <dir>`. It writes `lab.yaml`, `ops/STATE.md`, `ops/missions/`, `experiments/` and `.lab/`, restores the method, the skills and the roles to this version, links `.claude/agents` to `.agents/agents`, and switches the lab pack on if set-up did not.
|
|
45
|
+
5. **Add the front door.** Through folio's write skill, add a project document `lab`. Its description states the question. Its `reviewed` date is today, its state says the lab is set up and no mission is active, and its next step is the operator's first objective. Create a top-level map, `research`, through the write skill, keep it `live`, add the front door to it, and set it on the home page: `folio config set home.maps research`. Set `front: lab` in `lab.yaml`.
|
|
46
|
+
6. **Add the question.** Through the write skill, add the question as `Q-1`, with rank 1, why it matters, what would settle it, and what would make the lab drop it. Add it to the `research` map.
|
|
47
|
+
7. **Freeze the surfaces.** List each path the operator named under `frozen:` in `lab.yaml`, then run `lab-kit freeze <path>` for each. With none, leave `frozen: []`.
|
|
48
|
+
8. **Write the operating guide.** Write `AGENTS.md` at the repository root, and make `CLAUDE.md` the one line `@AGENTS.md`. `AGENTS.md` states the question and its id, says to read `.lab/method/DISCIPLINE.md` and `.lab/method/LADDER.md` before any work, says where the library is, names the frozen surfaces, and names `lab-kit check` as the gate. Keep it under a screen: the method holds the rules.
|
|
49
|
+
9. **Add CI.** Make CI run `lab-kit check` on every push and pull request. On GitHub, a workflow in `.github/workflows/` that installs `lab-kit` with pip and runs `lab-kit check` from the root.
|
|
50
|
+
10. **Record and check.** Run `folio journal add --title "Lab set up" --description "<the question, in one line>" --body "<where the library is, the frozen surfaces>" --kind decision --about lab,Q-1` in the library. Run `folio index` there, then `lab-kit check` from the root. Fix what it names until it reports no error.
|
|
51
|
+
11. **Commit.** Add exactly what setup wrote: the library (`folio.yaml`, `.gitignore`, `content/`, `assets/`, `.folio/`), `lab.yaml`, `ops/`, `experiments/.gitkeep`, `.lab/`, `.agents/`, `.claude/`, `AGENTS.md`, `CLAUDE.md`, the CI workflow, and any `.gitignore` setup changed. Never `git add -A`. Commit with one line saying the lab was set up.
|
|
52
|
+
12. **Tell the operator, in three lines,** where the library is, the question's id and the frozen surfaces, and end with "Tell me the first mission and we will plan it together." Example: "The lab is set up: the library is in docs/, the question is Q-1, nothing is frozen. Tell me the first mission and we will plan it together."
|
|
53
|
+
|
|
54
|
+
## Stops
|
|
55
|
+
|
|
56
|
+
- `lab.yaml` already exists: the lab is set up. Use the plan-mission or run-mission skill.
|
|
57
|
+
- `lab-kit version` or `folio version` fails and installing does not fix it. Say so and stop.
|
|
58
|
+
- A library exists but its `root` is not the repository root, and changing it would break its documents' paths. Ask the operator.
|
|
59
|
+
- The operator names a frozen surface that does not exist yet. Ask whether to freeze it once it does.
|
|
60
|
+
|
|
61
|
+
## Done when
|
|
62
|
+
|
|
63
|
+
- The library has the lab pack on and its `root` at the repository root.
|
|
64
|
+
- `lab.yaml` names the library, `front: lab`, and the frozen surfaces, each recorded in `.lab/frozen.sha256`.
|
|
65
|
+
- The front door `lab` and the question `Q-1` exist, on the `research` map.
|
|
66
|
+
- `AGENTS.md`, `CLAUDE.md` and the CI workflow exist.
|
|
67
|
+
- One `decision` journal entry records the setup.
|
|
68
|
+
- `lab-kit check` reports no error, and the setup is committed.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
- `lab-kit version` and `folio version` confirm the engines.
|
|
73
|
+
- `lab-kit init`: before a library exists, installs the method, the skills and the roles only.
|
|
74
|
+
- `folio init <dir> --name ".." --purpose ".."` creates the library and installs folio's skills.
|
|
75
|
+
- `folio config set <key> <value>` writes the charter: `reader`, `root`, `home.maps`.
|
|
76
|
+
- `folio pack on lab` switches the lab pack on.
|
|
77
|
+
- `lab-kit init --library <dir>` installs the lab.
|
|
78
|
+
- `lab-kit freeze <path>` records a frozen surface's hashes.
|
|
79
|
+
- `folio journal add --title ".." --description ".." --body ".." --kind decision --about lab,Q-1` records the setup.
|
|
80
|
+
- `folio index` regenerates the indices.
|
|
81
|
+
- `lab-kit check` runs the lab gate: `folio check`, then the lab checks.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The lab gate's checks."""
|
lab_kit/checks/base.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""What every lab check shares: its name, its default severity, and how it reports a problem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from folio.checks.problems import Problem
|
|
9
|
+
|
|
10
|
+
from ..errors import LabError
|
|
11
|
+
from ..lab import Lab
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Reporter:
|
|
16
|
+
check: str
|
|
17
|
+
severity: str # the check's default: "error" or "warning"
|
|
18
|
+
problems: list[Problem] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
def add(self, path: str, message: str, severity: str | None = None) -> None:
|
|
21
|
+
self.problems.append(Problem(path, severity or self.severity, self.check, message))
|
|
22
|
+
|
|
23
|
+
def warn(self, path: str, message: str) -> None:
|
|
24
|
+
self.add(path, message, "warning")
|
|
25
|
+
|
|
26
|
+
def guard(self, path: str, action: Callable[[], None]) -> None:
|
|
27
|
+
"""Run one item's part of a check; a file lab-kit cannot read becomes a problem on that file."""
|
|
28
|
+
try:
|
|
29
|
+
action()
|
|
30
|
+
except LabError as exc:
|
|
31
|
+
self.add(path, str(exc))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CheckFn = Callable[[Lab, Reporter], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LabCheck:
|
|
39
|
+
name: str
|
|
40
|
+
severity: str
|
|
41
|
+
fn: CheckFn
|
lab_kit/checks/files.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Checks on the lab's own files: ids, frozen surfaces, selftests, spend, history, method and state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from folio.checks.rules import measurements
|
|
11
|
+
|
|
12
|
+
from .. import __version__, data, frozen, gitlog, ops, records
|
|
13
|
+
from ..records import script_env
|
|
14
|
+
from ..errors import LabError
|
|
15
|
+
from ..lab import Lab
|
|
16
|
+
from .base import Reporter
|
|
17
|
+
|
|
18
|
+
STATE_WORDS = 300
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _id_pattern(lab: Lab) -> re.Pattern[str] | None:
|
|
22
|
+
prefixes = sorted(lab.library().prefixes)
|
|
23
|
+
if not prefixes:
|
|
24
|
+
return None
|
|
25
|
+
return re.compile(r"(?<![\w-])(?:" + "|".join(re.escape(p) for p in prefixes) + r")-\d+\b")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ids_resolve(lab: Lab, rep: Reporter) -> None:
|
|
29
|
+
"""Every id cited in a lab file resolves to a document in the library."""
|
|
30
|
+
lib = lab.library()
|
|
31
|
+
pattern = _id_pattern(lab)
|
|
32
|
+
|
|
33
|
+
def resolve(where: str, ident: object, what: str) -> None:
|
|
34
|
+
if not isinstance(ident, str) or not ident or not lib.find(ident):
|
|
35
|
+
rep.add(where, f"{what} `{ident}` names no document in the library")
|
|
36
|
+
|
|
37
|
+
def text_ids(where: str, text: str) -> None:
|
|
38
|
+
if pattern is None:
|
|
39
|
+
return
|
|
40
|
+
for ident in sorted(set(pattern.findall(text))):
|
|
41
|
+
resolve(where, ident, "cites")
|
|
42
|
+
|
|
43
|
+
if lab.settings.front:
|
|
44
|
+
resolve("lab.yaml", lab.settings.front, "`front`")
|
|
45
|
+
state = lab.root / ops.STATE
|
|
46
|
+
if state.is_file():
|
|
47
|
+
text_ids(ops.STATE, state.read_text(encoding="utf-8"))
|
|
48
|
+
for path in ops.mission_files(lab):
|
|
49
|
+
where = lab.rel(path)
|
|
50
|
+
|
|
51
|
+
def mission(path=path, where=where) -> None:
|
|
52
|
+
m = ops.read_mission(lab, path)
|
|
53
|
+
rests = m.meta.get("rests_on") or []
|
|
54
|
+
for ident in rests if isinstance(rests, list) else [rests]:
|
|
55
|
+
resolve(where, ident, "`rests_on`")
|
|
56
|
+
text_ids(where, m.text)
|
|
57
|
+
rep.guard(where, mission)
|
|
58
|
+
for slug in lab.experiment_slugs():
|
|
59
|
+
lock_where = f"experiments/{slug}/{records.LOCK}"
|
|
60
|
+
|
|
61
|
+
def lock(slug=slug, where=lock_where) -> None:
|
|
62
|
+
record = records.read_lock(lab, slug)
|
|
63
|
+
if record is not None:
|
|
64
|
+
resolve(where, record.protocol, "`protocol`")
|
|
65
|
+
rep.guard(lock_where, lock)
|
|
66
|
+
for run in records.runs(lab, slug):
|
|
67
|
+
resolve(run.path, run.data.get("protocol"), "`protocol`")
|
|
68
|
+
score_path = records.score_path(lab, slug)
|
|
69
|
+
if score_path.is_file():
|
|
70
|
+
where = lab.rel(score_path)
|
|
71
|
+
|
|
72
|
+
def score(score_path=score_path, where=where) -> None:
|
|
73
|
+
card = records.read_yaml(score_path, where)
|
|
74
|
+
resolve(where, card.get("protocol"), "`protocol`")
|
|
75
|
+
for name, entry in (card.get("record") or {}).items():
|
|
76
|
+
if isinstance(entry, dict):
|
|
77
|
+
if "protocol" in entry:
|
|
78
|
+
resolve(where, entry["protocol"], f"`record.{name}.protocol`")
|
|
79
|
+
if entry.get("supersedes"):
|
|
80
|
+
resolve(where, entry["supersedes"], f"`record.{name}.supersedes`")
|
|
81
|
+
text_ids(where, score_path.read_text(encoding="utf-8"))
|
|
82
|
+
rep.guard(where, score)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def frozen_intact(lab: Lab, rep: Reporter) -> None:
|
|
86
|
+
"""Every frozen surface matches its hashes in .lab/frozen.sha256."""
|
|
87
|
+
try:
|
|
88
|
+
recorded = frozen.recorded(lab)
|
|
89
|
+
except LabError as exc:
|
|
90
|
+
rep.add(frozen.FROZEN, str(exc))
|
|
91
|
+
return
|
|
92
|
+
surfaces = [s.rstrip("/") for s in lab.settings.frozen]
|
|
93
|
+
for path in sorted(recorded):
|
|
94
|
+
if not any(frozen.belongs(path, s) for s in surfaces):
|
|
95
|
+
rep.add(frozen.FROZEN, f"records {path}, which no surface under `frozen:` in lab.yaml covers")
|
|
96
|
+
for surface in surfaces:
|
|
97
|
+
mine = {p: d for p, d in recorded.items() if frozen.belongs(p, surface)}
|
|
98
|
+
if not mine:
|
|
99
|
+
rep.add("lab.yaml", f"frozen surface `{surface}` has no recorded hash; the operator runs "
|
|
100
|
+
f"`lab-kit freeze {surface}`")
|
|
101
|
+
continue
|
|
102
|
+
target = lab.root / surface
|
|
103
|
+
actual = records.tree_hashes(target, lab.root) if target.exists() else {}
|
|
104
|
+
for path in sorted(set(mine) | set(actual)):
|
|
105
|
+
if path not in actual:
|
|
106
|
+
rep.add(path, f"is gone from frozen surface `{surface}`")
|
|
107
|
+
elif path not in mine:
|
|
108
|
+
rep.add(path, f"was added to frozen surface `{surface}` after it was frozen")
|
|
109
|
+
elif mine[path] != actual[path]:
|
|
110
|
+
rep.add(path, f"changed: frozen surface `{surface}` is never edited in place")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _command(path: Path) -> list[str] | None:
|
|
114
|
+
if path.suffix == ".py":
|
|
115
|
+
return [sys.executable, str(path)]
|
|
116
|
+
if path.suffix == ".sh":
|
|
117
|
+
return ["bash", str(path)]
|
|
118
|
+
if path.stat().st_mode & 0o111:
|
|
119
|
+
return [str(path)]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def scripts(lab: Lab) -> list[Path]:
|
|
124
|
+
found: list[Path] = []
|
|
125
|
+
for slug in lab.experiment_slugs():
|
|
126
|
+
folder = lab.experiments / slug / "bin"
|
|
127
|
+
if folder.is_dir():
|
|
128
|
+
found.extend(p for p in sorted(folder.iterdir())
|
|
129
|
+
if p.is_file() and not p.name.startswith(".") and p.suffix not in (".pyc", ".md"))
|
|
130
|
+
found.extend(lab.root / t for t in lab.settings.tools)
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def selftest(lab: Lab, rep: Reporter) -> None:
|
|
135
|
+
"""Every script in experiments/*/bin/ and under `tools:` passes --selftest."""
|
|
136
|
+
for path in scripts(lab):
|
|
137
|
+
where = lab.rel(path) if path.exists() else path.relative_to(lab.root).as_posix()
|
|
138
|
+
if not path.is_file():
|
|
139
|
+
rep.add("lab.yaml", f"tool `{where}` does not exist")
|
|
140
|
+
continue
|
|
141
|
+
command = _command(path)
|
|
142
|
+
if command is None:
|
|
143
|
+
rep.add(where, "is neither a .py or .sh script nor executable, so it cannot run --selftest")
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
done = subprocess.run([*command, "--selftest"], cwd=lab.root, capture_output=True, text=True,
|
|
147
|
+
timeout=lab.settings.rederive_timeout, check=False, env=script_env())
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
rep.add(where, f"--selftest ran past {lab.settings.rederive_timeout}s")
|
|
150
|
+
continue
|
|
151
|
+
if done.returncode != 0:
|
|
152
|
+
tail = (done.stderr.strip() or done.stdout.strip()).splitlines()[-1:] or [""]
|
|
153
|
+
rep.add(where, f"--selftest exited {done.returncode}: {tail[0]}".rstrip(": "))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mission_approved(lab: Lab, rep: Reporter) -> None:
|
|
157
|
+
"""Missions are draft, active or concluded; an active or concluded one records who approved it and when;
|
|
158
|
+
nothing runs under a draft, and the state never names a draft as active."""
|
|
159
|
+
statuses: dict[str, str] = {}
|
|
160
|
+
for path in ops.mission_files(lab):
|
|
161
|
+
where = lab.rel(path)
|
|
162
|
+
try:
|
|
163
|
+
mission = ops.read_mission(lab, path)
|
|
164
|
+
except LabError as exc:
|
|
165
|
+
rep.add(where, str(exc))
|
|
166
|
+
continue
|
|
167
|
+
statuses[where] = mission.status
|
|
168
|
+
if mission.status not in ops.STATUSES:
|
|
169
|
+
rep.add(where, f"`status: {mission.status or '(none)'}` is not one of {', '.join(ops.STATUSES)}")
|
|
170
|
+
continue
|
|
171
|
+
if mission.status == "draft":
|
|
172
|
+
continue
|
|
173
|
+
if not mission.approved:
|
|
174
|
+
rep.add(where, f"is {mission.status} but records no approval: `approved:` names who approved "
|
|
175
|
+
"the mission and when (the plan-mission skill records it)")
|
|
176
|
+
elif not ops.approved_names_a_date(mission.approved):
|
|
177
|
+
rep.add(where, f"`approved: {mission.approved}` must name who approved the mission and the date, "
|
|
178
|
+
"as YYYY-MM-DD")
|
|
179
|
+
for run in records.runs(lab):
|
|
180
|
+
named = run.data.get("mission")
|
|
181
|
+
if isinstance(named, str) and statuses.get(named) == "draft":
|
|
182
|
+
rep.add(run.path, f"runs under {named}, a draft mission: nothing runs before the operator approves it")
|
|
183
|
+
state = lab.root / ops.STATE
|
|
184
|
+
if state.is_file():
|
|
185
|
+
active = ops.active_mission(state.read_text(encoding="utf-8"))
|
|
186
|
+
if active and statuses.get(active) == "draft":
|
|
187
|
+
rep.add(ops.STATE, f"names {active} as the active mission, but it is a draft awaiting approval")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def spend_recorded(lab: Lab, rep: Reporter) -> None:
|
|
191
|
+
"""A spending run names a mission with the operator's approval and a cap, and stays within the cap."""
|
|
192
|
+
spent_by_mission: dict[str, float] = {}
|
|
193
|
+
caps: dict[str, float] = {}
|
|
194
|
+
for run in records.runs(lab):
|
|
195
|
+
if run.data.get("spend") is not True:
|
|
196
|
+
continue
|
|
197
|
+
mission_path = run.data.get("mission")
|
|
198
|
+
if not isinstance(mission_path, str) or not mission_path:
|
|
199
|
+
rep.add(run.path, "spends but names no mission; a spending run needs one with the operator's approval")
|
|
200
|
+
continue
|
|
201
|
+
target = lab.root / mission_path
|
|
202
|
+
if not target.is_file():
|
|
203
|
+
rep.add(run.path, f"spends under mission {mission_path}, which does not exist")
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
mission = ops.read_mission(lab, target)
|
|
207
|
+
except LabError as exc:
|
|
208
|
+
rep.add(mission_path, str(exc))
|
|
209
|
+
continue
|
|
210
|
+
if not mission.approved:
|
|
211
|
+
rep.add(mission_path, f"records no operator approval (`approved:`), yet run {run.path} spends")
|
|
212
|
+
if mission.cap is None:
|
|
213
|
+
rep.add(mission_path, f"records no numeric `cap:`, yet run {run.path} spends")
|
|
214
|
+
continue
|
|
215
|
+
caps[mission_path] = mission.cap
|
|
216
|
+
spent = run.data.get("spent")
|
|
217
|
+
if run.ended:
|
|
218
|
+
if not isinstance(spent, (int, float)) or isinstance(spent, bool):
|
|
219
|
+
rep.add(run.path, "spent but recorded no spend: its command writes "
|
|
220
|
+
f"out/{records.SPEND} with `spent`")
|
|
221
|
+
continue
|
|
222
|
+
if isinstance(spent, (int, float)) and not isinstance(spent, bool):
|
|
223
|
+
spent_by_mission[mission_path] = spent_by_mission.get(mission_path, 0.0) + float(spent)
|
|
224
|
+
for mission_path, total in sorted(spent_by_mission.items()):
|
|
225
|
+
if total > caps[mission_path]:
|
|
226
|
+
rep.add(mission_path, f"its runs spent {total:g}, past its cap of {caps[mission_path]:g}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _lab_files(lab: Lab) -> list[str]:
|
|
230
|
+
"""The append-only lab files: missions, lock records and run records, now or ever committed."""
|
|
231
|
+
current = [lab.rel(p) for p in ops.mission_files(lab)]
|
|
232
|
+
for slug in lab.experiment_slugs():
|
|
233
|
+
if records.lock_path(lab, slug).is_file():
|
|
234
|
+
current.append(f"experiments/{slug}/{records.LOCK}")
|
|
235
|
+
current.extend(run.path for run in records.runs(lab))
|
|
236
|
+
pattern = re.compile(r"^(ops/missions/[^/]+\.md|experiments/[^/]+/lock\.json|"
|
|
237
|
+
r"experiments/[^/]+/runs/[^/]+/run\.json)$")
|
|
238
|
+
ever = [p for folder in ("ops/missions", "experiments") for p in gitlog.tracked(lab.root, folder)
|
|
239
|
+
if pattern.match(p)]
|
|
240
|
+
return sorted(set(current) | set(ever))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def append_only(lab: Lab, rep: Reporter) -> None:
|
|
244
|
+
"""Committed mission log entries, lock records and run records are unchanged in later commits."""
|
|
245
|
+
if not gitlog.in_git(lab.root):
|
|
246
|
+
rep.warn("lab.yaml", "the lab is not in a git repository, so lab-append-only could not be checked")
|
|
247
|
+
return
|
|
248
|
+
for path in _lab_files(lab):
|
|
249
|
+
history = gitlog.versions(lab.root, path)
|
|
250
|
+
committed = [(sha, text) for sha, text in history if sha != gitlog.WORKING]
|
|
251
|
+
if not committed:
|
|
252
|
+
continue
|
|
253
|
+
first_sha, first = committed[0]
|
|
254
|
+
if path.startswith("ops/missions/"):
|
|
255
|
+
previous = ops.log_of(first or "")
|
|
256
|
+
for sha, text in history[1:]:
|
|
257
|
+
label = "the working tree" if sha == gitlog.WORKING else f"commit {sha[:7]}"
|
|
258
|
+
if text is None:
|
|
259
|
+
rep.add(path, f"was deleted in {label}; a mission file is never deleted")
|
|
260
|
+
break
|
|
261
|
+
log = ops.log_of(text)
|
|
262
|
+
if previous is not None and (log is None or not log.startswith(previous.rstrip())):
|
|
263
|
+
rep.add(path, f"its log was changed in {label}: entries are only ever added at the bottom")
|
|
264
|
+
break
|
|
265
|
+
previous = log
|
|
266
|
+
continue
|
|
267
|
+
for sha, text in history[1:]:
|
|
268
|
+
label = "the working tree" if sha == gitlog.WORKING else f"commit {sha[:7]}"
|
|
269
|
+
if text != first:
|
|
270
|
+
what = "deleted" if text is None else "changed"
|
|
271
|
+
rep.add(path, f"was {what} in {label} after its first commit {first_sha[:7]}; "
|
|
272
|
+
"lock and run records never change once committed")
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def method_current(lab: Lab, rep: Reporter) -> None:
|
|
277
|
+
"""The method, the five skills and the four roles match the lab-kit version in lab.yaml."""
|
|
278
|
+
if lab.settings.version != __version__:
|
|
279
|
+
rep.add("lab.yaml", f"`lab-kit: {lab.settings.version}`, but this is lab-kit {__version__}; "
|
|
280
|
+
"install that version, or run `lab-kit init` to move the lab to this one")
|
|
281
|
+
return
|
|
282
|
+
for src, dest in data.shipped_files():
|
|
283
|
+
target = lab.root / dest
|
|
284
|
+
if not target.is_file():
|
|
285
|
+
rep.add(dest, f"is missing; lab-kit {__version__} ships it (`lab-kit init` restores it)")
|
|
286
|
+
elif target.read_bytes() != src.read_bytes():
|
|
287
|
+
rep.add(dest, f"differs from what lab-kit {__version__} ships; it is never hand-edited "
|
|
288
|
+
"(`lab-kit init` restores it)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def state_pointer(lab: Lab, rep: Reporter) -> None:
|
|
292
|
+
"""ops/STATE.md names an active mission or none, stays short, and cites ids for its numbers."""
|
|
293
|
+
path = lab.root / ops.STATE
|
|
294
|
+
if not path.is_file():
|
|
295
|
+
rep.add(ops.STATE, "does not exist; it says what is true now and points to the active mission")
|
|
296
|
+
return
|
|
297
|
+
text = path.read_text(encoding="utf-8")
|
|
298
|
+
active = ops.active_mission(text)
|
|
299
|
+
if active is None:
|
|
300
|
+
rep.add(ops.STATE, "has no `Active mission:` line naming a mission file or `none`")
|
|
301
|
+
elif active.lower() != "none" and not (lab.root / active).is_file():
|
|
302
|
+
rep.add(ops.STATE, f"names active mission `{active}`, which does not exist")
|
|
303
|
+
elif active.lower() != "none" and not active.startswith(ops.MISSIONS + "/"):
|
|
304
|
+
rep.add(ops.STATE, f"names active mission `{active}`, which is not under {ops.MISSIONS}/")
|
|
305
|
+
words = len(text.split())
|
|
306
|
+
if words > STATE_WORDS:
|
|
307
|
+
rep.add(ops.STATE, f"has {words} words, past {STATE_WORDS}: it is a pointer, never a record")
|
|
308
|
+
pattern = _id_pattern(lab)
|
|
309
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
310
|
+
found = measurements(line)
|
|
311
|
+
if found and (pattern is None or not pattern.search(line)):
|
|
312
|
+
rep.add(ops.STATE, f"line {number}: `{found[0]}` cites no id; a number in the state cites its result")
|
lab_kit/checks/locks.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Checks on locks and runs: the lock is recorded and intact, and every run follows it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import protocol as protocol_mod
|
|
6
|
+
from .. import records
|
|
7
|
+
from ..errors import LabError
|
|
8
|
+
from ..lab import Lab
|
|
9
|
+
from .base import Reporter
|
|
10
|
+
|
|
11
|
+
LOCKED_STATES = ("locked", "abandoned")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def lock_recorded(lab: Lab, rep: Reporter) -> None:
|
|
15
|
+
"""Every locked protocol has a lock.json naming it; every lock.json names a locked or abandoned protocol."""
|
|
16
|
+
lib = lab.library()
|
|
17
|
+
for doc in lib.documents:
|
|
18
|
+
if not doc.is_a("protocol") or doc.status != "locked":
|
|
19
|
+
continue
|
|
20
|
+
where = lab.doc_path(doc.main.path) if doc.main else doc.key
|
|
21
|
+
|
|
22
|
+
def one(doc=doc, where=where) -> None:
|
|
23
|
+
lock = records.read_lock(lab, doc.id)
|
|
24
|
+
if lock is None:
|
|
25
|
+
rep.add(where, f"is locked but experiments/{doc.id}/{records.LOCK} does not exist; "
|
|
26
|
+
f"run `lab-kit lock {doc.id}`")
|
|
27
|
+
elif lock.protocol.casefold() != doc.id.casefold():
|
|
28
|
+
rep.add(lock.path, f"names protocol `{lock.protocol}`, not `{doc.id}`, the experiment it sits in")
|
|
29
|
+
rep.guard(where, one)
|
|
30
|
+
for slug in lab.experiment_slugs():
|
|
31
|
+
path = f"experiments/{slug}/{records.LOCK}"
|
|
32
|
+
|
|
33
|
+
def check_lock(slug=slug, path=path) -> None:
|
|
34
|
+
lock = records.read_lock(lab, slug)
|
|
35
|
+
if lock is None:
|
|
36
|
+
return
|
|
37
|
+
proto = protocol_mod.find(lab, lock.protocol)
|
|
38
|
+
if proto is None:
|
|
39
|
+
rep.add(path, f"names protocol `{lock.protocol}`, which is not in the library")
|
|
40
|
+
elif proto.status not in LOCKED_STATES:
|
|
41
|
+
rep.add(path, f"names protocol `{lock.protocol}`, whose status is `{proto.status}`, "
|
|
42
|
+
"not locked or abandoned")
|
|
43
|
+
rep.guard(path, check_lock)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def lock_intact(lab: Lab, rep: Reporter) -> None:
|
|
47
|
+
"""A locked protocol, its status set aside, hashes to the value in its lock.json."""
|
|
48
|
+
for slug in lab.experiment_slugs():
|
|
49
|
+
path = f"experiments/{slug}/{records.LOCK}"
|
|
50
|
+
|
|
51
|
+
def one(slug=slug, path=path) -> None:
|
|
52
|
+
lock = records.read_lock(lab, slug)
|
|
53
|
+
if lock is None:
|
|
54
|
+
return
|
|
55
|
+
proto = protocol_mod.find(lab, lock.protocol)
|
|
56
|
+
if proto is None:
|
|
57
|
+
return # lab-lock-recorded names it
|
|
58
|
+
actual = protocol_mod.hash_text(proto.text())
|
|
59
|
+
if actual != lock.sha256:
|
|
60
|
+
rep.add(proto.path, f"changed since its lock: its hash, status set aside, is {actual[:12]}, "
|
|
61
|
+
f"and {path} records {lock.sha256[:12]}")
|
|
62
|
+
rep.guard(path, one)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_after_lock(lab: Lab, rep: Reporter) -> None:
|
|
66
|
+
"""Every run belongs to a locked experiment, records the lock's hash, and started after the lock."""
|
|
67
|
+
for where in records.runs_without_record(lab):
|
|
68
|
+
rep.add(where, f"is a run folder with no {records.RUN}")
|
|
69
|
+
for slug in lab.experiment_slugs():
|
|
70
|
+
def per_experiment(slug=slug) -> None:
|
|
71
|
+
lock = records.read_lock(lab, slug)
|
|
72
|
+
for run in records.runs(lab, slug):
|
|
73
|
+
data = run.data
|
|
74
|
+
if lock is None:
|
|
75
|
+
rep.add(run.path, f"belongs to experiment `{slug}`, which has no {records.LOCK}: "
|
|
76
|
+
"no run starts before the lock")
|
|
77
|
+
continue
|
|
78
|
+
if data.get("protocol") != lock.protocol:
|
|
79
|
+
rep.add(run.path, f"names protocol `{data.get('protocol')}`, not `{lock.protocol}`")
|
|
80
|
+
if data.get("lock") != lock.sha256:
|
|
81
|
+
rep.add(run.path, "records a lock hash that is not the one in "
|
|
82
|
+
f"experiments/{slug}/{records.LOCK}")
|
|
83
|
+
try:
|
|
84
|
+
started = records.parse_moment(data.get("started"), run.path + ": `started`")
|
|
85
|
+
except LabError as exc:
|
|
86
|
+
rep.add(run.path, str(exc))
|
|
87
|
+
continue
|
|
88
|
+
if started < lock.locked:
|
|
89
|
+
rep.add(run.path, f"started at {data.get('started')}, before the lock at "
|
|
90
|
+
f"{lock.data.get('locked')}")
|
|
91
|
+
rep.guard(f"experiments/{slug}", per_experiment)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def roster_frozen(lab: Lab, rep: Reporter) -> None:
|
|
95
|
+
"""Every run's configuration equals its locked protocol's pinned configuration."""
|
|
96
|
+
for slug in lab.experiment_slugs():
|
|
97
|
+
def per_experiment(slug=slug) -> None:
|
|
98
|
+
lock = records.read_lock(lab, slug)
|
|
99
|
+
proto = protocol_mod.find(lab, lock.protocol if lock else slug)
|
|
100
|
+
if proto is None:
|
|
101
|
+
return
|
|
102
|
+
pinned = protocol_mod.pinned_config(proto.text(), proto.path)
|
|
103
|
+
for run in records.runs(lab, slug):
|
|
104
|
+
if run.data.get("config") != pinned:
|
|
105
|
+
rep.add(run.path, f"its `config` differs from the yaml block under `## Pinned configuration` "
|
|
106
|
+
f"in {proto.path}: a run uses exactly the pinned configuration")
|
|
107
|
+
rep.guard(f"experiments/{slug}", per_experiment)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def evidence_sealed(lab: Lab, rep: Reporter) -> None:
|
|
111
|
+
"""Every finished run's out/ matches its manifest; a run with no end and no live process is orphaned."""
|
|
112
|
+
for run in records.runs(lab):
|
|
113
|
+
def one(run=run) -> None:
|
|
114
|
+
state = run.state
|
|
115
|
+
if state == "orphaned":
|
|
116
|
+
rep.warn(run.path, "has no end and no live process: it is orphaned; close it honestly or "
|
|
117
|
+
"start a fresh run with the same configuration")
|
|
118
|
+
return
|
|
119
|
+
if state == "running":
|
|
120
|
+
return
|
|
121
|
+
manifest_path = run.path.rsplit("/", 1)[0] + "/" + records.MANIFEST
|
|
122
|
+
if not run.manifest.is_file():
|
|
123
|
+
rep.add(manifest_path, "is missing for a run that ended")
|
|
124
|
+
return
|
|
125
|
+
recorded = records.parse_hashes(run.manifest.read_text(encoding="utf-8"), manifest_path)
|
|
126
|
+
actual = records.tree_hashes(run.out, run.out)
|
|
127
|
+
for name in sorted(set(recorded) | set(actual)):
|
|
128
|
+
if name not in actual:
|
|
129
|
+
rep.add(manifest_path, f"lists out/{name}, which is gone")
|
|
130
|
+
elif name not in recorded:
|
|
131
|
+
rep.add(manifest_path, f"does not list out/{name}, added after the run ended")
|
|
132
|
+
elif recorded[name] != actual[name]:
|
|
133
|
+
rep.add(manifest_path, f"out/{name} changed after the run ended")
|
|
134
|
+
rep.guard(run.path, one)
|