Task Runner — @job, Dependencies, and Fingerprints¶
The task runner turns your job functions into build-tool-style targets: declare dependencies, guard execution with preconditions, cache results with fingerprints, and retry on failure.
Quick Start¶
Basic @job decorator¶
from functualize.job.decorators import job
from functualize.job import Log
@job(extra_description="Deploy the application.")
def deploy(log: Log):
log("Deploying...")
With dependencies¶
from functualize.job.decorators import job, Deps
@job(deps=Deps("lint", "test"))
def build(sh: Shell, log: Log):
log("Building after lint and test pass...")
sh(["uv", "build"])
Deps run first, in dependency order. By default, if any dep fails, downstream jobs are skipped (Deps.policy="fail-fast"). Use Deps(policy="keep-going") for Make-style -k behavior.
With fingerprint caching¶
from functualize.job.decorators import job, Fingerprint
@job(cache=Fingerprint(sources=["src/**/*.py", "pyproject.toml"]))
def build(sh: Shell):
sh(["uv", "build"])
The build only re-runs when its source files change. The fingerprint includes the
file hashes and the job's resolved config, plus any arguments the caller
passed — the same job with different arguments gets different fingerprints.
Framework-injected parameters (Log, Shell, Sources, …) are excluded: they
are not part of the call's meaning, and a live object's repr is not stable
between processes.
Four behaviours worth knowing before you rely on this:
- A declared output that is missing forces a run.
generatesis part of the freshness question, not decoration — a job is not up to date if the artifact it promised to produce is not there. sourcesandgeneratesare both glob patterns, and a pattern that matches nothing counts as missing:
@job(cache=Fingerprint(sources=["src/**/*.py"], generates=["dist/*.whl"]))
def build(sh: Shell): ...
dist/*.whl is a declaration about a wheel whose version is not known in
advance. With dist/ empty, the job is not fresh — same verdict as a missing
literal path, because "the promised artifact is not there" is one fact however
it was spelled.
- Declared paths may live anywhere. Absolute patterns, ../ patterns and
patterns reaching through a symlinked directory are all declarable. Each path
is recorded as written, so an absolute one will not match on a teammate's
machine and their first run re-runs the job once — nothing breaks, the work is
just not shared. See
ADR-013.
- Declared inputs that resolve to nothing refuse the run (exit 3),
rather than reporting "0 sources unchanged, up to date". A stage cannot
certify success having verified nothing. Declaring no sources is different
and is unaffected.
Where the freshness ledger lives¶
Fingerprints are stored in a runtime state store, and there are two modes. Which one you are in depends on a single directory:
| Mode | When | Where the ledger lives |
|---|---|---|
| project | a .functualize/ directory is found, walking upward from the working directory |
<that directory>/fresh.json — inside your project, alongside the code it describes |
| standalone | no .functualize/ directory anywhere above you |
$XDG_CACHE_HOME/functualize/<project-id>/fresh.json — a hashed directory under your home cache |
func is meant to run over loose scripts anywhere on the filesystem, so
standalone is the fallback rather than the failure: littering a .functualize/
beside every one-off script would be worse than a keyed cache directory.
A substrate plugin changes the file, not the directory. The table above is
the default layout, where each store is its own JSON file. Install a
StoreSubstrate — functualize-substrate-sqlite, say — and every store moves
into one database in the same directory, so the ledger is reported as
.functualize/state.db#fresh rather than .functualize/fresh.json. The mode
still decides which directory; the substrate decides what sits in it. Read
the path the command prints rather than assuming either form.
mkdir .functualize is the switch. Do it when you want the ledger versioned
with the project, shared by everyone working in it, or simply findable — after
which rm -rf .functualize is a full reset.
Both commands tell you which mode you are in and where the file actually is:
func builtin data show # State path + Mode
func builtin info # the same two facts, beside config resolution
Worth checking before concluding a job "won't re-run": in standalone mode the file you are looking for is under a hashed directory you have never seen.
Reading the inputs you declared¶
The glob you declare is expanded on every run to decide freshness. Read the result rather than restating it:
from functualize.job import Fingerprint, Sources, job
@job(cache=Fingerprint(sources=["src/**/*.yaml"], generates=["out/parsed.json"]))
def parse(sources: Sources) -> None:
for path in sources.keys(): # project-relative POSIX paths
text = Path(path).read_text()
entry = sources["src/app.yaml"] # {"mtime": float, "size": int, "sha256": str}
Restating the glob in the body is how the freshness check and the work drift
apart. sources.declared tells "declared no sources" apart from "declared
sources that matched nothing"; sources.generates carries the declared outputs.
See ADR-012.
Deciding your own freshness¶
A fresh job is skipped: the engine decides that before your body is called, so the body never runs. That is the right default, and it is the wrong answer for a job that produces an artifact — for such a job, "I am already current" means "hand back what I built", not "do nothing".
Fingerprint(decides=True) moves that decision into the job. The body is
entered when the verdict says SKIP_FRESH, and reads the verdict through a
Freshness parameter:
from pathlib import Path
from functualize.job import Fingerprint, Freshness, Log, Sources, job
ARTIFACT = Path("build/report.json") # your format, your location
@job(
cache=Fingerprint(
sources=["inputs/*.md"],
generates=["build/report.json"],
decides=True,
),
)
def report(fresh: Freshness, sources: Sources, log: Log) -> None:
verdict = fresh.verdict()
if verdict is not None and verdict.is_fresh:
log(f"up to date under {verdict.key} — returning the artifact")
print(ARTIFACT.read_text())
return
# ...the real work, then write ARTIFACT...
Two runs tell the whole story:
$ func lab report
rebuilt build/report.json from 2 declared inputs
BUILT built=836e97a7 state=run
$ func lab report
up to date under lab.report::fa723457…::checksum — returning the artifact
CACHED built=836e97a7 state=skip_fresh
The second run entered the body (state=skip_fresh), read the verdict and
returned the artifact it had already built — the same built token, no
rebuild. Without decides=True that second run would have printed nothing at
all.
The verdict is the decision itself, not a summary of it: state
(GuardState), key, recorded_value, declared_sources,
declared_generates, source_map, and is_fresh — true for SKIP_FRESH
alone. verdict() returns None only when the job declares no Fingerprint:
there was no decision, and a fabricated one would be a lie.
func builtin why <job> renders that same GuardState, so a person and their
job cannot disagree about a run:
Worth knowing before you rely on it:
- The framework never reads your artifact. It knows the path only because
you declared it under
generates, and all it asks is whether that path exists. Rewritebuild/report.jsonby hand and the job is still fresh. The format, the location and the retention are yours; the decision about your declared inputs is the framework's. - The body runs on every invocation while your inputs are unchanged. A
decided run does not rewrite its fingerprint record — the decision was
discarded, exactly as
--force-freshdiscards it. Reading the verdict is cheap; that is the trade, and the body is what makes it pay. - It is not a cache. No artifact is stored, content-addressed or evicted on
your behalf; the verdict is the contract and the storage is yours
(
contributor/architecture/run-model/11-boundaries.md§B, N1). - Reading a fresh verdict does not oblige you to skip. A job that is fresh and cheap can do the work anyway.
- It is not a way to report a skip. An opted-in run that runs is recorded as having run — "the framework skipped me" and "I ran and decided" stay distinguishable in history.
- Its scope is
SKIP_FRESHonly. A satisfiedstatusguard, a failingPrecondition(still exit 3) and a blocking gate (still exit 5) all stand, exactly as they do forforce_fresh.
The worked example — including the control job that does not opt in, and a
test that hand-edits the artifact to show the framework never reads it — is
examples/standalone/freshness_lab/.
With guards¶
from functualize.job.decorators import job, Guards, Exec
@job(guards=Guards(preconditions=["docker_running"]), exec=Exec(platforms=["linux", "darwin"]))
def deploy_docker(sh: Shell):
sh(["docker", "compose", "up", "-d"])
Guards are checked before dependencies. The pipeline is: platforms →
preconditions → status → fingerprint (platforms are read from Exec, not
Guards). If Docker isn't running the job
refuses immediately — RunStatus.REFUSED, exit 3 — rather than failing
after an opaque error from the shell. Exit 3 is distinct from exit 1 on purpose:
nothing ran and nothing raised, so a caller can tell "I declined to start" from
"the body threw".
Value Objects Reference¶
All operational concerns are grouped into typed value objects:
Deps¶
policy="fail-fast"(default): stop on first failurepolicy="keep-going": run everything not downstream of a failure (likemake -k)
Fingerprint¶
sources: glob patterns. Hashed with resolved config/args for the composite key.method:"checksum"(default),"timestamp", or"none".decides:False(default).Truemeans "when I am fresh, enter my body and let me decide" — the body reads its verdict through aFreshnessparameter and may return its cached artifact instead of rebuilding. See Deciding your own freshness.
Guards¶
preconditions: registered checks (session-cached).status: named status checks.
Exec¶
run:"always"(default),"once"(deduplicates within a session),"when_changed"(deduplicates only with identical resolved args).platforms:sys.platformprefix match — a mismatch refuses (exit 3).retry:Retry(attempts, backoff=..., on=(...), on_exit_codes=(...)).
Inspecting Why a Job Will Run¶
$ func builtin why build
build
platforms ✓ linux
preconditions docker: ✓
fingerprint src/**/*.py: 3 files changed (a.py, b.py, c.py)
deps lint ✓ fresh · test ✗ stale → will run first
Use func builtin why to see guard results, fingerprint freshness, and which dependencies will re-run. On any job run, --explain prints the same verdict without executing.
State Management¶
The state store holds fingerprints, guard results, and execution history. Its
location depends on the mode described in
Where the freshness ledger lives —
func builtin data show prints the resolved path:
func builtin data clear # Clear derived state (fingerprints, history, preconditions)
func builtin data clear --scopes # ...and discard in-flight workflow runs too
func builtin cache clear # Clear discovery cache (job metadata)
These are independent — state clear doesn't touch the cache; cache clear doesn't touch state.
Workflow scopes live in their own file, .functualize/scopes.json, and state clear
keeps them — it tells you how many it kept. A scope is a run somebody is waiting on,
including any gate input they already approved, so discarding one takes --scopes, and
even then the file is moved aside rather than deleted.
Parallel Execution¶
Runs jobs concurrently with a bounded thread pool. Output modes:
| Mode | Behavior |
|---|---|
interleaved |
Streams mixed output in real time |
grouped |
Buffers per-job output, emits on completion with CI group markers |
prefixed |
Each line prefixed with [job_name] |
Pipeline Mode¶
Jobs with a Stdout capability act as Unix pipeline stages:
func build emits NDJSON; jq processes it. The exit code table propagates
through the pipeline:
| Code | Meaning |
|---|---|
| 0 | success — and skipped: a guard saying "nothing to do" did what was asked |
| 1 | the job body raised |
| 2 | usage or config error |
| 3 | refused — a declared precondition for running was not met: a Precondition failed, or Fingerprint(sources=…) resolved to no files |
| 4 | stale-check failure (--check) |
| 5 | blocked awaiting gate input — ran successfully and is resumable |
3 and 5 are deliberately different: a workflow paused at a gate ran and can be resumed; a refusal never started. The same code for both would force every caller to parse stderr.
See Also¶
- Composing Capabilities — how this fits with the other capabilities: a combination matrix of what happens at each intersection, and the traps between them
- Freshness Lab — the worked example for a job that caches its own artifact, with the control job that does not
- Shell Capability Guide — running external commands with lifecycle management
- Workflows Guide — multi-step DAGs with gates and conditional branching