Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .github/workflows/eval.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ name: Eval
on:
workflow_dispatch:
pull_request:
types: [labeled]
# `ready_for_review` beside the defaults, so flipping a draft to
# ready triggers the run the draft guard below skips.
types: [opened, synchronize, reopened, ready_for_review]

# Only one eval per PR at a time — cancel in-progress runs
concurrency:
Expand All @@ -21,7 +23,7 @@ permissions:

jobs:
eval:
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'run-eval'
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
# Step-level timeout on the agent step (below) is what actually
# bounds the run: a job-level timeout would cancel the always()
Expand Down
290 changes: 208 additions & 82 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/lightcone/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ def status(as_json: bool) -> None:
}[state]
lines.append(f" image: {tag} — {described}")
lines.append(f" sandbox: {report.sandbox}")
lines.append(f" crate: {report.crate}")
lines.append("")
marks = {"current": "[dim]·[/dim]", "behind": "[cyan]·[/cyan]", "stale": "[yellow]![/yellow]"}
width = max((len(o.output) for o in report.outputs), default=0)
Expand Down
35 changes: 31 additions & 4 deletions src/lightcone/engine/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,18 +201,39 @@ def require_fetched(path: Path) -> None:
"""
if path.is_symlink() and not path.exists():
unfetched = _ANNEX_OBJECTS in path.readlink().as_posix()
elif path.stat().st_size > _POINTER_MAX_BYTES:
unfetched = False
else:
with path.open("rb") as f:
unfetched = f.read(len(_POINTER_PREFIX)) == _POINTER_PREFIX
unfetched = is_pointer(path)
if unfetched:
raise ContentNotFetchedError(
f"{path}: the content is not in this clone — git-annex holds a "
f"reference to it, not the data. Fetch it with `git annex get {path}`."
)


def is_pointer(path: Path) -> bool:
"""Test whether a regular file holds an annex pointer, not content.

git-annex's own ``isPointerFile`` rule, spelled once: a file no
larger than 32 KiB whose bytes begin ``/annex/objects/``. The locked
shape — a symlink into the object store — is a separate question the
callers ask themselves, because what a symlink means differs by
caller.

Args:
path: An existing regular file.

Returns:
Whether it is a pointer.

Raises:
OSError: If the file cannot be read.
"""
if path.stat().st_size > _POINTER_MAX_BYTES:
return False
with path.open("rb") as f:
return f.read(len(_POINTER_PREFIX)) == _POINTER_PREFIX


def _feed(h: hashlib._Hash, path: Path) -> None:
"""Stream *path* into *h* — outputs are not assumed to fit in memory."""
with path.open("rb") as f:
Expand Down Expand Up @@ -266,6 +287,12 @@ class Manifest:
#: existed, not back-compat machinery.
started_at: str = ""
finished_at: str = ""
#: The uv that resolved and installed the environment the recipe ran
#: in — the one tool between the lock and the installed artifacts.
#: Attestation, like ``lc_version``: outside both hashes, never a
#: rebuild signal, defaulted empty because that is the true value for
#: a manifest written before the field existed.
uv_version: str = ""
#: The image the recipe ran in — ``{tag, id, archive, arch}`` — or
#: ``None`` on the host. Defaulted, and that is not back-compat
#: machinery: ``None`` is the *true* value for every manifest a
Expand Down
12 changes: 8 additions & 4 deletions src/lightcone/engine/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ def sync(root: Path, runtime: Runtime) -> list[str]:
"""Converge ``.lightcone/venv`` inside the image. The containerized twin
of ``project.sync``.

The one container run that gets the network and a writable project
mount — converge once, then execute without writing to the
environment, the same discipline as direct mode. The host's uv cache
The one container run that gets a writable project mount — converge
once, then execute without writing to the environment, the same
discipline as direct mode. The host's uv cache
is mounted at its identical path, so a complete environment
materializes from cache hits in about a second; the cache location is
``uv cache dir``'s answer, never a guess, because that is uv's own
Expand Down Expand Up @@ -414,7 +414,9 @@ def converge(runtime: Runtime) -> list[str]:
return sync(runtime.root, runtime)


