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

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

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