Durable jobs

Plugins register jobs to handle trusted Events after the originating operation commits.

Bun server projects with jobs automatically open durable storage, recover unfinished operations before indexing Sources, and run due work after startup. An Action can return once its Source changes and notification work are durably admitted; it does not wait for a provider request.

Define a job

Use defineJob with a registered, non-browser Event. Job identity is its Plugin id and local contribution name. Keep that name stable and change version when a saved input or step history is no longer compatible.

import { defineEvent, defineJob, definePlugin } from "@pathmx/core"
import { z } from "zod"

const ReportRequested = defineEvent({
  data: z.strictObject({ report: z.string() }),
})

export function Reports(readReport: (id: string) => Promise<string>) {
  return definePlugin({
    id: "reports",
    events: { requested: ReportRequested },
    jobs: {
      prepare: defineJob({
        on: ReportRequested,
        version: "1",
        async run(event, { step }) {
          await step.run(
            "read-report",
            async () => ({
              status: "completed",
              value: await readReport(event.data.report),
            }),
            { recover: "retry", decode: (value) => z.string().parse(value) },
          )
        },
      }),
    },
  })
}

An Action stages the Event with events.record; trusted server or Plugin CLI code can await ctx.events.emit. Matching handlers are selected at admission. Installing a new handler later does not replay historical Events into it. Ordinary events.subscribe listeners remain process-local observers; use jobs for work that must survive restart.

Save steps and retry safely

Call step.run(name, activity, { recover, decode }) sequentially. Each activity receives { key, interrupted }: a stable identity for provider deduplication and whether execution was interrupted. Completed JSON results are saved and decoded on replay, so a completed activity is not repeated. Names and order must remain compatible. Parallel or nested steps are rejected.

Activities return one of these outcomes:

OutcomeBehavior
{ status: "completed", value }Persist JSON data and continue.
{ status: "retry", at, reason }Retry at an absolute Unix time in milliseconds, with a bounded attempt count.
{ status: "failed", reason }Stop failed work for inspection.
{ status: "blocked", reason }Stop until its execution prerequisite is restored.
{ status: "unknown", reason }Stop an uncertain effect for reconciliation.

recover: "retry" permits rerunning an interrupted activity; choose it only for safe reads or effects with a suitable idempotency protocol. recover: "stop" marks an interrupted activity unknown. Unexpected exceptions also stop uncertain work. Do not wrap a mutating Action in step.run; use step.action so its commit and saved receipt share the same durable boundary. Core preserves identity and history; each integration owns its provider's retry safety. Email supplies that protocol for messages.

When a step throws, the host logs job.step.error through the owning Plugin's logger, with Plugin, handler, run and step identifiers. The error message is capped at 2,000 characters and JSON-escaped for the log. It is not automatically redacted; provider adapters should sanitize messages that may contain secrets. Saved step and run reasons remain generic, and diagnostics do not make unknown work retryable. For an expected failure, return an explicit outcome with a suitable public reason.

Each job receives { app, state, step }. app is the receiving Plugin's normal AppContext, including app.view, Events, database and scoped job operations. It is supplied at execution, not serialized into the run. The trusted job must choose an explicitly admitted Actor/session; an Event payload alone does not authorize impersonating an Actor.

An optional synchronous capture() on the job returns JSON configuration saved at admission, exposed as state during execution. Use it for policy that must survive a deployment change; do not capture credentials. Missing handlers, incompatible versions, or invalid persisted inputs stop execution.

Save an Action as a step

Use step.action(name, view, action, input, { target? }) for an existing Action. It returns the normal Action receipt and shares the same sequential history as step.run. The View must come from the job's owning host, with an admitted Actor and no read-only subject override. Options match view.actions.run; the host owns operation identity, so callers do not supply a retry key.

For example, a trusted report job can infer a summary and then save it through a normal Action. The Action remains responsible for permission checks and finite Source effects:

import { defineAction, defineEvent, defineJob, definePlugin } from "@pathmx/core"
import { z } from "zod"

const SummaryRequested = defineEvent({
  data: z.object({ actor: z.string(), report: z.string() }),
})

export function Summaries(summarize: (body: string) => Promise<string>) {
  return definePlugin({
    id: "summaries",
    events: { requested: SummaryRequested },
    actions: {
      save: defineAction({
        target: "report",
        input: z.object({ summary: z.string() }),
        available: ({ can }) => can("write"),
        prepare({ target, input }) {
          return {
            effects: { update: [target.id] },
            run({ tx }) {
              tx.source(target.id).data({ summary: input.summary })
              return { report: target.id }
            },
          }
        },
      }),
    },
    jobs: {
      summarize: defineJob({
        on: SummaryRequested,
        version: "1",
        async run(event, { app, step }) {
          // The trusted Event producer has authorized acting for this Actor.
          const view = app.view({ type: "local", actor: event.data.actor })
          const summary = await step.run("summarize", async () => {
            const report = view.readSource(event.data.report)
            if (!report) return { status: "blocked", reason: "Report unavailable" }
            return { status: "completed", value: await summarize(report.body) }
          }, { recover: "stop", decode: (value) => z.string().parse(value) })
          await step.action("save", view, "summaries.save", { summary }, {
            target: event.data.report,
          })
        },
      }),
    },
  })
}

No Source transaction stays open during summarize. Before an uncommitted Action runs, Core checks current Actor, View and Action admission, including changes made while external work was in flight. Permission denial blocks the step; restore the prerequisite and use the existing job retry operation. Rejected preparation or validation fails the step. Uncertain outcomes stop for reconciliation unless the journal already holds a prepared operation that can be recovered.

