Files
boxmaker/docs/plans/M5d2/05-loopd-jobs.md
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
session_slot and the scheduler tests held a temporary JSON Value across
statements (E0716). Cron now pins DOM and DOW as AND, which the given tests
had not. Gate counts from task 04 on move up by one.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 01:31:07 -07:00

220 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).
- [ ] **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 count is
not 1545; 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`.