Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,8 +1065,18 @@ failed.** `lc materialize --check` is a gate, and a project of curated
results would otherwise never pass it again — that is the `behind` half.
The other half is that `made` stays empty when *every* recipe fails, so
`not made and not planned` reported "nothing to do" over a list of
failures. It is `ok and not made and not planned`; those two are the
first keys of the JSON report and are what an agent branches on.
failures. It is true only when `failed`, `blocked`, `made` and `planned`
are all empty, and it is the JSON report's first key.

**The materialize report has no `ok`, by decision (2026-10).** It meant
"nothing failed or was blocked", which check mode can never violate, so
`--check --json` said `"ok": true` while exiting 1 over outputs still to
build, and agents read it as done. Redefining it as the exit code made
it identical to `up_to_date` in check mode, a second name for one fact.
The remaining keys say what they mean: a run succeeded when `failed`
and `blocked` are empty, a check passes when `up_to_date` is true, and
the exit code is nonzero when any of `failed`, `blocked` or `planned`
is not empty.

**`lc status` reports; `--check` gates.** Status always exits 0 — a state
is not a failure — and it is the only verb that shows the commit each
Expand Down
17 changes: 11 additions & 6 deletions src/lightcone/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ def build(as_json: bool) -> None:
is_flag=True,
help=(
"Report what would run and why, without executing or committing "
"anything; exit 1 if anything is out of date."
"anything. Exit 1 if any output would run; in JSON, `up_to_date` "
"is false."
),
)
@click.option(
Expand Down Expand Up @@ -391,7 +392,8 @@ def materialize(
click.echo("\n".join(["", *report.notes]), err=True)
_render_materialize_output(report, root, dry_run=check_only)

if not report.ok or (check_only and not report.up_to_date):
# `planned` is filled by check mode only: there it is the gate failing.
if report.failed or report.blocked or report.planned:
sys.exit(1)


Expand Down Expand Up @@ -441,7 +443,10 @@ def status(as_json: bool) -> None:
lines.append(f" sandbox: {escape(report.sandbox)}")
lines.append(f" crate: {escape(report.crate)}")
lines.append("")
marks = {"current": "[dim]·[/dim]", "behind": "[cyan]·[/cyan]", "stale": "[yellow]![/yellow]"}
marks = {
"current": "[dim]·[/dim]", "behind": "[cyan]·[/cyan]",
"stale": "[yellow]![/yellow]", "no recipe": "[yellow]·[/yellow]",
}
width = max((len(o.output) for o in report.outputs), default=0)
# The commit gets a column of its own, for every state and not only
# the interesting ones: "which code made this" is the question the
Expand All @@ -450,15 +455,15 @@ def status(as_json: bool) -> None:
# in `why`, so this one path covers it; the dedicated field exists
# for machine consumers of `--json`.
lines += [
f" {marks[o.status]} {o.status:<8} {o.output:<{width}} "
f" {marks[o.status]} {o.status:<9} {o.output:<{width}} "
f"{o.git_sha[:7] or '—':<7}" + (f" [dim]{escape(o.why)}[/dim]" if o.why else "")
for o in report.outputs
]
lines += [f" [yellow]![/yellow] {escape(warning)}" for warning in report.warnings]

counts = report.counts
if not report.outputs:
lines.append("[dim]The analysis declares no output with a recipe.[/dim]")
lines.append("[dim]The analysis declares no output.[/dim]")
else:
lines.append("")
lines.append(
Expand Down Expand Up @@ -499,7 +504,7 @@ def _render_materialize_output(report: MaterializeReport, root: Path, *, dry_run
lines += [f" [red]✗[/red] blocked {name}" for name in report.blocked]
lines += [f" [yellow]![/yellow] {escape(warning)}" for warning in report.warnings]

if not report.ok:
if report.failed or report.blocked:
verdict = f"[red]✗[/red] {where} did not finish"
elif report.up_to_date:
verdict = f"[green]✓[/green] {where} is up to date — nothing to do"
Expand Down
49 changes: 29 additions & 20 deletions src/lightcone/engine/materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, Literal, Protocol
from uuid import uuid4

from lightcone.engine import assets, container, dataset, identity, plan, project, worker
Expand Down Expand Up @@ -78,11 +78,6 @@ class MaterializeReport:
#: prints these unwrapped, exactly as ``lc run`` does.
notes: list[str] = field(default_factory=list)

@property
def ok(self) -> bool:
"""Whether everything that was attempted finished."""
return not self.failed and not self.blocked

@property
def up_to_date(self) -> bool:
"""Whether the analysis needed nothing done to it.
Expand All @@ -94,18 +89,23 @@ def up_to_date(self) -> bool:

A run that failed is not up to date either, however little it
managed to produce: ``made`` stays empty when every recipe fails,
so without ``ok`` here the first two keys of the JSON report would
read "nothing to do" over a list of failures.
so without ``failed`` and ``blocked`` here the first key of the
JSON report would read "nothing to do" over a list of failures.
"""
return self.ok and not self.made and not self.planned
return not (self.failed or self.blocked or self.made or self.planned)

def as_dict(self) -> dict[str, Any]:
"""Return the report as JSON-ready data.

There is deliberately no overall success key: a run succeeded when
``failed`` and ``blocked`` are empty, a check passes when
``up_to_date`` is true, and one key standing for both read as
"done" over outputs still to build.

Returns:
Every field, with ``ok`` and ``up_to_date`` first.
Every field, with ``up_to_date`` first.
"""
return {"ok": self.ok, "up_to_date": self.up_to_date, **asdict(self)}
return {"up_to_date": self.up_to_date, **asdict(self)}


# =============================================================================
Expand Down Expand Up @@ -138,7 +138,8 @@ def check(root: Path, targets: Sequence[str], *, refresh: bool = False) -> Mater
read, or a target matches nothing.
"""
report = MaterializeReport()
for key, verdict, _, _ in _classified(root, targets, report, refresh=refresh):
_, classified = _classified(root, targets, report, refresh=refresh)
for key, verdict, _, _ in classified:
name = _name(key)
if verdict.calls_for_a_remake(refresh=refresh):
report.planned[name] = verdict.why
Expand All @@ -151,7 +152,10 @@ def check(root: Path, targets: Sequence[str], *, refresh: bool = False) -> Mater

def _classified(
root: Path, targets: Sequence[str], report: MaterializeReport, *, refresh: bool
) -> list[tuple[Key, assets.Verdict, assets.Manifest | None, dataset.LastWrite | None]]:
) -> tuple[
Graph,
list[tuple[Key, assets.Verdict, assets.Manifest | None, dataset.LastWrite | None]],
]:
"""Classify every task in topological order, reading nothing but disk.

The walk both read-only modes share, so there is one answer to "what
Expand All @@ -170,8 +174,8 @@ def _classified(
decides whether their dependents see the sentinel.

Returns:
One ``(key, verdict, manifest, foreign write)`` per task, upstream
first.
The graph and one ``(key, verdict, manifest, foreign write)`` per
task, upstream first.
"""
graph, env_version, _ = _graph(root, targets, report)
unfetched: set[str] = set()
Expand All @@ -186,7 +190,7 @@ def _classified(
"`lc materialize` fetches declared inputs before executing "
"recipes. Compute is required for outputs whose inputs cannot yet be checked."
)
return classified
return graph, classified


def _classify_graph(
Expand Down Expand Up @@ -287,8 +291,8 @@ class OutputStatus:

#: ``universe/output_id``.
output: str
status: assets.Status
#: Why, for ``stale`` and ``behind``. Empty for ``current``.
status: assets.Status | Literal["no recipe"]
#: Why, for ``stale`` and ``behind``. Empty for ``current`` and ``no recipe``.
why: str
#: The commit the output was materialized at, or empty if it never was.
#: This is the whole point of the verb: an artifact that is behind is
Expand Down Expand Up @@ -344,7 +348,7 @@ class StatusReport:
@property
def counts(self) -> dict[str, int]:
"""How many outputs are in each state, states with none included."""
tally = {"current": 0, "behind": 0, "stale": 0}
tally = {"current": 0, "behind": 0, "stale": 0, "no recipe": 0}
for output in self.outputs:
tally[output.status] += 1
return tally
Expand Down Expand Up @@ -392,7 +396,8 @@ def status(root: Path) -> StatusReport:
result.image = {"tag": tag, "state": state, "archive": archive}
result.sandbox = _sandbox_line(result.mode)
stamps = []
for key, verdict, manifest, foreign in _classified(root, [], report, refresh=False):
graph, classified = _classified(root, [], report, refresh=False)
for key, verdict, manifest, foreign in classified:
if manifest and manifest.finished_at:
stamps.append(manifest.finished_at)
result.outputs.append(
Expand All @@ -405,6 +410,10 @@ def status(root: Path) -> StatusReport:
foreign_write=foreign.sha if foreign else "",
)
)
result.outputs.extend(
OutputStatus(output=_name(key), status="no recipe", why="", git_sha="", data_version="")
for key in graph.no_recipe
)
result.crate = _crate_line(root, max(stamps, default=""))
result.warnings = report.warnings
return result
Expand Down
32 changes: 20 additions & 12 deletions src/lightcone/engine/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
``(universe, output)`` pair that has a recipe. A task carries everything
executing it needs and nothing about *how* it will be executed: the
rendered command, where its bytes go, what it reads, which decisions it
was made under, and its ``definition_version``.
was made under, and its ``definition_version``. Active outputs without a
recipe are retained separately for status and are not executable tasks.

What the spec *means* is ASTRA's to say. ``astra.resolve`` settles each
universe's decisions, resolves every output's inputs to what supplies
Expand Down Expand Up @@ -73,9 +74,11 @@ def depends_on(self) -> tuple[Key, ...]:

@dataclass(frozen=True)
class Graph:
"""Every task a run could make, and how they relate."""
"""Every task a run could make, and outputs with no recipe."""

tasks: dict[Key, Task]
#: Active outputs ASTRA resolved without a recipe; reportable, not executable.
no_recipe: tuple[Key, ...] = ()

def order(self) -> list[Key]:
"""Return the tasks in dependency order.
Expand Down Expand Up @@ -157,8 +160,7 @@ def build(root: Path) -> Graph:
root: The project root.

Returns:
One task per ``(universe, output)`` pair that has a recipe and is
active in that universe.
The active recipe tasks and keys of active outputs without recipes.

Raises:
ProjectError: If the spec is missing, declares no universe, gives
Expand All @@ -181,6 +183,7 @@ def build(root: Path) -> Graph:
spec = dict(resolve_analysis_tree(load_yaml(spec_path), root))

tasks: dict[Key, Task] = {}
no_recipe: list[Key] = []
declared_in: dict[str, Path] = {}
for path in universes:
universe = load_yaml(path)
Expand All @@ -196,10 +199,12 @@ def build(root: Path) -> Graph:
f"results/{universe_id}/. Give each universe its own id."
)
declared_in[universe_id] = path
for task in _tasks(root, universe_id, spec, universe):
universe_tasks, universe_no_recipe = _tasks(root, universe_id, spec, universe)
for task in universe_tasks:
tasks[task.key] = task
no_recipe.extend((universe_id, output_id) for output_id in universe_no_recipe)

return Graph(tasks=tasks)
return Graph(tasks=tasks, no_recipe=tuple(no_recipe))


def declared_path(root: Path, path: Path) -> str:
Expand Down Expand Up @@ -271,13 +276,13 @@ def _tasks(
universe_id: str,
spec: dict[str, object],
universe: dict[str, object],
) -> list[Task]:
"""Every task one universe contributes.
) -> tuple[list[Task], list[str]]:
"""Resolve one universe's tasks and outputs that cannot be executed.

``resolve_outputs`` has already dropped what this universe does not
produce, so the only filter left is whether an output carries a
command: a re-export names bytes another output makes, and making it
twice under two ids is not a thing to do.
produce. Outputs without a command are not scheduled. Declared-only
ones are returned for status; a re-export is not, because it names
bytes another output makes and that output is reported in its place.
"""
from astra.resolve import render_command, resolve_outputs

Expand All @@ -303,8 +308,11 @@ def file_of(out: object) -> Path:
)

tasks = []
no_recipe = []
for out in resolved:
if not out.command:
if out.reexports is None:
no_recipe.append(out.id)
continue
output_path = file_of(out)
values: dict[str, str] = {}
Expand Down Expand Up @@ -351,4 +359,4 @@ def file_of(out: object) -> Path:
resources=resources,
)
)
return tasks
return tasks, no_recipe
Loading
Loading