Task 01 told Ornith to add up every `test result:` N passed. A greedy sed kept the last digit (1512 became 1022) and it stopped with the correct T1 and given test uncommitted. T50/T59: the last line of make gate is the count; summing is not a stop. Co-Authored-By: Grok 4.6 <noreply@x.ai>
221 lines
8.5 KiB
Markdown
221 lines
8.5 KiB
Markdown
# M5d-2 task 05: job files and the run ledger
|
|
|
|
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
|
|
is empty; otherwise stop. Never switch branch.
|
|
**Commit subject:** `loopd: job files, run ledger, jobs-invalid`
|
|
**Log row:** the Task cell is `M5d2/05-loopd-jobs`.
|
|
|
|
## Goal
|
|
|
|
Owner-written `{home}/jobs/<name>.toml` and a run ledger `{home}/jobs/runs.jsonl` (spec 4.2).
|
|
`loopd` reads job files and cannot write them. Unknown keys, a name that is not `[a-z0-9-]+`,
|
|
or a name so long that `job-<name>-<yyyymmdd>t<hhmm>` is not a `SessionId`, are
|
|
`jobs-invalid`. A missing jobs directory is an empty list.
|
|
|
|
Nothing ticks yet. `loopd serve` refuses to start when `load` errors, so a misspelt job cannot
|
|
sit silently.
|
|
|
|
## Every place that changes
|
|
|
|
| Place | What happens there |
|
|
|---|---|
|
|
| `crates/loopd/src/jobs.rs` | new: `Job`, `Run`, `RunStatus`, `load`, `session_id`, `append`, `close_leftovers`, `live_session` |
|
|
| `crates/loopd/src/lib.rs` | `pub mod jobs;` |
|
|
| `crates/loopd/src/main.rs` | `run_serve` calls `jobs::load` after `check_summarise` |
|
|
| `docs/runbook.md` | append the given `jobs-invalid` entry |
|
|
| `crates/loopd/tests/jobs.rs` | new, given |
|
|
| everything else | **no change** |
|
|
|
|
## Files
|
|
|
|
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
|
|
`crates/loopd/tests/jobs.rs`.
|
|
- Append, from `docs/plans/M5d2/files/runbook-jobs-invalid.md`, to `docs/runbook.md`.
|
|
- Create: `crates/loopd/src/jobs.rs` (skeleton below).
|
|
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/implementer-log.md`.
|
|
- Do not touch: any other file.
|
|
|
|
## Facts you need
|
|
|
|
- **`name` from the file stem.** `mail.toml` → `mail`. The stem must match `^[a-z0-9-]+$`
|
|
(bytes, nonempty) and `session_id(name, now)` must succeed. `Mail.toml` is `JobsError::Name`.
|
|
- **`session_id`.** `format!("job-{name}-{}t{}", ymd, hm)` with `ymd` = `rfc[0..4]+rfc[5..7]+rfc[8..10]`
|
|
and `hm` = `rfc[11..13]+rfc[14..16]`, all from `now.to_rfc3339()`, then
|
|
`SessionId::new`. `SessionId` is `[a-z0-9-]`, max 64. `"n".repeat(50)` is too long (`job-` +
|
|
50 + `-` + 8 + `t` + 4 = 68).
|
|
- **`load`.** `read_dir`. `ErrorKind::NotFound` → `Ok(vec![])`. Any other IO error is
|
|
`JobsError::Invalid` naming the directory. Skip names that do not end in `.toml`. Sort by
|
|
`name` so the order is stable. Parse with `#[serde(deny_unknown_fields)]` on a private
|
|
`File { schedule, prompt }`.
|
|
- **`RunStatus`.** `started` / `finished` / `failed` (`rename_all = "snake_case"`).
|
|
- **`append`.** Create the parent directory (0700 is fine; do not weaken a tighter mode). One
|
|
JSON object, then a newline. Open append, write, `sync_all`.
|
|
- **`live_session`.** Walk every line of the ledger (missing file → `Ok(None)`). The last
|
|
`Run` whose `name` matches: `Started` → `Some(session)`, anything else → `None`.
|
|
- **`close_leftovers`.** Names whose last run is `Started` get one more `Run` with
|
|
`status: Failed` and the same `session`, `time: now`. Return how many names were closed.
|
|
A missing file is `Ok(0)`.
|
|
- **`Display` for `Invalid`.** `"{path}: {detail}; see docs/runbook.md#jobs-invalid"`. The
|
|
given unknown-keys test checks `ends_with("see docs/runbook.md#jobs-invalid")` and that
|
|
the path's file name is in the string.
|
|
- **`run_serve`.** After `check_summarise`, `jobs::load(&cfg.paths.home.join("jobs"))`. On
|
|
`Err`, print `loopd: {e}` and return exit 1. Do not create the directory here.
|
|
- A file that exists but cannot be read is an error. Only a missing directory is empty.
|
|
- The items the edits use. This table is complete.
|
|
|
|
| Item | What it does |
|
|
|---|---|
|
|
| `toml::from_str` | already a `loopd` dependency |
|
|
| `SessionId::new` | the 64-byte alphabet check |
|
|
| `serde` `deny_unknown_fields` | unknown keys |
|
|
|
|
## Skeleton
|
|
|
|
`crates/loopd/src/jobs.rs` — fill every function; no `todo!()`. Keep the types in this order.
|
|
|
|
```rust
|
|
//! Owner-written job files and the run ledger (M5d spec, section 4.2).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use bxcore::{SessionId, Timestamp};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Job {
|
|
pub name: String,
|
|
pub schedule: String,
|
|
pub prompt: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RunStatus {
|
|
Started,
|
|
Finished,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Run {
|
|
pub time: Timestamp,
|
|
pub name: String,
|
|
pub session: SessionId,
|
|
pub status: RunStatus,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum JobsError {
|
|
Name(String),
|
|
Invalid { path: PathBuf, detail: String },
|
|
}
|
|
|
|
impl std::fmt::Display for JobsError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
JobsError::Name(name) => write!(
|
|
f,
|
|
"job name {name:?} is not a valid job; see docs/runbook.md#jobs-invalid"
|
|
),
|
|
JobsError::Invalid { path, detail } => write!(
|
|
f,
|
|
"{}: {detail}; see docs/runbook.md#jobs-invalid",
|
|
path.display()
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for JobsError {}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct File {
|
|
schedule: String,
|
|
prompt: String,
|
|
}
|
|
|
|
pub fn session_id(name: &str, now: Timestamp) -> Result<SessionId, JobsError> {
|
|
// Build job-<name>-<yyyymmdd>t<hhmm> from now.to_rfc3339() as in Facts. SessionId::new
|
|
// maps a failure to JobsError::Name(name.to_string()).
|
|
let _ = (name, now);
|
|
Err(JobsError::Name(name.to_string()))
|
|
}
|
|
|
|
pub fn load(dir: &Path) -> Result<Vec<Job>, JobsError> {
|
|
let _ = dir;
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
pub fn append(path: &Path, run: &Run) -> Result<(), JobsError> {
|
|
let _ = (path, run);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn live_session(path: &Path, name: &str) -> Result<Option<SessionId>, JobsError> {
|
|
let _ = (path, name);
|
|
Ok(None)
|
|
}
|
|
|
|
pub fn close_leftovers(path: &Path, now: Timestamp) -> Result<usize, JobsError> {
|
|
let _ = (path, now);
|
|
Ok(0)
|
|
}
|
|
```
|
|
|
|
Replace the four stub bodies in the same task. Do not leave a stub that returns `Ok(None)` /
|
|
`Ok(0)` / `Ok(vec![])` once you have written the real body. `session_id` must not stay on the
|
|
`Err` path.
|
|
|
|
## Steps
|
|
|
|
- [ ] **1. Copy the given test.**
|
|
|
|
```sh
|
|
cp docs/plans/M5d2/files/crates/loopd/tests/jobs.rs crates/loopd/tests/
|
|
```
|
|
|
|
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
|
|
target `jobs` does not compile (`E0432` unresolved `loopd::jobs`). Every other target
|
|
compiles.
|
|
- [ ] **3. The module.** Write `jobs.rs` from the skeleton, then fill the four bodies one at a
|
|
time (`session_id`, `load`, `append`, `live_session` / `close_leftovers`). Add `pub mod jobs;`
|
|
to `lib.rs`. Wire `run_serve` as in Facts. `cargo check -p loopd` prints no error and no
|
|
warning. `wc -l crates/loopd/src/jobs.rs` prints a number ≤ 500.
|
|
- [ ] **4. The tests.** `cargo test -p loopd --test jobs`: every test passes, 8 tests.
|
|
- [ ] **5. The runbook.** From the repository root:
|
|
|
|
```sh
|
|
{ echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.md
|
|
```
|
|
|
|
Then `grep -c '^## jobs-invalid$' docs/runbook.md` prints `1`.
|
|
- [ ] **6. Check.** `git diff --stat` lists `crates/loopd/src/jobs.rs`,
|
|
`crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/runbook.md`.
|
|
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
|
|
`gate: ok`. Test count **1545** (1537 before, plus 8). After `gate: ok`, do not add up
|
|
`test result:` lines.
|
|
- [ ] **8. Log and commit.** Task cell `M5d2/05-loopd-jobs`. Stage with
|
|
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as
|
|
`AGENTS.md` says.
|
|
|
|
## Done when
|
|
|
|
- `make gate` prints `gate: ok` with 1545 tests.
|
|
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/jobs.rs`, `crates/loopd/src/lib.rs`,
|
|
`crates/loopd/src/main.rs`, `crates/loopd/tests/jobs.rs`, `docs/runbook.md` and the log.
|
|
|
|
## Stop and report if
|
|
|
|
- The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate's last
|
|
line is not `gate: ok`; you add a cron crate; a missing jobs directory is an error; `Display` for `Invalid`
|
|
does not end with the runbook pointer; `jobs.rs` exceeds 500 lines; you write a job file.
|
|
|
|
## What the given tests pin
|
|
|
|
A valid `mail.toml` loads; unknown keys name the file and the runbook; `Mail.toml` is `Name`;
|
|
`session_id` is `job-mail-20261003t0700`; a 50-character name is `Name`; a missing directory is
|
|
empty; leftover `started` becomes `failed` and is no longer live; a `started` without a close
|
|
is live until `finished`.
|