Files
boxmaker/docs/plans/M5d2/05-loopd-jobs.md
T
kyleandGrok 4.6 2a48b7f36d docs: M5d-2: after gate: ok, do not sum test results
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>
2026-10-03 12:56:14 -07:00

8.5 KiB

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.

//! 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.

    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:

    { 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.