The Action's receipt, Events, downstream job admission and committed operation marker are saved together. After a crash, a committed step returns that receipt without invoking the Action or admitting its Events again. A prepared operation is recovered before job execution resumes. Source-file recovery retains the storage boundary described below.

Saved invocation identity includes the Actor/session authority, View context, Action name, explicit target and JSON input. Changing any of those for the same step fails; object property order is immaterial. Inputs are captured before work begins. Output must be JSON or undefined for a void Action; unsupported outputs fail before commit. Replay returns the original receipt, including its original operation id and time. Incompatible handler/output changes require a job version change, just like other saved steps.

Completed receipts are private execution history for the same trusted job and matching View; replay does not rerun current Action availability checks. Creating a new contextual View still requires normal admission. Do not expose saved results to a browser without its own current access checks. This protocol covers Source mutations and Event/job admission, not arbitrary network or provider effects inside an Action; keep those in explicit step.run integrations.

Inspect and retry from a live Plugin

The trusted AppContext supplied to Plugin hooks includes app.jobs. A Plugin can list and retry runs belonging to its currently registered jobs without stopping the server:

// Inside server code that has already authorized the operator:
const runs = await app.jobs.list({ limit: 50 })
const failed = runs.find((run) => run.status === "failed")
if (failed) await app.jobs.retry(failed.id)

List returns newest-admitted runs first, filtering to the receiving Plugin before applying the limit (default 100, range 1–1000). Each summary includes id, event, handler, version, status, due, attempts, and reason. event is the Event id; due is a Unix time in milliseconds. Captured configuration and step payloads are not included. A Plugin with no registered jobs sees an empty list when the host supports these operations. Missing and other-Plugin run ids receive the same not-found error on retry.

retry(id) requeues compatible failed or blocked work and wakes the owning host. Completed step results and the original run/Event identities are preserved. A pending run is unchanged, including its scheduled due time. Running, completed, and unknown runs cannot be retried; unknown or interrupted step outcomes require reconciliation. Unrelated executing jobs do not block requeueing. The worker still executes jobs sequentially, and retry returning does not mean the job finished.

These are trusted server capabilities, not browser permissions. The Plugin must check operator access before displaying results or invoking retry from an Action, query, or route. Keep retry in a retry-only Action: it is an immediate durable operation, not staged with Source writes, and a later Action failure cannot undo it. Hosts without live job operations, including static execution and inspection hosts, report that the operation is unavailable.

JobDefinition, Json, JobRun, and PluginJobs are exported from @pathmx/core for reusable job definitions, step values, and typed operation helpers. The runner and storage records remain internal.

Operate the queue

bun run pmx jobs list --limit 100
bun run pmx jobs inspect '<run-id>'
bun run pmx jobs operation '<operation-id>'
bun run pmx jobs retry '<run-id>'
bun run pmx jobs drain --limit 32

jobs operation shows the operation's outcome, original request receipt when present, and a summary of changed Source paths, Event identities, asset moves and Action steps. Only unresolved operations include full recovery intent, with saved before/after Source content. Completed operations are not a Source-content archive.

List and inspection read saved state directly. With pmx dev or pmx serve running, retry and drain go to that server's worker, so no second worker starts. One native owner holds the repository for its lifetime, and process death releases that lock. Without a server, the command owns the repository while it runs. If a server exists but cannot be reached, the command fails instead of taking over its work. Drains run one batch at a time: a drain requested while the worker is busy waits for that batch, then processes up to its own --limit and reports only the jobs it ran. retry requeues compatible failed or blocked work while preserving completed steps; pending work is unchanged, and unknown outcomes cannot be cleared this way. Live Plugins use app.jobs.retry through the already-owning host instead of opening a second owner. drain --production uses the deployment's production effect policy; against a running server it must match that server's mode.

Action failures can include an operation id. Inspect it when commit outcome is uncertain. An unresolved prepared operation fences normal repository access and job execution until its saved Source intent can be recovered; do not bypass this fence by deleting journal records. A commit already running in the current host is not abandoned recovery work. Readiness checks do not scan committed history or wait in the durable commit queue; read-freshness checks still protect overlapping repository updates.

For an independent test instance, use a separate copy of the Sources and set PATHMX_DB_DIR to its own state directory outside the Source root. Changing the port alone does not separate ownership. A separate state directory has its own journal, caches and development sign-in state; it does not coordinate writes to Sources shared with another instance.

Storage boundary

Bun saves operation intent before replacing Source files and admits Events, run identities, Action-step receipts, and the committed marker in one SQL transaction. Recovery accepts files matching their saved before or after content and stops on conflicting external edits. Individual asset renames recover alongside their reference edits; mismatched asset bytes stop recovery. Durable folder moves are unsupported. An unrelated editor can still race filesystem writes; this protocol does not claim atomic coordination with every external writer or tested guarantees for every power-loss scenario.

When a commit succeeds, the same SQL transaction replaces its full recovery payload with a content fingerprint, request receipt and summary, and releases copied job-admission data. Event records, admitted runs and step outcomes remain with their respective owners. Unresolved intent stays complete until recovery succeeds. Opening an existing journal compacts completed history in restartable batches without changing operation identities or repeating work.

Job history lives in private project storage outside Sources. Request receipts, Events, runs and steps have no automatic time-based expiry. Email separately prunes its message data. SQLite can reuse space freed by compaction; the physical database file does not necessarily shrink immediately. Static builds do not execute jobs. The Cloudflare live host uses a repository Durable Object with the shared journal and runner, transactional storage, and alarms to wake due work. This does not add authored recurring schedules or parallel jobs.