def policy_for(runtime: Runtime, read_paths: list[Path]) -> sandbox.Policy:
def policy_for(
runtime: Runtime, read_paths: list[Path], *, output_dir: Path | None = None
) -> sandbox.Policy:
"""Build the exec policy for a resolved runtime.

The one place the ``env_dir``/``containerized`` pair is assembled —
Expand All @@ -426,6 +428,7 @@ def policy_for(runtime: Runtime, read_paths: list[Path]) -> sandbox.Policy:
Args:
runtime: The resolved runtime.
read_paths: Declared inputs, as :func:`sandbox.exec_policy` takes.
output_dir: A recipe's own output directory; absent for a probe.

Returns:
The policy for this world.
Expand All @@ -435,6 +438,7 @@ def policy_for(runtime: Runtime, read_paths: list[Path]) -> sandbox.Policy:
read_paths=read_paths,
env_dir=runtime.env_dir,
containerized=runtime.mode == "containerized",
output_dir=output_dir,
)


Expand Down
130 changes: 86 additions & 44 deletions src/lightcone/engine/crate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@

from __future__ import annotations

import bisect
import hashlib
import json
import tomllib
import re
import uuid
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any

Expand All @@ -42,8 +44,6 @@
from lightcone.engine.plan import Graph, Key
from lightcone.engine.project import SPEC_FILENAME

CRATE_FILENAME = "ro-crate-metadata.json"

#: The vocabulary the run-level facts come from. Without it in the
#: ``@context``, terms like ``containerImage`` and ``sha256`` are
#: undefined and silently dropped on JSON-LD expansion — typed but inert.
Expand All @@ -63,32 +63,15 @@
#: data entities and every action's ``object`` list cannot drift apart.
_ENVIRONMENT = ("pyproject.toml", "uv.lock", ".python-version")

#: A SHA-256-backed annex key: ``SHA256E-s<size>--<64 hex><ext>`` (the E
#: backend keeps the extension). The hex *is* the raw sha256 of the
#: content, which is what makes a checksum publishable with none of the
#: bytes fetched. Any other backend yields a size and no digest — never
#: a wrong one.
_SHA256_KEY = re.compile(r"^SHA256E?-s(\d+)--([0-9a-f]{64})(?:\..*)?$")

def license_of(root: Path) -> str:
"""Read the project's declared license out of ``pyproject.toml``.

Presence is what turns crate maintenance on: RO-Crate requires a
license, a run must not refuse over a missing key, and inventing one
would assert terms over someone's data — so declaring
``[project].license`` is declaring the intent to publish.

Args:
root: The project root.

Returns:
The license as declared — an SPDX expression, a URL, free text,
or a file path for the table forms — or empty when undeclared.
"""
try:
data = tomllib.loads((root / "pyproject.toml").read_text())
except (OSError, tomllib.TOMLDecodeError):
return ""
declared = data.get("project", {}).get("license")
if isinstance(declared, str):
return declared
if isinstance(declared, dict):
return str(declared.get("text") or declared.get("file") or "")
return ""
#: Any backend key's size field, for ``contentSize`` alone.
_KEY_SIZE = re.compile(r"^\w+-s(\d+)")


def render(
Expand All @@ -98,6 +81,7 @@ def render(
license: str,
dsid: str,
writer: Callable[[Path], LastWrite],
keys: Mapping[str, str],
) -> str:
"""Build the crate document for the project as it stands.

