Skip to content

feat(jobs): persist lastResult and lastRanAt per job in state#182

Open
Nibbler1250 wants to merge 1 commit intomoazbuilds:masterfrom
Nibbler1250:feat/jobs-last-result
Open

feat(jobs): persist lastResult and lastRanAt per job in state#182
Nibbler1250 wants to merge 1 commit intomoazbuilds:masterfrom
Nibbler1250:feat/jobs-last-result

Conversation

@Nibbler1250
Copy link
Copy Markdown
Contributor

Resolves #175.

Adds two optional fields to each job entry in state.json so the statusline / heartbeat prompt / external monitors can see what each job last did without re-executing it.

Changes

src/statusline.ts — extend StateData.jobs[] type:

jobs: {
  name: string;
  nextAt: number;
  /** Outcome of the most recent run. Absent until the job runs at least once. */
  lastResult?: "ok" | "error" | "skipped";
  /** Unix timestamp (ms) of the most recent completion. Absent until first run. */
  lastRanAt?: number;
}[];

src/commands/start.ts — three small additions:

  1. Declare an in-memory jobLastResult Map alongside the existing jobRetryState Map (same lifecycle: resets on daemon restart, no disk persistence).
  2. In the runJob() .then() handler, right after the run completes (next to where jobRetryState is updated), record the outcome:
    jobLastResult.set(job.name, {
      result: r.exitCode === 0 ? "ok" : "error",
      ranAt: Date.now(),
    });
  3. In updateState(), spread the recorded fields into each job entry so they land in state.json on the next 60-second tick:
    jobs: currentJobs.map((job) => {
      const last = jobLastResult.get(job.name);
      return {
        name: job.name,
        nextAt: nextCronMatch(...).getTime(),
        ...(last ? { lastResult: last.result, lastRanAt: last.ranAt } : {}),
      };
    })

Properties

  • Additive: fields are absent until the job runs at least once; existing consumers see no change.
  • No new disk I/O: piggybacks on the existing updateState() 60s tick.
  • No new dependency: just a Map<string, { result, ranAt }> in memory.
  • Lifecycle matches jobRetryState: in-memory only, drops on daemon restart (no stale debt across restarts, consistent with how retry state already works in this file).

Notes on "skipped"

I included "skipped" in the union type for forward compatibility (e.g. a future heartbeat could mark a job skipped due to rate-limit / disabled / pause), but this PR doesn't write skipped anywhere yet. Happy to drop that variant if you'd rather keep the surface tight to "ok" | "error" for now.

Build

  • bun build src/index.ts --target=bun → 0.42 MB, clean.
  • bunx tsc --noEmit → no new errors. The two pre-existing errors at start.ts:442 / :447 (forwardToTelegram / forwardToDiscord arity mismatch) live on master already; I left them alone.
  • No new lockfile / package changes.

Source

Originally requested by myself in #14 (Mega-Post) — thanks @TerrysPOV for breaking it out into its own ticket.

Resolves moazbuilds#175. Adds two optional fields to each entry in state.json so
the statusline / heartbeat prompt / external monitors can see what each
job last did without re-executing it.

- src/statusline.ts: extend StateData.jobs[] type with optional
  lastResult: "ok" | "error" | "skipped" and lastRanAt: number (ms).
- src/commands/start.ts:
  - declare in-memory jobLastResult Map (resets on daemon restart, like
    jobRetryState).
  - record outcome in the runJob() then-handler right after the run
    completes — same place jobRetryState is touched.
  - in updateState(), spread the recorded last-result fields into each
    job entry so they land in state.json on the next 60s tick.

Fields are additive (absent until a job has run at least once), no
breaking change to existing consumers, no new disk I/O beyond what
updateState() already does.

Source: requested by @Nibbler1250 in moazbuilds#14 (Mega-Post).
Copy link
Copy Markdown
Collaborator

@TerrysPOV TerrysPOV left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues:

  1. Daemon crashes at startup when any jobs are configured — updateState() is called immediately at line 770, but const jobLastResult (which the updated updateState() body now reads via jobLastResult.get(job.name)) is declared at line ~779. const variables are in the temporal dead zone until their declaration is reached; accessing one before that throws ReferenceError: Cannot access 'jobLastResult' before initialization. Any user with jobs configured will see the daemon die on startup. Fix: move the jobLastResult declaration above the updateState() call (alongside or before jobRetryState).

heartbeat: currentSettings.heartbeat.enabled
? { nextAt: nextHeartbeatAt }
: undefined,
jobs: currentJobs.map((job) => {
const last = jobLastResult.get(job.name);
return {
name: job.name,
nextAt: nextCronMatch(job.schedule, now, currentSettings.timezoneOffsetMinutes).getTime(),

// In-memory retry state: resets on daemon restart (no stale debt across restarts).
const jobRetryState = new Map<string, { failCount: number; retryAt: number }>();
// Track each job's most recent outcome so state.json can expose lastResult/lastRanAt
// for crash-recovery + status displays. Resets on daemon restart (in-memory only).

  1. "skipped" is declared in the type but never written — The type union in statusline.ts includes "skipped", and jobLastResult is typed to accept it, but the only write path sets "ok" or "error" from r.exitCode. The natural trigger for "skipped" — jobs bypassed because isRateLimited() returns true — has no jobLastResult.set() call. Any consumer checking lastResult === "skipped" to detect rate-limit suspensions will never see it. Fix: set { result: "skipped", ranAt: Date.now() } inside the isRateLimited() branch of the setInterval, once per job that was due to fire.

jobs: {
name: string;
nextAt: number;
/** Outcome of the most recent run. Absent until the job runs at least once. */
lastResult?: "ok" | "error" | "skipped";

Please effect these changes, commit and resubmit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(jobs): persist lastResult and lastRanAt per job in state

2 participants