Skip to content

Commit d428c52

Browse files
committed
fix: resolve 13 red-team CLI bugs (6 P0, 3 P1, 4 P2)
Closes #62 P0 trust/correctness: - Resolve relative source_dir/build_dir against pipeline file, not cwd (#3) - Clear synix_dir on --build-dir override to prevent stale routing (#4) - Propagate source load failures instead of silently succeeding (#5) - Add Layer.level read-only property to fix info crash (#8) - Rewrite info/status to read .synix/ snapshot store, not legacy build/ (#9) - Diff uses RefStore run history instead of legacy versions/ dir (#11) P1 operator consistency: - Planner uses estimated-count placeholders for downstream cardinality (#1) - Standardize invalid ref handling to sys.exit(1) across all inspectors (#10) - Clean also removes refs/releases/ ref files (#12) P2 docs/discoverability: - Mesh commands honor SYNIX_MESH_ROOT env var via resolve_mesh_root() (#2) - Batch planner tracks DAG cardinality instead of estimate_output_count(1) (#6) - Fix llms.txt diff syntax to match actual CLI (#7) - Add refs/plans to refs list prefix scan (#13)
1 parent a03b8d8 commit d428c52

48 files changed

Lines changed: 1133 additions & 243 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ synix lineage <artifact-id> # Provenance tree
3939
synix list # All artifacts in current snapshot
4040
synix show <id> # Render artifact content
4141
synix plan pipeline.py # Dry-run with cost estimates
42-
synix diff <ref-a> <ref-b> # Structured diff between refs
42+
synix diff [ARTIFACT_ID] --old-build-dir DIR # Diff artifacts between build dirs
4343
synix clean # Remove releases and work state
4444
```
4545

src/synix/build/batch_runner.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@ def plan_batch(pipeline: Pipeline) -> list[dict]:
612612
compute_levels(pipeline.layers)
613613
build_order = resolve_build_order(pipeline)
614614

615+
layer_cardinality: dict[str, int] = {}
615616
layers_info = []
616617
for layer in build_order:
617618
info: dict = {
@@ -622,6 +623,12 @@ def plan_batch(pipeline: Pipeline) -> list[dict]:
622623

623624
if isinstance(layer, Source):
624625
info["mode"] = "source"
626+
try:
627+
source_config = {"source_dir": pipeline.source_dir}
628+
artifacts = layer.load(source_config)
629+
layer_cardinality[layer.name] = len(artifacts)
630+
except Exception:
631+
layer_cardinality[layer.name] = 1
625632
elif isinstance(layer, Transform):
626633
try:
627634
mode = _resolve_batch_mode(layer, pipeline)
@@ -637,13 +644,15 @@ def plan_batch(pipeline: Pipeline) -> list[dict]:
637644
info["mode"] = mode
638645
info["batch_param"] = layer.batch
639646

640-
# Estimate work units
647+
# Estimate work units using DAG-aware cardinality tracking
641648
dep_counts = sum(
642-
dep_layer.estimate_output_count(1) for dep_layer in layer.depends_on if isinstance(dep_layer, Transform)
649+
layer_cardinality.get(dep.name, 1)
650+
for dep in layer.depends_on
643651
)
644652
if dep_counts == 0:
645-
dep_counts = len(layer.depends_on) or 1
653+
dep_counts = 1
646654
info["estimated_requests"] = layer.estimate_output_count(dep_counts)
655+
layer_cardinality[layer.name] = layer.estimate_output_count(dep_counts)
647656
else:
648657
info["mode"] = "unknown"
649658

src/synix/build/diff.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
import difflib
66
import json
7+
import logging
78
from dataclasses import dataclass, field
89
from pathlib import Path
910

10-
from synix.build.refs import synix_dir_for_build_dir
11-
from synix.build.snapshot_view import SnapshotArtifactCache
11+
from synix.build.refs import RefStore, synix_dir_for_build_dir
12+
from synix.build.snapshot_view import SnapshotArtifactCache, SnapshotView
1213
from synix.core.models import Artifact
1314

15+
logger = logging.getLogger(__name__)
16+
1417

1518
@dataclass
1619
class ArtifactDiff:
@@ -134,7 +137,52 @@ def diff_artifact_by_label(
134137
return None
135138
return diff_artifact(old_art, new_art)
136139

137-
# No previous build dir — check for version history
140+
# No previous build dir — try snapshot-era ref lookup.
141+
# Find the previous run ref whose OID differs from HEAD.
142+
try:
143+
ref_store = RefStore(synix_dir)
144+
head_oid = ref_store.read_ref("HEAD")
145+
if head_oid is not None:
146+
run_refs = ref_store.iter_refs("refs/runs")
147+
# Find runs whose OID differs from HEAD (i.e. previous runs)
148+
prev_ref: str | None = None
149+
# iter_refs returns sorted ascending; walk in reverse to find
150+
# the most recent run that is NOT the current HEAD.
151+
for ref_name, oid in reversed(run_refs):
152+
if oid != head_oid:
153+
prev_ref = ref_name
154+
break
155+
156+
if prev_ref is not None:
157+
prev_view = SnapshotView.open(synix_dir, ref=prev_ref)
158+
try:
159+
prev_data = prev_view.get_artifact(label)
160+
except KeyError:
161+
prev_data = None
162+
163+
if prev_data is not None:
164+
from datetime import datetime
165+
166+
created_at_str = prev_data.get("metadata", {}).get("created_at")
167+
created_at = (
168+
datetime.fromisoformat(created_at_str) if created_at_str else datetime.now()
169+
)
170+
old_art = Artifact(
171+
label=prev_data["label"],
172+
artifact_type=prev_data["artifact_type"],
173+
artifact_id=prev_data["artifact_id"],
174+
input_ids=prev_data.get("input_ids", []),
175+
prompt_id=prev_data.get("prompt_id"),
176+
model_config=prev_data.get("model_config"),
177+
created_at=created_at,
178+
content=prev_data["content"],
179+
metadata=prev_data.get("metadata", {}),
180+
)
181+
return diff_artifact(old_art, new_art)
182+
except (ValueError, FileNotFoundError, KeyError):
183+
logger.debug("Snapshot-era ref lookup failed for diff of %r", label, exc_info=True)
184+
185+
# Legacy fallback: check for build/versions/<label> directories
138186
versions_dir = Path(build_dir) / "versions" / label
139187
if not versions_dir.exists():
140188
return None

src/synix/build/pipeline.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@ def load_pipeline(path: str) -> Pipeline:
4242
raise ValueError(f"Pipeline module {path} must define a 'pipeline' variable")
4343
if not isinstance(pipeline, Pipeline):
4444
raise TypeError(f"'pipeline' variable must be a Pipeline instance, got {type(pipeline)}")
45+
46+
# Resolve relative paths against the pipeline file location
47+
pipeline_parent = filepath.parent
48+
if not Path(pipeline.source_dir).is_absolute():
49+
pipeline.source_dir = str((pipeline_parent / pipeline.source_dir).resolve())
50+
if not Path(pipeline.build_dir).is_absolute():
51+
pipeline.build_dir = str((pipeline_parent / pipeline.build_dir).resolve())
52+
4553
if pipeline.synix_dir is None:
46-
build_dir = Path(pipeline.build_dir)
47-
if not build_dir.is_absolute():
48-
build_dir = (filepath.parent / build_dir).resolve()
49-
pipeline.synix_dir = str(synix_dir_for_build_dir(build_dir))
54+
pipeline.synix_dir = str(synix_dir_for_build_dir(Path(pipeline.build_dir)))
5055

5156
validate_pipeline(pipeline)
5257
return pipeline

src/synix/build/plan.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,20 @@ def _plan_source_layer(
315315

316316
try:
317317
artifacts = layer.load(source_config)
318-
except Exception:
319-
# If loading fails (e.g., source_dir doesn't exist), report 0 artifacts
320-
artifacts = []
318+
except Exception as exc:
319+
layer_artifacts[layer.name] = []
320+
return StepPlan(
321+
name=layer.name,
322+
level=layer._level,
323+
status="error",
324+
artifact_count=0,
325+
rebuild_count=0,
326+
cached_count=0,
327+
estimated_llm_calls=0,
328+
estimated_tokens=0,
329+
estimated_cost=0.0,
330+
reason=f"source load failed: {exc}",
331+
)
321332

322333
layer_artifacts[layer.name] = artifacts
323334
artifact_count = len(artifacts)
@@ -424,7 +435,10 @@ def _plan_transform_layer(
424435
estimated_count = layer.estimate_output_count(len(inputs))
425436

426437
# Store placeholder artifacts for downstream planning
427-
layer_artifacts[layer.name] = inputs # approximate
438+
layer_artifacts[layer.name] = [
439+
Artifact(label=f"__plan__{layer.name}_{i}", artifact_type="placeholder", content="")
440+
for i in range(estimated_count)
441+
]
428442

429443
return StepPlan(
430444
name=layer.name,
@@ -508,7 +522,13 @@ def _plan_transform_layer(
508522
reason = "new"
509523

510524
# Store existing artifacts for downstream planning
511-
layer_artifacts[layer.name] = existing if existing else inputs
525+
if existing:
526+
layer_artifacts[layer.name] = existing
527+
else:
528+
layer_artifacts[layer.name] = [
529+
Artifact(label=f"__plan__{layer.name}_{i}", artifact_type="placeholder", content="")
530+
for i in range(total_count)
531+
]
512532

513533
return StepPlan(
514534
name=layer.name,

src/synix/build/runner.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,8 @@ def run(
168168

169169
try:
170170
artifacts = layer.load(source_config)
171-
except Exception:
172-
logger.warning("Source %s failed to load", layer.name, exc_info=True)
173-
artifacts = []
171+
except Exception as exc:
172+
raise RuntimeError(f"Source '{layer.name}' failed to load: {exc}") from exc
174173

175174
# Record source artifacts in snapshot
176175
for artifact in artifacts:

src/synix/cli/artifact_commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ def list_artifacts(layer: str | None, build_dir: str, synix_dir: str | None, ref
5858

5959
try:
6060
view = SnapshotView.open(resolved_synix_dir, ref=ref)
61-
except ValueError:
62-
console.print("[dim]No artifacts found.[/dim]")
63-
return
61+
except ValueError as e:
62+
console.print(f"[red]Cannot open snapshot:[/red] {e}")
63+
sys.exit(1)
6464

6565
artifacts = view.list_artifacts()
6666

src/synix/cli/build_commands.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import sys
66
import time
7+
from pathlib import Path
78

89
import click
910
from rich import box
@@ -162,9 +163,10 @@ def build(
162163
sys.exit(1)
163164

164165
if source_dir:
165-
pipeline.source_dir = source_dir
166+
pipeline.source_dir = str(Path(source_dir).resolve())
166167
if build_dir:
167-
pipeline.build_dir = build_dir
168+
pipeline.build_dir = str(Path(build_dir).resolve())
169+
pipeline.synix_dir = None # Force recomputation from overridden build_dir
168170

169171
concurrency_label = f"{concurrency} threads" if concurrency > 1 else "sequential"
170172
console.print(
@@ -428,9 +430,10 @@ def plan(
428430
sys.exit(1)
429431

430432
if source_dir:
431-
pipeline.source_dir = source_dir
433+
pipeline.source_dir = str(Path(source_dir).resolve())
432434
if build_dir:
433-
pipeline.build_dir = build_dir
435+
pipeline.build_dir = str(Path(build_dir).resolve())
436+
pipeline.synix_dir = None # Force recomputation from overridden build_dir
434437

435438
try:
436439
build_plan = plan_build(pipeline, source_dir=source_dir)
@@ -464,6 +467,7 @@ def plan(
464467
"cached": "cyan",
465468
"rebuild": "yellow",
466469
"new": "green",
470+
"error": "red",
467471
}
468472

469473
# Model label for root
@@ -663,8 +667,6 @@ def _display_source_change_warnings(build_plan, pipeline):
663667
if not build_dir:
664668
return
665669

666-
from pathlib import Path
667-
668670
from synix.build.refs import synix_dir_for_build_dir
669671
from synix.build.snapshot_view import SnapshotArtifactCache
670672

src/synix/cli/clean_commands.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,13 @@ def clean(build_dir: str, synix_dir: str | None, release_name: str | None, yes:
7474
for name, path in targets:
7575
shutil.rmtree(path)
7676
console.print(f"[green]Cleaned:[/green] {name} ({path})")
77+
78+
# Clean corresponding release refs
79+
if release_name:
80+
ref_file = sd / "refs" / "releases" / release_name
81+
if ref_file.exists():
82+
ref_file.unlink()
83+
else:
84+
release_refs_dir = sd / "refs" / "releases"
85+
if release_refs_dir.exists():
86+
shutil.rmtree(release_refs_dir)

0 commit comments

Comments
 (0)