Expand All @@ -109,17 +93,21 @@ def render(
Args:
root: The project root.
graph: The full task graph — every universe, every output.
license: The declared license, from :func:`license_of`.
license: The declared license, from :func:`project.license_of`.
dsid: The dataset UUID, the namespace absolute entity ids are
minted under so they are stable across clones.
writer: Answers "which commit last touched this path" —
:func:`dataset.last_writer` bound to the root, injected so
the builder stays free of git.
keys: Each annexed file's key, repository-relative —
:func:`dataset.annex_keys`'s answer, injected for the same
reason as *writer*. SHA-256-backed keys become per-file
checksums an archive can verify with ``sha256sum``.

Returns:
The ``ro-crate-metadata.json`` text, trailing newline included.
"""
build = _Builder(root, graph, license, dsid, writer)
build = _Builder(root, graph, license, dsid, writer, keys)
return build.document()


Expand All @@ -137,13 +125,19 @@ def __init__(
license: str,
dsid: str,
writer: Callable[[Path], LastWrite],
keys: Mapping[str, str],
) -> None:
from astra.helpers import load_yaml

self.root = root
self.graph = graph
self.license = license
self.writer = writer
self.keys = dict(keys)
#: Sorted once: each output selects its files by bisecting this,
#: not by rescanning the whole map — the map holds every annexed
#: file in the repository, data/ included.
self.sorted_keys = sorted(self.keys)
self.crate = ROCrate()
self.crate.metadata.extra_contexts.append(_WORKFLOW_RUN_CONTEXT)
#: Every materialized task, sorted: the one iteration order.
Expand Down Expand Up @@ -311,11 +305,47 @@ def _environment_files(self) -> None:
readme["about"] = {"@id": "./"}

def _file(self, name: str) -> Any:
# Idempotent by id, so the second asker (the license file is
# asked for by the workflow and the root) does not hash again.
if (existing := self.crate.dereference(name)) is not None:
return existing
properties: dict[str, Any] = {}
if fmt := _format_of(name):
properties["encodingFormat"] = fmt
properties.update(self._integrity(name))
return self.crate.add_file(self.root / name, name, properties=properties)

def _integrity(self, name: str) -> dict[str, str]:
"""``sha256`` and ``contentSize`` for one repository file.

An annexed file answers from its key — the working tree may hold
only a pointer, and hashing that would publish a digest of the
wrong bytes — and a git-carried file from the bytes themselves.
Both are repository state, so the render stays pure. A file
neither annexed nor readable carries no claim at all.

The byte path re-checks the pointer shape rather than trusting
the key map's absence: ``annex_keys`` answers empty for a whole
repository whenever git-annex cannot answer at all, and a
pointer file reads perfectly well — so without the guard, one
failed ``git annex find`` would publish a well-formed digest of
the pointer text for every annexed file, silently.
"""
if key := self.keys.get(name):
if digest := _SHA256_KEY.match(key):
return {"contentSize": digest.group(1), "sha256": digest.group(2)}
if size := _KEY_SIZE.match(key):
return {"contentSize": size.group(1)}
return {}
path = self.root / name
try:
if path.is_symlink() or assets.is_pointer(path):
return {}
data = path.read_bytes()
except OSError:
return {}
return {"contentSize": str(len(data)), "sha256": hashlib.sha256(data).hexdigest()}

def _dataset_id(self, key: Key) -> str:
"""One output directory's crate id — :func:`plan.declared_path`'s
answer, never a second spelling of the results layout."""
Expand All @@ -331,6 +361,15 @@ def _dataset(self, key: Key, manifest: assets.Manifest) -> None:
manifest_file = self._file(f"{dataset_id}{assets.MANIFEST_FILENAME}")
manifest_file["about"] = {"@id": dataset_id}
entity["subjectOf"] = {"@id": manifest_file.id}
# Every file the directory holds, each with the checksum its
# annex key already carries — the claim `sha256sum` can check
# after a `git archive` deposit, where `version` above is lc's
# own framed directory digest and deliberately is not that.
parts = [manifest_file]
lo = bisect.bisect_left(self.sorted_keys, dataset_id)
hi = bisect.bisect_left(self.sorted_keys, dataset_id + "\uffff")
parts += [self._file(name) for name in self.sorted_keys[lo:hi]]
entity["hasPart"] = [{"@id": part.id} for part in parts]

# ----- the runs -----

Expand Down Expand Up @@ -374,7 +413,7 @@ def _objects(self, key: Key, manifest: assets.Manifest) -> list[dict[str, str]]:
if upstream in self.made_keys:
refs.append({"@id": self._dataset_id(upstream)})
continue
refs.append({"@id": self._external(name, task.inputs[name], manifest)})
refs.append({"@id": self._external(name, task.inputs[name])})
# The *recorded* decisions: the values the recipe actually ran
# under, whatever the spec says today. A decision the workflow no
# longer declares gets no exampleOfWork — there is no parameter
Expand All @@ -394,33 +433,36 @@ def _objects(self, key: Key, manifest: assets.Manifest) -> list[dict[str, str]]:
refs.append({"@id": value.id})
return refs

def _external(self, name: str, path: Path, manifest: assets.Manifest) -> str:
def _external(self, name: str, path: Path) -> str:
"""A declared input the spec points at, in or out of the tree.

In-or-out is :func:`plan.declared_path`'s answer — relative
inside the tree, absolute outside it — never a second spelling
of that rule here: two copies of one path rule is how the first
one shipped a bug.

An in-tree input's checksum comes from its annex key, like every
other file: a ``File`` entity's ``sha256`` describes the
*deposit* — the bytes a ``git archive`` carries — never what any
particular run consumed, so manifests disagreeing about a shared
input (a half-rebuilt project) do not suppress it; which bytes a
run consumed is its own manifest's ``input_versions``. An
out-of-tree input carries none: its recorded digest is lc's
*framed* hash, not a raw sha256, so publishing it under the
workflow-run ``sha256`` term would be a checksum nothing can
verify — the manifests keep the full story, which is the layer's
stated weaker promise.
"""
declared = plan.declared_path(self.root, path)
in_tree = not Path(declared).is_absolute()
entity_id = declared if in_tree else Path(declared).as_uri()
recorded = manifest.input_versions.get(name, "")
digest = recorded.removeprefix("sha256:") if recorded.startswith("sha256:") else ""
if (existing := self.crate.dereference(entity_id)) is not None:
# Two manifests can testify to different bytes for one input
# — a half-rebuilt project. Publishing either digest would
# contradict a manifest in the same crate, so publish
# neither; the manifests themselves keep the full story.
if digest and existing.get("sha256") not in (None, digest):
existing.pop("sha256")
if self.crate.dereference(entity_id) is not None:
return entity_id
properties: dict[str, Any] = {"@type": "File", "name": declared}
if fmt := _format_of(declared):
properties["encodingFormat"] = fmt
if digest:
properties["sha256"] = digest
if in_tree:
properties.update(self._integrity(declared))
self.crate.add_file(path, declared, properties=properties)
else:
# Outside the repository: recorded by content, not stored
Expand Down
Loading
Loading