feat(plane): memory store (file and MinIO) and the remember task (harnessx-removal task 2)

plane/memory.py: the MemoryStore interface with FileStore (index.jsonl, facts by
content hash, repos/<host>__<owner>__<name>/<pin>.jsonl, idempotent ingest,
deterministic row ids, refusal below 1 GiB free naming the path) and MinioStore
(object metadata and tags, S3 Select pushdown probed once per process with a
logged local fallback, object version ids on every row read, versioning and
runs/ lifecycle helper, presigned URLs). OUSAST_MEMORY selects the backend;
MINIO_* settings come from .env; the minio SDK is the optional extra `minio`.

plane/tasks/remember.py: model-free rows (facts, verdict, unit_cost, alert)
from a case's artifacts; remember_run computes them on the host for a recorded
run without writing into it. reconciler.mark_done (477 lines total), seed
before and ingest after `ousast plane run`, `ousast plane remember <run>`.
The generator annotates each repo-facts Task with openultrasast.io/memory-key
(repo, pin, candidates digest, image digest) and the Run with its population
and split; plane/ regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-30 11:10:28 +02:00
co-authored by Claude Opus 5.5
parent d64ab2817e
commit 52d28b5121
31 changed files with 2121 additions and 14 deletions
+44
View File
@@ -248,6 +248,11 @@ def main(argv: list[str] | None = None) -> int:
plane_status.add_argument("run")
plane_status.add_argument("--units", action="store_true", help="per-unit rows from each task's units.jsonl")
plane_sub.add_parser("doctor", help="check kind, Agent Substrate, the ax controller and the runner image")
plane_remember = plane_sub.add_parser("remember", help="ingest a run's memory rows into the store (OUSAST_MEMORY)")
plane_remember.add_argument("run")
plane_remember.add_argument("--plane", type=Path, default=Path("plane"), help="where runs/<run>.yaml names population and split")
plane_remember.add_argument("--population", help="the population the run measured (default: the Run's annotation)")
plane_remember.add_argument("--split", help="the split of that population (default: the Run's annotation)")
plane_ws = plane_sub.add_parser("workspaces", help="one Workspace manifest per case pin of a population file")
plane_ws.add_argument("population", type=Path)
plane_ws.add_argument("--out", type=Path, default=Path("plane/workspaces"))
@@ -1304,9 +1309,14 @@ def _plane(args: argparse.Namespace) -> int:
from .plane import reconciler # lazy: the plane is a maintainer surface, not the scan path
if args.plane_command == "run":
_plane_memory("seed", args.run_manifest)
result = reconciler.run(args.run_manifest, workers=args.workers, ax=args.ax)
print(f"run finished: {result}")
_plane_memory("ingest", args.run_manifest)
return 0 if result == "done" else 1
if args.plane_command == "remember":
manifest = args.plane / "runs" / f"{args.run}.yaml"
return _plane_memory("ingest", manifest if manifest.is_file() else None, run=args.run, population=args.population, split=args.split)
if args.plane_command == "status":
print(reconciler.status(args.run, units=args.units))
return 0
@@ -1324,6 +1334,40 @@ def _plane(args: argparse.Namespace) -> int:
return 0
def _plane_memory(
step: str, run_manifest: Path | None, *, run: str | None = None, population: str | None = None, split: str | None = None
) -> int:
"""``seed`` before a Run, ``ingest`` after it (and ``ousast plane remember``). A store failure is reported and
never changes the run's result: ingest can be repeated by hand."""
from .plane import memory, reconciler
from .plane.tasks.remember import UNKNOWN, remember_run
try:
store = memory.open_store()
if step == "seed":
assert run_manifest is not None
seeded = memory.seed(run_manifest, store)
if seeded:
print(f"memory {store.describe()}: facts reused for {len(seeded)} tasks: {', '.join(seeded)}")
return 0
annotations: dict[str, str] = {}
if run_manifest is not None:
spec, _ = reconciler.load_run(run_manifest)
run, annotations = run or spec.metadata.name, dict(spec.metadata.annotations)
assert run is not None
results = remember_run(
reconciler.run_dir(run), store, population=population or annotations.get(memory.POPULATION_ANNOTATION) or UNKNOWN,
split=split or annotations.get(memory.SPLIT_ANNOTATION) or UNKNOWN,
) # fmt: skip
except (memory.MemoryStoreError, OSError, ValueError) as exc:
print(f"memory {step} failed: {exc}", file=sys.stderr)
return 1
fresh = [r for r in results if not r.skipped]
kinds = " ".join(f"{k}={n}" for k, n in memory.counts(fresh).items()) or "none"
print(f"memory {store.describe()}: {run}: {len(fresh)} tasks ingested, {len(results) - len(fresh)} already in the index; rows {kinds}")
return 0
def _plane_increment(args: argparse.Namespace) -> int:
from .plane.generate import increment
+2
View File
@@ -63,6 +63,8 @@ SPEC_OWNED = {
"plane.tasks.agree": "ai-service-plane Req 5.3/2.1: agreement across two verify passes with per-candidate cost, turns and site match",
"plane.reconciler": "ai-service-plane Req 3.1-3.5, 7.3: ax-backed Run execution, status attribution and doctor; `ousast plane`",
"plane.generate": "ai-service-plane task 7: an increment's Workspaces, per-case Tasks and Run from a recorded set",
"plane.memory": "harnessx-removal Req 6.1/6.4: the memory store (file, MinIO), ingest and fact reuse; `ousast plane remember`",
"plane.tasks.remember": "harnessx-removal Req 6.1: a case's delivered artifacts as memory rows, model-free",
"plane.workspaces": "ai-service-plane task 4: Workspace manifests per case pin of a population; `ousast plane workspaces`",
"plane.runner": "ai-service-plane Req 4.1-4.4: the ax-task-runner entrypoint (PID 1 of the task image); delivers the output directory",
}
+22 -6
View File
@@ -15,7 +15,11 @@ under ``--plane`` (default ``plane/``), everything a Run over the set needs besi
(``triage.json``, for the measurement);
- ``tasks/<run>.yaml``: per case, one Task per template (two for ``verify``: ``OUSAST_PASS=a|b``) binding the two
Workspaces and naming the files as env;
- ``runs/<run>.yaml``: the Run, per case ``facts -> verify a, verify b -> agree``, with budgets.
- ``runs/<run>.yaml``: the Run, per case ``facts -> verify a, verify b -> agree``, with budgets, annotated with the
population and split (``openultrasast.io/population``/``split``: the files' stems) the memory rows carry.
Each ``repo-facts`` Task carries ``openultrasast.io/memory-key`` (repository, pin, candidates digest, runner image
digest): the key under which stored facts are reused instead of recomputed (``plane/memory.py``, ``seed``).
Why per-case Tasks rather than one Task per step: an ax Task binds its Workspaces by name and a Run entry adds
neither a binding nor env (it names a Task, artifacts and a budget), and ``Run.inputs`` accepts only
@@ -53,7 +57,9 @@ import yaml
from ..model.endpoint import price_of
from .budget import cost_of
from .manifests import GIT_COMMITS_ANNOTATION, RUN_API_VERSION, Task, load_manifests
from .memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SPLIT_ANNOTATION, memory_key
from .reconciler import _ax_name, _doc
from .tasks.repo_facts import _digest
from .tasks.verify import MAX_STEPS, units_of
__all__ = ["CaseInputs", "fix_ranges", "increment", "read_cases", "render", "repin_templates"]
@@ -251,8 +257,11 @@ def _tiebreak_entries(item: CaseInputs) -> list[dict[str, Any]]:
] # fmt: skip
def render(cases: Sequence[CaseInputs], templates: Mapping[str, Task], run_name: str, command: str) -> dict[str, str]:
"""Relative path under the plane root -> file text, for every generated manifest."""
def render(
cases: Sequence[CaseInputs], templates: Mapping[str, Task], run_name: str, command: str, *, population: str = "", split: str = ""
) -> dict[str, str]:
"""Relative path under the plane root -> file text, for every generated manifest. ``population`` and ``split``
(the population file's and the set's stems) become the Run's annotations, which the memory rows carry."""
header = f"# generated by `{command}`; do not edit, regenerate.\n"
out: dict[str, str] = {}
tasks: list[dict[str, Any]] = []
@@ -266,7 +275,11 @@ def render(cases: Sequence[CaseInputs], templates: Mapping[str, Task], run_name:
repo = [(pinned["metadata"]["name"], CASE_PATH), (inputs["metadata"]["name"], INPUTS_PATH)]
on_repo = {"OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo"}
facts_env = {**on_repo, "OUSAST_INPUT_CANDIDATES": f"{INPUTS_PATH}/functions.json"}
tasks.append(_task(templates["repo-facts"], f"repo-facts-{case_id}", facts_env, repo))
facts_task = _task(templates["repo-facts"], f"repo-facts-{case_id}", facts_env, repo)
digest = _digest(sorted({fn for _, fn, _ in item.kept}))
key = memory_key(str(item.case["repo"]), str(item.case["vulnerable"]), digest, templates["repo-facts"].image or "")
facts_task["metadata"].setdefault("annotations", {})[MEMORY_KEY_ANNOTATION] = key
tasks.append(facts_task)
for label in ("a", "b"):
env = {**on_repo, "OUSAST_INPUT_CANDIDATES": f"{INPUTS_PATH}/candidates.json", "OUSAST_PASS": label}
tasks.append(_task(templates["verify"], f"verify-{label}-{case_id}", env, repo))
@@ -295,7 +308,9 @@ def render(cases: Sequence[CaseInputs], templates: Mapping[str, Task], run_name:
tasks.sort(key=lambda doc: 1 if doc["metadata"]["name"].startswith("verify-c-") else 0) # stable: the old Tasks first
entries.extend(tiebreak) # appended: the first Run's entries stay byte-identical, so a rerun keeps their state
out[f"tasks/{run_name}.yaml"] = _dump(header, tasks)
run = {"apiVersion": RUN_API_VERSION, "kind": "Run", "metadata": {"name": run_name}, "spec": {"tasks": entries}}
annotations = {k: v for k, v in ((POPULATION_ANNOTATION, population), (SPLIT_ANNOTATION, split)) if v}
metadata: dict[str, Any] = {"name": run_name, **({"annotations": annotations} if annotations else {})}
run = {"apiVersion": RUN_API_VERSION, "kind": "Run", "metadata": metadata, "spec": {"tasks": entries}}
total = sum(float(e["budget"]["usd"]) for e in entries)
candidates = sum(len(c.candidates) for c in cases)
kept = sum(len(c.kept) for c in cases)
@@ -340,7 +355,8 @@ def increment(
if runner_image is not None:
repin_templates(plane, runner_image)
loaded = load_manifests([plane / "tasks" / f"{name}.yaml" for name in TEMPLATES])
files = render(read_cases(validation_set, candidates_dir, triage_dir, population, repos), loaded.tasks, run_name, command)
cases = read_cases(validation_set, candidates_dir, triage_dir, population, repos)
files = render(cases, loaded.tasks, run_name, command, population=population.stem, split=validation_set.stem)
written: list[Path] = []
for relative, text in files.items():
path = plane / relative
+695
View File
@@ -0,0 +1,695 @@
"""The plane's memory store (harnessx-removal Requirement 6.1/6.4, design section 4).
One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows
of five kinds (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``proposal_outcome``) that the ``remember`` task
(:mod:`.tasks.remember`) derives from a run's delivered artifacts. Layout, identical for both backends::
index.jsonl one row per ingested (run, task): sha256 of its rows, row count
facts/<sha256>.json facts.json by content (identical facts stored once)
repos/<host>__<owner>__<name>/<pin>.jsonl the rows of one repository + 40-hex pin, sorted by id
``OUSAST_MEMORY`` selects the backend: ``file:///path`` (:class:`FileStore`, default ``results_root()/memory``) or
``minio://<bucket>[/<prefix>]`` (:class:`MinioStore`, the ``minio`` extra). MinIO's endpoint and credentials come
from ``.env`` (``MINIO_ENDPOINT``, ``MINIO_ACCESS_KEY``, ``MINIO_SECRET_KEY``, ``MINIO_SECURE``), never from a
manifest, and are never printed. Ingest is idempotent: an ``index.jsonl`` hit on (run, task, sha256) skips the
delivery, and rows carry a deterministic ``id``, so a repeated row replaces itself. Every object is written whole
(``FileStore``: a temporary file renamed) and the index last, so an interrupted ingest leaves nothing partial that
a repeat would not repair. ``FileStore`` refuses to write below 1 GiB free and names its path.
Fact reuse: a facts entry is valid for (repo, pin, candidates digest, runner image digest), the key the generator
writes as the Task annotation ``openultrasast.io/memory-key``. :func:`seed` runs before a Run: for every entry whose
Task carries a key with stored facts and no state yet, it writes ``<run>/<task>/facts.json`` and a ``done``
summary and marks the task done under the run lock (``reconciler.mark_done``), so the reconciler skips it.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shutil
import tempfile
from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Protocol
from urllib.parse import urlsplit
log = logging.getLogger(__name__)
MEMORY_KEY_ANNOTATION = "openultrasast.io/memory-key"
POPULATION_ANNOTATION = "openultrasast.io/population"
SPLIT_ANNOTATION = "openultrasast.io/split"
KINDS = ("facts", "verdict", "unit_cost", "alert", "proposal_outcome")
ROW_FIELDS = ("id", "kind", "repo", "pin", "run", "task", "population", "split", "image")
TAG_FIELDS = ("repo", "pin", "kind", "family", "run", "population", "split")
MIN_FREE_BYTES = 1 << 30
MINIO_KEYS = ("MINIO_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY")
_PIN = re.compile(r"[0-9a-f]{40}")
_FIELD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
_TAG_VALUE = re.compile(r"[^A-Za-z0-9 +\-=._:/@]")
class MemoryStoreError(RuntimeError):
"""The store refused or failed a read or write; the message names the store and the cause."""
# --- rows and keys -------------------------------------------------------------------------------------------------
def repo_key(repo: str) -> str:
"""``host/owner/name`` of a repository URL (scheme, credentials, ``.git`` and trailing slashes dropped)."""
text = repo.strip()
if "://" in text:
parts = urlsplit(text)
text = f"{parts.hostname or ''}{parts.path}"
text = text.rstrip("/").removesuffix(".git").strip("/")
if not text:
raise ValueError(f"not a repository: {repo!r}")
return text
def repo_dir(repo: str) -> str:
return repo_key(repo).replace("/", "__")
def row_id(kind: str, run: str, task: str, subject: str) -> str:
"""The deterministic row id: sha256 of (kind, run, task, subject), subject being the candidate, the path,
the facts sha256 or the rule id with its location."""
return hashlib.sha256("\x00".join((kind, run, task, subject)).encode("utf-8")).hexdigest()
def image_digest(image: str) -> str:
"""The content part of an image reference (``sha256:...``): the registry host may differ, the code may not."""
return image.rpartition("@")[2] if "@" in image else image
def memory_key(repo: str, pin: str, candidates: str, image: str) -> str:
"""The annotation text of a facts entry's validity key."""
key = {"candidates": candidates, "image": image_digest(image), "pin": pin, "repo": repo_key(repo)}
return json.dumps(key, sort_keys=True, separators=(",", ":"))
def parse_memory_key(text: str) -> dict[str, str]:
key = json.loads(text)
if not isinstance(key, dict) or set(key) != {"candidates", "image", "pin", "repo"}:
raise ValueError(f"{MEMORY_KEY_ANNOTATION} must name candidates, image, pin and repo: {text[:200]!r}")
return {k: str(v) for k, v in key.items()}
def validate_row(row: object, where: str = "row") -> dict[str, Any]:
if not isinstance(row, dict):
raise MemoryStoreError(f"{where}: a row must be a JSON object")
missing = [f for f in ROW_FIELDS if not isinstance(row.get(f), str) or not row.get(f)]
if missing:
raise MemoryStoreError(f"{where}: missing or empty fields {missing}")
if row["kind"] not in KINDS:
raise MemoryStoreError(f"{where}: unknown kind {row['kind']!r} (one of {', '.join(KINDS)})")
if not _PIN.fullmatch(row["pin"]):
raise MemoryStoreError(f"{where}: pin {row['pin']!r} is not a 40-hex commit")
return row
def _line(row: Mapping[str, Any]) -> str:
return json.dumps(row, sort_keys=True, separators=(",", ":"))
def rows_digest(rows: Iterable[Mapping[str, Any]]) -> str:
return hashlib.sha256("".join(_line(r) + "\n" for r in rows).encode("utf-8")).hexdigest()
def matches(row: Mapping[str, Any], where: Mapping[str, Any] | None) -> bool:
"""Top-level equality on every ``where`` field; the rule the S3 Select expression pushes down."""
return all(row.get(k) == v for k, v in (where or {}).items())
def where_sql(where: Mapping[str, Any]) -> str:
"""The S3 Select expression of ``where`` (JSON Lines input): equality per field, AND-joined."""
clauses: list[str] = []
for name, value in sorted(where.items()):
if not _FIELD.fullmatch(name):
raise ValueError(f"where: field {name!r} is not a plain identifier")
ref = f's."{name}"'
if value is None:
clauses.append(f"{ref} IS NULL")
elif isinstance(value, bool):
clauses.append(f"{ref} = {'true' if value else 'false'}")
elif isinstance(value, int | float):
clauses.append(f"{ref} = {value!r}")
elif isinstance(value, str):
clauses.append(f"{ref} = '{value.replace(chr(39), chr(39) * 2)}'")
else:
raise ValueError(f"where: {name} must be a string, number, boolean or null")
return "SELECT * FROM S3Object s" + (" WHERE " + " AND ".join(clauses) if clauses else "")
@dataclass(frozen=True)
class Record:
"""A row as read, with the object it came from and that object's version (Req 6.4 provenance)."""
row: dict[str, Any]
key: str
version: str | None
@dataclass(frozen=True)
class IngestResult:
run: str
task: str
rows: int
skipped: bool
kinds: dict[str, int]
# --- the interface -------------------------------------------------------------------------------------------------
class MemoryStore(ABC):
"""Rows by repository and pin, facts by content hash, and the ingest index; two backends, one behaviour."""
# primitives a backend implements; keys are the layout's paths
@abstractmethod
def _get(self, key: str) -> tuple[bytes, str | None] | None: ...
@abstractmethod
def _put(self, key: str, data: bytes, labels: Mapping[str, str] | None = None) -> None: ...
@abstractmethod
def _delete(self, key: str) -> None: ...
@abstractmethod
def _keys(self, prefix: str) -> list[str]: ...
@abstractmethod
def describe(self) -> str: ...
@abstractmethod
def presign_put(self, key: str, expires: timedelta = timedelta(hours=1)) -> str: ...
@abstractmethod
def presign_get(self, key: str, expires: timedelta = timedelta(hours=1)) -> str: ...
def _check_space(self) -> None: # noqa: B027 -- optional: only a backend with a local disk overrides it
"""A backend with a local disk refuses a write that would fill it."""
def _select(self, key: str, where: Mapping[str, Any]) -> tuple[list[dict[str, Any]], str | None] | None:
"""Rows of ``key`` matching ``where``, filtered where the data lives; None when the object is absent."""
found = self._get(key)
if found is None:
return None
return [r for r in _parse_lines(found[0], key) if matches(r, where)], found[1]
def _object_may_match(self, key: str, kind: str | None) -> bool:
return True
# --- reads ---
def index(self) -> list[dict[str, Any]]:
found = self._get("index.jsonl")
return _parse_lines(found[0], "index.jsonl") if found else []
def get_facts(self, sha256: str) -> bytes | None:
"""The facts.json stored under ``sha256``; a file whose content no longer hashes to it is dropped with a
warning and reported absent, so the task recomputes."""
key = f"facts/{sha256}.json"
found = self._get(key)
if found is None:
return None
if hashlib.sha256(found[0]).hexdigest() != sha256:
log.warning("memory %s: %s no longer matches its sha256; dropped, the task recomputes", self.describe(), key)
self._delete(key)
return None
return found[0]
def rows(
self, repo: str | None = None, pin: str | None = None, kind: str | None = None, where: Mapping[str, Any] | None = None
) -> list[Record]:
"""Rows filtered by repository, pin, kind and top-level equality on ``where``, in key then id order."""
prefix = f"repos/{repo_dir(repo)}/" if repo else "repos/"
keys = [k for k in self._keys(prefix) if k.endswith(".jsonl") and (pin is None or k.endswith(f"/{pin}.jsonl"))]
wanted = {**(where or {}), **({"kind": kind} if kind else {})}
records: list[Record] = []
for key in sorted(keys):
if not self._object_may_match(key, kind):
continue
selected = self._select(key, wanted)
if selected is not None:
records.extend(Record(r, key, selected[1]) for r in sorted(selected[0], key=lambda r: str(r["id"])))
return records
# --- writes ---
def put_facts(self, data: bytes) -> str:
sha = hashlib.sha256(data).hexdigest()
key = f"facts/{sha}.json"
if self._get(key) is None:
self._check_space()
self._put(key, data)
return sha
def put_row(self, row: Mapping[str, Any]) -> None:
self.put_rows([row])
def put_rows(self, rows: Sequence[Mapping[str, Any]]) -> None:
"""Merge rows into their repository + pin objects by ``id`` (a repeated id replaces the stored row)."""
groups: dict[str, dict[str, dict[str, Any]]] = {}
for index, row in enumerate(rows):
checked = validate_row(dict(row), f"row {index}")
groups.setdefault(f"repos/{repo_dir(checked['repo'])}/{checked['pin']}.jsonl", {})[checked["id"]] = checked
if groups:
self._check_space()
for key, new in sorted(groups.items()):
found = self._get(key)
merged = {str(r["id"]): r for r in (_parse_lines(found[0], key) if found else [])}
merged.update(new)
ordered = [merged[i] for i in sorted(merged)]
self._put(key, "".join(_line(r) + "\n" for r in ordered).encode("utf-8"), _labels(ordered))
def ingest_rows(self, run: str, task: str, rows: Sequence[Mapping[str, Any]], facts: bytes | None = None) -> IngestResult:
"""One (run, task)'s rows and facts; a repeat of the same rows is skipped by the index."""
checked = [validate_row(dict(r), f"{run}/{task} row {i + 1}") for i, r in enumerate(rows)]
digest = rows_digest(checked)
kinds: dict[str, int] = {}
for row in checked:
kinds[row["kind"]] = kinds.get(row["kind"], 0) + 1
index = self.index()
if any(e.get("run") == run and e.get("task") == task and e.get("sha256") == digest for e in index):
return IngestResult(run, task, len(checked), True, kinds)
self._check_space()
if facts is not None:
self.put_facts(facts)
self.put_rows(checked)
entry = {"run": run, "task": task, "sha256": digest, "rows": len(checked), "ingested": _now()}
self._put("index.jsonl", "".join(_line(e) + "\n" for e in [*index, entry]).encode("utf-8"))
return IngestResult(run, task, len(checked), False, kinds)
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _parse_lines(data: bytes, where: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for number, raw in enumerate(data.decode("utf-8").splitlines(), 1):
if not raw.strip():
continue
try:
row = json.loads(raw)
except ValueError as exc:
raise MemoryStoreError(f"{where}:{number}: not JSON ({exc})") from exc
if not isinstance(row, dict):
raise MemoryStoreError(f"{where}:{number}: not a JSON object")
rows.append(row)
return rows
def _labels(rows: Sequence[Mapping[str, Any]]) -> dict[str, str]:
"""Object metadata/tags: per field the distinct values of the object's rows, space-joined, S3-safe, <=256."""
labels: dict[str, str] = {}
for name in TAG_FIELDS:
values = sorted({_TAG_VALUE.sub("_", str(r[name])) for r in rows if r.get(name) not in (None, "")})
text = " ".join(values)
labels[name] = text if len(text) <= 256 else "*" # "*": too many to list; the object may match anything
return labels
# --- the file backend ----------------------------------------------------------------------------------------------
class FileStore(MemoryStore):
"""The layout under a directory: this laptop's store and the unit tests'."""
def __init__(self, root: Path) -> None:
self.root = Path(root)
def describe(self) -> str:
return f"file://{self.root}"
def _path(self, key: str) -> Path:
path = (self.root / key).resolve()
if not path.is_relative_to(self.root.resolve()):
raise MemoryStoreError(f"{key!r} escapes the store {self.root}")
return path
def _check_space(self) -> None:
probe = self.root
while not probe.exists() and probe != probe.parent:
probe = probe.parent
free = shutil.disk_usage(probe).free
if free < MIN_FREE_BYTES:
raise MemoryStoreError(
f"refusing to write the memory store at {self.root}: {free / (1 << 30):.2f} GiB free, below 1 GiB "
"(free space, or set OUSAST_MEMORY to another store; `ousast plane remember <run>` repeats the ingest)"
)
def _get(self, key: str) -> tuple[bytes, str | None] | None:
path = self._path(key)
if not path.is_file():
return None
data = path.read_bytes()
return data, "sha256:" + hashlib.sha256(data).hexdigest() # a file has no version: its content names it
def _put(self, key: str, data: bytes, labels: Mapping[str, str] | None = None) -> None:
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as sink:
sink.write(data)
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def _delete(self, key: str) -> None:
self._path(key).unlink(missing_ok=True)
def _keys(self, prefix: str) -> list[str]:
base = self._path(prefix)
if not base.is_dir():
return []
return sorted(p.relative_to(self.root.resolve()).as_posix() for p in base.rglob("*") if p.is_file() and not p.name.startswith("."))
def presign_put(self, key: str, expires: timedelta = timedelta(hours=1)) -> str:
return self._path(key).as_uri()
def presign_get(self, key: str, expires: timedelta = timedelta(hours=1)) -> str:
return self._path(key).as_uri()
# --- the MinIO backend ---------------------------------------------------------------------------------------------
class ObjectClient(Protocol):
"""The object operations :class:`MinioStore` needs; :class:`SdkClient` adapts the ``minio`` SDK, tests fake it."""
def get(self, key: str) -> tuple[bytes, str | None] | None: ...
def put(self, key: str, data: bytes, labels: Mapping[str, str]) -> None: ...
def delete(self, key: str) -> None: ...
def keys(self, prefix: str) -> list[str]: ...
def tags(self, key: str) -> dict[str, str]: ...
def version(self, key: str) -> str | None: ...
def select(self, key: str, expression: str) -> bytes: ...
def presign(self, method: str, key: str, expires: timedelta) -> str: ...
def expire(self, prefix: str, days: int, rule_id: str) -> None: ...
def enable_versioning(self) -> None: ...
_SELECT_PROBES: dict[str, bool] = {}
PROBE_KEY = "_probe/select.jsonl"
class MinioStore(MemoryStore):
"""The layout in one bucket (optionally under a prefix): object metadata and tags on every row object, S3
Select pushdown when the server answers it (probed once per process) and a local filter otherwise, bucket
versioning for provenance, lifecycle expiry for ``runs/``, presigned URLs."""
def __init__(self, client: ObjectClient, bucket: str, prefix: str = "") -> None:
self.client, self.bucket, self.prefix = client, bucket, prefix.strip("/")
def describe(self) -> str:
return f"minio://{self.bucket}" + (f"/{self.prefix}" if self.prefix else "")
def _k(self, key: str) -> str:
return f"{self.prefix}/{key}" if self.prefix else key
def _get(self, key: str) -> tuple[bytes, str | None] | None:
return self.client.get(self._k(key))
def _put(self, key: str, data: bytes, labels: Mapping[str, str] | None = None) -> None:
self.client.put(self._k(key), data, dict(labels or {}))
def _delete(self, key: str) -> None:
self.client.delete(self._k(key))
def _keys(self, prefix: str) -> list[str]:
cut = len(self.prefix) + 1 if self.prefix else 0
return sorted(k[cut:] for k in self.client.keys(self._k(prefix)))
def _object_may_match(self, key: str, kind: str | None) -> bool:
"""Filter by tag instead of reading: an object whose ``kind`` tag lacks ``kind`` holds no such row."""
if kind is None:
return True
tagged = self.client.tags(self._k(key)).get("kind")
return tagged is None or tagged == "*" or kind in tagged.split()
def select_supported(self) -> bool:
"""Whether the server answers S3 Select over JSON Lines: one probe per bucket per process."""
name = self.describe()
if name not in _SELECT_PROBES:
try:
self.client.put(self._k(PROBE_KEY), b'{"probe":1}\n{"probe":2}\n', {})
got = _parse_lines(self.client.select(self._k(PROBE_KEY), where_sql({"probe": 1})), PROBE_KEY)
_SELECT_PROBES[name] = got == [{"probe": 1}]
detail = "" if _SELECT_PROBES[name] else f"; the probe returned {got!r}"
except Exception as exc: # noqa: BLE001 -- any failure means "not supported"; the fallback is exact
_SELECT_PROBES[name], detail = False, f"; {type(exc).__name__}: {str(exc)[:200]}"
if not _SELECT_PROBES[name]:
log.warning("memory %s: the server does not answer S3 Select%s; filtering rows locally", name, detail)
return _SELECT_PROBES[name]
def _select(self, key: str, where: Mapping[str, Any]) -> tuple[list[dict[str, Any]], str | None] | None:
if not where or not self.select_supported():
return super()._select(key, where)
version = self.client.version(self._k(key)) # stat, then select: Select takes no version id
if version is None and self.client.get(self._k(key)) is None:
return None
try:
return _parse_lines(self.client.select(self._k(key), where_sql(where)), key), version
except Exception as exc: # noqa: BLE001 -- one object's Select failing never loses rows
log.warning("memory %s: S3 Select on %s failed (%s); filtering it locally", self.describe(), key, type(exc).__name__)
return super()._select(key, where)
def presign_put(self, key: str, expires: timedelta = timedelta(hours=1)) -> str:
return self.client.presign("PUT", self._k(key), expires)
def presign_get(self, key: str, expires: timedelta = timedelta(hours=1)) -> str:
return self.client.presign("GET", self._k(key), expires)
def configure(self, runs_expire_days: int = 30) -> None:
"""Bucket versioning on (provenance) and the lifecycle rule expiring ``runs/`` after ``runs_expire_days``;
``repos/``, ``facts/`` and ``proposals/`` are kept."""
if runs_expire_days < 1:
raise ValueError("runs_expire_days must be at least 1")
self.client.enable_versioning()
self.client.expire(self._k("runs/"), runs_expire_days, "ousast-runs-expiry")
class SdkClient:
""":class:`ObjectClient` over ``minio.Minio``; imported lazily so the core install needs no SDK."""
def __init__(self, endpoint: str, access_key: str, secret_key: str, secure: bool, bucket: str) -> None:
try:
from minio import Minio
except ImportError as exc:
raise MemoryStoreError("OUSAST_MEMORY=minio://... needs the minio SDK: install openultrasast[minio]") from exc
self.sdk, self.bucket = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure), bucket
def get(self, key: str) -> tuple[bytes, str | None] | None:
from minio.error import S3Error
try:
response = self.sdk.get_object(self.bucket, key)
except S3Error as exc:
if exc.code in ("NoSuchKey", "NoSuchObject"):
return None
raise
try:
return response.read(), response.headers.get("x-amz-version-id")
finally:
response.close()
response.release_conn()
def put(self, key: str, data: bytes, labels: Mapping[str, str]) -> None:
import io
from minio.commonconfig import Tags
tags = Tags(for_object=True)
for name, value in labels.items():
tags[name] = value
metadata: dict[str, str | list[str] | tuple[str]] = dict(labels)
self.sdk.put_object(
self.bucket, key, io.BytesIO(data), len(data), content_type="application/x-ndjson",
metadata=metadata or None, tags=tags if labels else None,
) # fmt: skip
def delete(self, key: str) -> None:
self.sdk.remove_object(self.bucket, key)
def keys(self, prefix: str) -> list[str]:
return sorted(str(o.object_name) for o in self.sdk.list_objects(self.bucket, prefix=prefix, recursive=True))
def tags(self, key: str) -> dict[str, str]:
return dict(self.sdk.get_object_tags(self.bucket, key) or {})
def version(self, key: str) -> str | None:
from minio.error import S3Error
try:
version = self.sdk.stat_object(self.bucket, key).version_id
return None if version is None else str(version)
except S3Error as exc:
if exc.code in ("NoSuchKey", "NoSuchObject"):
return None
raise
def select(self, key: str, expression: str) -> bytes:
from minio.select import JSONInputSerialization, JSONOutputSerialization, SelectRequest
request = SelectRequest(expression, JSONInputSerialization(json_type="LINES"), JSONOutputSerialization(), request_progress=False)
with self.sdk.select_object_content(self.bucket, key, request) as result:
return b"".join(result.stream())
def presign(self, method: str, key: str, expires: timedelta) -> str:
if method == "PUT":
return str(self.sdk.presigned_put_object(self.bucket, key, expires=expires))
return str(self.sdk.presigned_get_object(self.bucket, key, expires=expires))
def expire(self, prefix: str, days: int, rule_id: str) -> None:
from minio.commonconfig import ENABLED, Filter
from minio.lifecycleconfig import Expiration, LifecycleConfig, Rule
rule = Rule(ENABLED, rule_filter=Filter(prefix=prefix), rule_id=rule_id, expiration=Expiration(days=days))
self.sdk.set_bucket_lifecycle(self.bucket, LifecycleConfig([rule]))
def enable_versioning(self) -> None:
from minio.commonconfig import ENABLED
from minio.versioningconfig import VersioningConfig
self.sdk.set_bucket_versioning(self.bucket, VersioningConfig(ENABLED))
def minio_settings(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
"""Endpoint, keys and TLS from ``.env``/the environment; a missing key is named, a value never is."""
if environ is None:
from ..config import load_dotenv
load_dotenv()
environ = os.environ
missing = [k for k in MINIO_KEYS if not environ.get(k)]
if missing:
raise MemoryStoreError(f"OUSAST_MEMORY=minio://... needs {', '.join(missing)} in .env or the environment")
secure = (environ.get("MINIO_SECURE") or "true").strip().lower() not in ("0", "false", "no", "off")
return {
"endpoint": environ["MINIO_ENDPOINT"], "access_key": environ["MINIO_ACCESS_KEY"],
"secret_key": environ["MINIO_SECRET_KEY"], "secure": secure,
} # fmt: skip
def open_store(spec: str | None = None, environ: Mapping[str, str] | None = None) -> MemoryStore:
"""The store ``spec`` (else ``OUSAST_MEMORY``, else ``file://<results_root()>/memory``) names."""
from .reconciler import results_root
text = spec or (environ if environ is not None else os.environ).get("OUSAST_MEMORY") or ""
if not text:
return FileStore(results_root() / "memory")
parts = urlsplit(text)
if parts.scheme == "file" and parts.path:
return FileStore(Path(parts.path))
if parts.scheme == "minio" and parts.netloc:
settings = minio_settings(environ)
return MinioStore(SdkClient(bucket=parts.netloc, **settings), parts.netloc, parts.path)
raise MemoryStoreError(f"OUSAST_MEMORY must be file:///<path> or minio://<bucket>[/<prefix>], got {text!r}")
# --- ingest and seed -----------------------------------------------------------------------------------------------
REMEMBER_OUTPUT = "memory.jsonl"
def read_memory(path: Path) -> list[dict[str, Any]]:
"""A delivered ``memory.jsonl``: every row valid, else the error names the file and line."""
rows: list[dict[str, Any]] = []
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if raw.strip():
try:
rows.append(validate_row(json.loads(raw), f"{path}:{number}"))
except ValueError as exc:
raise MemoryStoreError(f"{path}:{number}: {exc}") from exc
return rows
def ingest(run_dir: Path, store: MemoryStore | None = None) -> list[IngestResult]:
"""Every delivered ``<task>/memory.jsonl`` of ``run_dir`` (and the ``facts.json`` beside it) into the store.
A run without remember outputs is a no-op that says so; a malformed file fails before anything is written."""
store = store or open_store()
delivered = sorted(p for p in Path(run_dir).glob(f"*/{REMEMBER_OUTPUT}") if p.is_file())
if not delivered:
log.info("memory: %s has no remember outputs; nothing to ingest", run_dir)
return []
parsed = [(path, read_memory(path)) for path in delivered]
results: list[IngestResult] = []
for path, rows in parsed:
facts = path.parent / "facts.json"
results.append(store.ingest_rows(Path(run_dir).name, path.parent.name, rows, facts.read_bytes() if facts.is_file() else None))
return results
def seed(run_manifest: Path, store: MemoryStore | None = None, *, results_root: Path | None = None) -> list[str]:
"""Reuse stored facts for a Run's tasks whose ``openultrasast.io/memory-key`` matches (Req 6.1): each such task
without a directory or a state gets ``facts.json`` and a ``done`` summary and is marked done under the run
lock. Returns the seeded task names. A changed pin, candidate set or runner image finds nothing and recomputes."""
from . import reconciler
run_spec, manifests = reconciler.load_run(Path(run_manifest))
store = store or open_store()
base = reconciler.run_dir(run_spec.metadata.name, results_root)
state = (reconciler._read_json(base / "state.json") or {}).get("tasks") or {}
seeded: list[str] = []
for entry in run_spec.tasks:
text = manifests.tasks[entry.task].metadata.annotations.get(MEMORY_KEY_ANNOTATION)
if not text or (base / entry.name).exists() or state.get(entry.name, {}).get("status", "pending") != "pending":
continue
key = parse_memory_key(text)
where = {"candidates_digest": key["candidates"], "image": key["image"]}
for record in sorted(store.rows(key["repo"], key["pin"], "facts", where), key=lambda r: str(r.row["run"]), reverse=True):
data = store.get_facts(str(record.row["sha256"]))
if data is None:
continue
reused = {"sha256": record.row["sha256"], "from_run": record.row["run"], "image": key["image"]}
(base / entry.name).mkdir(parents=True)
(base / entry.name / "facts.json").write_bytes(data)
summary = {"status": "done", "units_done": 1, "units_total": 1, "usd": None, "calls": 0, "usage": {}, "model": None}
(base / entry.name / "summary.json").write_text(json.dumps({**summary, "reused": reused}, indent=2, sort_keys=True) + "\n")
reconciler.mark_done(run_spec.metadata.name, entry.name, reused=reused, results_root=results_root)
seeded.append(entry.name)
break
return seeded
def counts(results: Iterable[IngestResult]) -> dict[str, int]:
total: dict[str, int] = {}
for result in results:
for kind, number in result.kinds.items():
total[kind] = total.get(kind, 0) + number
return dict(sorted(total.items()))
__all__ = [
"KINDS",
"MEMORY_KEY_ANNOTATION",
"POPULATION_ANNOTATION",
"SPLIT_ANNOTATION",
"FileStore",
"IngestResult",
"MemoryStore",
"MemoryStoreError",
"MinioStore",
"ObjectClient",
"Record",
"SdkClient",
"counts",
"image_digest",
"ingest",
"memory_key",
"open_store",
"parse_memory_key",
"repo_key",
"row_id",
"seed",
"validate_row",
"where_sql",
]
+13 -1
View File
@@ -257,6 +257,18 @@ def _acquire_lock(base: Path) -> Path:
return lock
def mark_done(run_name: str, task: str, *, reused: Mapping[str, Any], results_root: Path | None = None) -> None:
"""Set ``task`` done without running it (its outputs came from the memory store), under the run lock."""
base = run_dir(run_name, results_root)
base.mkdir(parents=True, exist_ok=True)
lock = _acquire_lock(base)
try:
state = _State(base / "state.json", _read_json(base / "state.json") or {"run": run_name, "started": _now(), "tasks": {}})
state.set(task, status="done", finished=_now(), usd=None, model=None, reused=dict(reused))
finally:
lock.unlink(missing_ok=True)
def _await(ax: Ax, name: str, delivered: Callable[[], bool], start: Callable[[], str | None]) -> str | None:
"""Resume ``name``, ``start`` it once Running (again after a re-resume), await delivery: None, else why it failed.
``Failed`` is final only once a resume went through (ax sets it when a resume call fails); until then a
@@ -462,4 +474,4 @@ def status(run_name: str, *, units: bool = False, results_root: Path | None = No
return "\n".join(lines)
__all__ = ["Ax", "Receiver", "attribution", "doctor", "load_run", "outcome_of", "render_task", "run", "run_dir", "status"]
__all__ = ["Ax", "Receiver", "attribution", "doctor", "load_run", "mark_done", "outcome_of", "render_task", "run", "run_dir", "status"]
+246
View File
@@ -0,0 +1,246 @@
"""`remember` (harnessx-removal Requirement 6.1, design section 4): a case's delivered artifacts as memory rows.
No model, budget ``{usd: 0, calls: 0}``, one per case. It reads the case's `facts.json`, the `units.jsonl` of the
verify passes a, b and c, the final `agreed.json` (its per-candidate rows and `disputed` list) and, when present,
`alerts.jsonl`, and writes `memory.jsonl` -- rows of the kinds `facts`, `verdict`, `unit_cost` and `alert` (the
fifth kind, `proposal_outcome`, comes from the loop) -- and passes `facts.json` through. Task code interprets the
artifacts, so the reconciler and the runner stay content-blind (ai-service-plane Req 3.4).
Every row carries `id` (deterministic: :func:`..memory.row_id`), `kind`, `repo`, `pin`, `run`, `task`,
`population`, `split` and `image` (the runner image digest the case's tasks ran on).
Env contract in a Run: `OUSAST_OUTPUT_DIR`, `OUSAST_RUN`, `OUSAST_TASK`, `OUSAST_INPUT_FACTS`,
`OUSAST_INPUT_PASS_A`/`_B`, optional `OUSAST_INPUT_PASS_C`, `OUSAST_INPUT_SUMMARY_A`/`_B`/`_C` (the passes'
`summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS`; the repository and pin from the bound
Workspace (`AX_WORKSPACES_YAML`, `OUSAST_GIT_PINS`), the image from `AX_TASK_YAML`, the population and split from
`OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and
`openultrasast.io/split` annotations). Exit 0 done, 2 failed.
On the host, :func:`remember_run` does the same over a recorded run directory (``ousast plane remember <run>``):
per case without a delivered `memory.jsonl` it reads the artifacts and the rendered `task.yaml` beside them and
ingests the rows directly, writing nothing into the run directory.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import traceback
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from ..memory import REMEMBER_OUTPUT, IngestResult, MemoryStore, image_digest, ingest, repo_key, row_id, validate_row
from .repo_facts import _digest
PASSES = ("a", "b", "c")
UNKNOWN = "unknown"
@dataclass(frozen=True)
class Context:
run: str
task: str
repo: str
pin: str
image: str
population: str = UNKNOWN
split: str = UNKNOWN
def row(self, kind: str, subject: str, **fields: Any) -> dict[str, Any]:
base = {
"id": row_id(kind, self.run, self.task, subject), "kind": kind, "repo": repo_key(self.repo), "pin": self.pin,
"run": self.run, "task": self.task, "population": self.population, "split": self.split, "image": self.image,
} # fmt: skip
return {**base, **fields}
def candidates_digest(facts: Mapping[str, Any]) -> str:
"""`repo_facts._digest` of the candidate names `facts.json` was computed for (its `callers` keys)."""
names = sorted({str(key).partition("::")[2] for key in (facts.get("callers") or {})})
return _digest(names)
def _final(row: Mapping[str, Any], disputed: set[str]) -> str:
if row.get("agreed"):
return "agreed"
return "disputed" if str(row["candidate"]) in disputed else "rejected"
def rows_for(
ctx: Context,
*,
facts: bytes | None,
passes: Mapping[str, Sequence[Mapping[str, Any]]],
models: Mapping[str, str | None],
agreed: Mapping[str, Any] | None,
alerts: Sequence[Mapping[str, Any]] = (),
) -> list[dict[str, Any]]:
"""The memory rows of one case: facts, verdicts, unit costs, alerts."""
rows: list[dict[str, Any]] = []
if facts is not None:
loaded = json.loads(facts)
sha = hashlib.sha256(facts).hexdigest()
counts = loaded.get("counts") or {}
files = int(counts.get("files", len(loaded.get("files") or {})))
rows.append(ctx.row("facts", sha, sha256=sha, candidates_digest=candidates_digest(loaded), files=files))
if agreed is not None:
disputed = {str(d["candidate"]) for d in agreed.get("disputed") or []}
for cand in agreed.get("candidates") or []:
finding = cand.get("finding") or {}
fields = {
"candidate": cand["candidate"], "family": finding.get("family") or agreed.get("family"), "site": cand.get("site"),
"passes": {p: cand.get(p) for p in PASSES}, "final": _final(cand, disputed), "tiebreak": cand.get("c") is not None,
"site_match": cand.get("site_match"), "usd": cand.get("usd"), "turns": cand.get("turns"),
} # fmt: skip
rows.append(ctx.row("verdict", str(cand["candidate"]), **fields))
for label in PASSES:
for unit in passes.get(label) or []:
usage = dict(unit.get("usage") or {})
fields = {
"pass": label, "path": unit.get("path"), "usd": unit.get("usd"), "calls": usage.get("calls"), "usage": usage,
"turns": unit.get("turns"), "model": models.get(label),
} # fmt: skip
rows.append(ctx.row("unit_cost", f"{label}:{unit.get('path')}", **fields))
for alert in alerts:
fields = {k: alert.get(k) for k in ("rule_id", "rule_status", "path", "line", "function", "pin_role")}
rows.append(ctx.row("alert", f"{fields['rule_id']}:{fields['path']}:{fields['line']}", **fields))
return [validate_row(r, f"{ctx.task} {r['kind']}") for r in rows]
def _jsonl(path: Path | None) -> list[dict[str, Any]]:
if path is None or not path.is_file():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def _json(path: Path | None) -> Any:
return json.loads(path.read_text(encoding="utf-8")) if path is not None and path.is_file() else None
def _model(summary: object) -> str | None:
return str(summary["model"]) if isinstance(summary, dict) and summary.get("model") else None
def context_from(
task_doc: Mapping[str, Any], workspaces: Sequence[Mapping[str, Any]], *, run: str, task: str, population: str, split: str
) -> Context:
"""Repository, pin and image from a rendered Task and its Workspaces: the pin named in `OUSAST_GIT_PINS`, the
repository of that Workspace's git entry, the Task's image."""
spec = task_doc.get("spec") or {}
env = {str(e.get("name")): str(e.get("value")) for e in spec.get("env") or []}
pins: dict[str, str] = json.loads(env.get("OUSAST_GIT_PINS") or "{}")
if len(pins) != 1:
raise ValueError(f"{task}: expected one pinned repository in OUSAST_GIT_PINS, got {sorted(pins)}")
((ref, pin),) = pins.items()
ws_name, _, git_name = ref.partition("/")
repos = [
str(g["repo"])
for w in workspaces if (w.get("metadata") or {}).get("name") == ws_name
for g in (w.get("spec") or {}).get("git") or [] if g.get("name") == git_name
] # fmt: skip
if not repos:
raise ValueError(f"{task}: no bound Workspace git entry {ref}")
image = str(spec.get("image") or "")
if not image:
raise ValueError(f"{task}: the Task names no image")
return Context(run, task, repos[0], pin, image_digest(image), population or UNKNOWN, split or UNKNOWN)
def _rendered(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
docs = [d for d in yaml.safe_load_all(path.read_text(encoding="utf-8")) if isinstance(d, dict)]
tasks = [d for d in docs if d.get("kind") == "Task"]
if not tasks:
raise ValueError(f"{path}: no rendered Task")
return tasks[-1], [d for d in docs if d.get("kind") == "Workspace"]
def case_rows(base: Path, case: str, *, population: str, split: str) -> tuple[list[dict[str, Any]], bytes | None]:
"""The rows of one case of a recorded run directory, from its artifacts and the rendered `task.yaml`."""
facts_dir = base / f"{case}-facts"
candidates = [base / f"{case}-{step}" / "task.yaml" for step in ("facts", "va", "vb", "agree")]
rendered = next((path for path in candidates if path.is_file()), None)
if rendered is None:
raise ValueError(f"{base}: case {case} has no rendered task.yaml")
task_doc, workspaces = _rendered(rendered)
ctx = context_from(task_doc, workspaces, run=base.name, task=f"{case}-remember", population=population, split=split)
reused = (_json(facts_dir / "summary.json") or {}).get("reused")
if isinstance(reused, dict) and reused.get("image"):
ctx = Context(ctx.run, ctx.task, ctx.repo, ctx.pin, image_digest(str(reused["image"])), ctx.population, ctx.split)
facts = (facts_dir / "facts.json").read_bytes() if (facts_dir / "facts.json").is_file() else None
final = base / f"{case}-final" / "agreed.json"
agreed = _json(final if final.is_file() else base / f"{case}-agree" / "agreed.json")
passes = {p: _jsonl(base / f"{case}-v{p}" / "units.jsonl") for p in PASSES}
models = {p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES}
alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl")
return rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts), facts
def remember_run(run_dir: Path, store: MemoryStore, *, population: str = UNKNOWN, split: str = UNKNOWN) -> list[IngestResult]:
"""Ingest a run: delivered `memory.jsonl` files as they are, and every other case (a `<case>-facts` task)
computed here from its artifacts."""
base = Path(run_dir)
if not base.is_dir():
raise ValueError(f"no run directory at {base}")
results = ingest(base, store)
delivered = {r.task for r in results}
for facts_dir in sorted(base.glob("*-facts")):
case = facts_dir.name.removesuffix("-facts")
if f"{case}-remember" in delivered or (base / f"{case}-remember" / REMEMBER_OUTPUT).is_file():
continue
rows, facts = case_rows(base, case, population=population, split=split)
results.append(store.ingest_rows(base.name, f"{case}-remember", rows, facts))
return results
def _path(env: Mapping[str, str], name: str) -> Path | None:
return Path(env[name]) if env.get(name) else None
def _units(path: Path | None) -> list[dict[str, Any]]:
return _jsonl(path / "units.jsonl" if path is not None and path.is_dir() else path)
def main(environ: Mapping[str, str] | None = None) -> int:
env = environ if environ is not None else os.environ
output_dir = _path(env, "OUSAST_OUTPUT_DIR")
summary: dict[str, Any] = {"status": "failed", "units_done": 0, "units_total": 1, "usd": 0, "calls": 0, "usage": {}, "model": None}
try:
if output_dir is None:
raise ValueError("OUSAST_OUTPUT_DIR is not set")
output_dir.mkdir(parents=True, exist_ok=True)
task_doc = yaml.safe_load(env.get("AX_TASK_YAML") or "{}") or {}
task_doc.setdefault("spec", {})["env"] = [{"name": "OUSAST_GIT_PINS", "value": env.get("OUSAST_GIT_PINS") or "{}"}]
workspaces = [d for d in yaml.safe_load_all(env.get("AX_WORKSPACES_YAML") or "") if isinstance(d, dict)]
ctx = context_from(
task_doc, workspaces, run=env.get("OUSAST_RUN") or UNKNOWN, task=env.get("OUSAST_TASK") or "remember",
population=env.get("OUSAST_POPULATION") or UNKNOWN, split=env.get("OUSAST_SPLIT") or UNKNOWN,
) # fmt: skip
facts_path = _path(env, "OUSAST_INPUT_FACTS")
facts = facts_path.read_bytes() if facts_path is not None and facts_path.is_file() else None
passes = {p: _units(_path(env, f"OUSAST_INPUT_PASS_{p.upper()}")) for p in PASSES}
models = {p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES}
agreed = _json(_path(env, "OUSAST_INPUT_AGREED"))
rows = rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=_jsonl(_path(env, "OUSAST_INPUT_ALERTS")))
(output_dir / REMEMBER_OUTPUT).write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
if facts is not None:
(output_dir / "facts.json").write_bytes(facts)
summary.update(status="done", units_done=1, rows=len(rows))
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback
summary["reason"] = traceback.format_exc()[-2000:]
if output_dir is not None:
(output_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({k: summary.get(k) for k in ("status", "rows")}), file=sys.stderr)
return 0 if summary["status"] == "done" else 2
__all__ = ["Context", "candidates_digest", "case_rows", "context_from", "main", "remember_run", "rows_for"]
if __name__ == "__main__":
raise SystemExit(main())