agentic-dev-loop 1.0.0a20__tar.gz → 1.0.0a21__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentic-dev-loop might be problematic. Click here for more details.
- {agentic_dev_loop-1.0.0a20/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a21}/PKG-INFO +2 -1
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/README.md +1 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/config.py +147 -0
- agentic_dev_loop-1.0.0a21/src/agentic_dev_loop/core/paths.py +69 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/isolation.py +76 -23
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/presets.py +3 -14
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/step_workspace.py +25 -12
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/sync_back.py +34 -21
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21/src/agentic_dev_loop.egg-info}/PKG-INFO +2 -1
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
- agentic_dev_loop-1.0.0a21/tests/test_028_declared_caches.py +241 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_isolation.py +81 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_repo_ascii.py +4 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_step_workspace.py +46 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_sync_back.py +85 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_pbt.py +19 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a20/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a21}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a21
|
|
4
4
|
Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
|
|
5
5
|
Author: Luke Wilson
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -220,6 +220,7 @@ steps:
|
|
|
220
220
|
| `extensions_dir` | Where consumer `extensions:` modules resolve - defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
|
|
221
221
|
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
222
222
|
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
|
|
223
|
+
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
223
224
|
|
|
224
225
|
### Secrets (`secrets:`) - refs only, never values
|
|
225
226
|
|
|
@@ -178,6 +178,7 @@ steps:
|
|
|
178
178
|
| `extensions_dir` | Where consumer `extensions:` modules resolve - defaults to `~/.agentic-dev-loop/extensions`, **outside** the workspace. Workspace-resident extension/tool modules are refused at load. |
|
|
179
179
|
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
180
180
|
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
|
|
181
|
+
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
181
182
|
|
|
182
183
|
### Secrets (`secrets:`) - refs only, never values
|
|
183
184
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0a21"
|
|
8
8
|
description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -4,12 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import re
|
|
7
|
+
import warnings
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
import yaml
|
|
11
12
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
13
|
|
|
14
|
+
from agentic_dev_loop.core.paths import _has_wildcards, path_excluded, relative_parts
|
|
15
|
+
|
|
13
16
|
TERMINALS = frozenset({"success", "failure"})
|
|
14
17
|
KNOWN_SECRET_PROVIDERS = frozenset({"env"})
|
|
15
18
|
MOUNT_MODES = frozenset({"rw", "ro"})
|
|
@@ -51,6 +54,38 @@ def _validate_mount_path(path: str) -> str:
|
|
|
51
54
|
return "/".join(parts) if parts else "."
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def _validate_declared_path(path: str, *, allow_wildcards: bool) -> str:
|
|
58
|
+
"""Validate one declared ``caches:``/``sync_excludes:`` entry (028).
|
|
59
|
+
|
|
60
|
+
Same repo-relative contract as mount paths: non-empty, no absolute paths,
|
|
61
|
+
no ``..`` traversal, no ``.git`` component, no host-only dir. Wildcard
|
|
62
|
+
entries (``sync_excludes:`` only) skip wildcard segments in the literal
|
|
63
|
+
checks; the literal segments still obey the same rules. The normalised
|
|
64
|
+
forward-slash form is returned.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(path, str) or not path.strip():
|
|
67
|
+
raise ValueError("declared path must be a non-empty string")
|
|
68
|
+
p = path.strip()
|
|
69
|
+
if p.startswith(("/", "\\")) or (len(p) > 1 and p[1] == ":"):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"declared path {path!r} must be repo-relative (no absolute paths)")
|
|
72
|
+
parts = [seg for seg in p.replace("\\", "/").split("/")
|
|
73
|
+
if seg not in ("", ".")]
|
|
74
|
+
for seg in parts:
|
|
75
|
+
if allow_wildcards and any(ch in seg for ch in "*?["):
|
|
76
|
+
continue
|
|
77
|
+
if seg == "..":
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"declared path {path!r} escapes the repo (no '..' traversal)")
|
|
80
|
+
if seg == ".git":
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"declared path {path!r} must never name '.git'")
|
|
83
|
+
if seg in HOST_ONLY_MOUNT_DIRS:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"declared path {path!r} names the host-only dir {seg!r}")
|
|
86
|
+
return "/".join(parts) if parts else "."
|
|
87
|
+
|
|
88
|
+
|
|
54
89
|
def parse_egress_target(spec: str) -> tuple[str, int | None]:
|
|
55
90
|
"""Parse ``host``, ``host:port``, or ``scheme://host[:port]`` into
|
|
56
91
|
``(host, port)``. Malformed entries that can never match fail loud
|
|
@@ -404,6 +439,13 @@ class PipelineConfig(BaseModel):
|
|
|
404
439
|
# missing-dependency fail-fast.
|
|
405
440
|
deps_policy: str = "locked"
|
|
406
441
|
lockfiles: list[str] = []
|
|
442
|
+
# 028: pipeline-declared toolchain caches (rw bind-mounts from the real
|
|
443
|
+
# workspace) and run-wide sync-back excludes (scratch semantics: skipped
|
|
444
|
+
# from both the export copy and sync-back). Both default to [] - absent
|
|
445
|
+
# means "no toolchain caches beyond the engine's own sandbox venv" and
|
|
446
|
+
# "no sync-back excludes".
|
|
447
|
+
caches: list[str] = []
|
|
448
|
+
sync_excludes: list[str] = []
|
|
407
449
|
|
|
408
450
|
@field_validator("deps_policy")
|
|
409
451
|
@classmethod
|
|
@@ -418,6 +460,21 @@ class PipelineConfig(BaseModel):
|
|
|
418
460
|
def _coerce_lockfiles(cls, v: Any) -> Any:
|
|
419
461
|
return [v] if isinstance(v, str) else v
|
|
420
462
|
|
|
463
|
+
@field_validator("caches", "sync_excludes", mode="before")
|
|
464
|
+
@classmethod
|
|
465
|
+
def _coerce_declared_paths(cls, v: Any) -> Any:
|
|
466
|
+
return [v] if isinstance(v, str) else v
|
|
467
|
+
|
|
468
|
+
@field_validator("caches")
|
|
469
|
+
@classmethod
|
|
470
|
+
def _valid_caches(cls, v: list[str]) -> list[str]:
|
|
471
|
+
return [_validate_declared_path(c, allow_wildcards=False) for c in v]
|
|
472
|
+
|
|
473
|
+
@field_validator("sync_excludes")
|
|
474
|
+
@classmethod
|
|
475
|
+
def _valid_sync_excludes(cls, v: list[str]) -> list[str]:
|
|
476
|
+
return [_validate_declared_path(c, allow_wildcards=True) for c in v]
|
|
477
|
+
|
|
421
478
|
@field_validator("secrets", mode="before")
|
|
422
479
|
@classmethod
|
|
423
480
|
def _reject_value_shaped_refs(cls, v: Any) -> Any:
|
|
@@ -464,6 +521,96 @@ class PipelineConfig(BaseModel):
|
|
|
464
521
|
f"the host-only state_dir {self.state_dir!r}")
|
|
465
522
|
return self
|
|
466
523
|
|
|
524
|
+
@model_validator(mode="after")
|
|
525
|
+
def _caches_avoid_state_dir(self) -> "PipelineConfig":
|
|
526
|
+
"""028: a declared cache may never name (or sit under) the host-only
|
|
527
|
+
run-state dir - it would bind-mount the engine's own state into the
|
|
528
|
+
sandbox as a rw cache."""
|
|
529
|
+
sd = self.state_dir.strip().rstrip("/\\")
|
|
530
|
+
if not sd or sd in ("/", "."):
|
|
531
|
+
return self
|
|
532
|
+
sd_parts = relative_parts(sd)
|
|
533
|
+
if not sd_parts:
|
|
534
|
+
return self
|
|
535
|
+
for cache in self.caches:
|
|
536
|
+
cp = relative_parts(cache)
|
|
537
|
+
if cp[:len(sd_parts)] == sd_parts:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"cache path {cache!r} names the host-only state_dir "
|
|
540
|
+
f"{self.state_dir!r}")
|
|
541
|
+
return self
|
|
542
|
+
|
|
543
|
+
@model_validator(mode="after")
|
|
544
|
+
def _mounts_avoid_caches(self) -> "PipelineConfig":
|
|
545
|
+
"""028/BR-1.3: a step mount equal to or under a declared cache is
|
|
546
|
+
rejected - the rw cache submount would silently defeat a declared
|
|
547
|
+
``ro`` mount there. A whole-tree ``.`` mount stays fine (the cache
|
|
548
|
+
submount layers on top)."""
|
|
549
|
+
cache_parts = [relative_parts(c) for c in self.caches if relative_parts(c)]
|
|
550
|
+
if not cache_parts:
|
|
551
|
+
return self
|
|
552
|
+
for step in self.steps:
|
|
553
|
+
for m in step.mounts:
|
|
554
|
+
mp = relative_parts(m.path)
|
|
555
|
+
for cp in cache_parts:
|
|
556
|
+
if mp[:len(cp)] == cp:
|
|
557
|
+
raise ValueError(
|
|
558
|
+
f"step {step.name!r}: mount path {m.path!r} names "
|
|
559
|
+
f"declared cache {('/'.join(cp))!r}")
|
|
560
|
+
return self
|
|
561
|
+
|
|
562
|
+
@model_validator(mode="after")
|
|
563
|
+
def _excludes_avoid_caches(self) -> "PipelineConfig":
|
|
564
|
+
"""028: a literal sync-exclude equal to or under a declared cache is
|
|
565
|
+
redundant (caches already behave as scratch) and fails loud. Wildcard
|
|
566
|
+
excludes are skipped - their overlap is fuzzy, not a contradiction."""
|
|
567
|
+
cache_parts = [relative_parts(c) for c in self.caches if relative_parts(c)]
|
|
568
|
+
if not cache_parts:
|
|
569
|
+
return self
|
|
570
|
+
for exclude in self.sync_excludes:
|
|
571
|
+
if _has_wildcards(exclude):
|
|
572
|
+
continue
|
|
573
|
+
ep = relative_parts(exclude)
|
|
574
|
+
for cp in cache_parts:
|
|
575
|
+
if ep[:len(cp)] == cp:
|
|
576
|
+
raise ValueError(
|
|
577
|
+
f"sync_excludes entry {exclude!r} is redundant: it is "
|
|
578
|
+
f"already excluded as declared cache "
|
|
579
|
+
f"{('/'.join(cp))!r}")
|
|
580
|
+
return self
|
|
581
|
+
|
|
582
|
+
@model_validator(mode="after")
|
|
583
|
+
def _sandbox_venv_not_declared_cache(self) -> "PipelineConfig":
|
|
584
|
+
"""028: the sandbox venv is engine infrastructure, never author data.
|
|
585
|
+
Declaring ``.venv-sandbox`` in ``caches:`` while the 022 relocation
|
|
586
|
+
(``isolation.sandbox_venv_dir``) is set is a contradiction."""
|
|
587
|
+
if self.isolation is None or self.isolation.sandbox_venv_dir is None:
|
|
588
|
+
return self
|
|
589
|
+
for cache in self.caches:
|
|
590
|
+
if relative_parts(cache) == (".venv-sandbox",):
|
|
591
|
+
raise ValueError(
|
|
592
|
+
"declaring '.venv-sandbox' in 'caches:' contradicts "
|
|
593
|
+
"'isolation.sandbox_venv_dir' - the sandbox venv is "
|
|
594
|
+
"relocated outside the workspace (022)")
|
|
595
|
+
return self
|
|
596
|
+
|
|
597
|
+
@model_validator(mode="after")
|
|
598
|
+
def _warn_shadowed_artifacts(self) -> "PipelineConfig":
|
|
599
|
+
"""028: an artifact/produces path matching a sync-exclude would never
|
|
600
|
+
sync back. Non-fatal warning (wildcard overlap is fuzzy, so a hard
|
|
601
|
+
failure would false-positive)."""
|
|
602
|
+
if not self.sync_excludes:
|
|
603
|
+
return self
|
|
604
|
+
for step in self.steps:
|
|
605
|
+
for artifact in list(step.produces) + (
|
|
606
|
+
[step.artifact] if step.artifact is not None else []):
|
|
607
|
+
if path_excluded(artifact, self.sync_excludes):
|
|
608
|
+
warnings.warn(
|
|
609
|
+
f"step {step.name!r}: {artifact!r} matches a "
|
|
610
|
+
"sync_excludes entry and will never sync back",
|
|
611
|
+
stacklevel=2)
|
|
612
|
+
return self
|
|
613
|
+
|
|
467
614
|
@model_validator(mode="after")
|
|
468
615
|
def _check_graph(self) -> "PipelineConfig":
|
|
469
616
|
names = [s.name for s in self.steps]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Repo-relative path helpers shared by config validation and sync-back.
|
|
2
|
+
|
|
3
|
+
One place for the declared-path exclusion vocabulary (028): literal entries
|
|
4
|
+
exclude by component prefix (the dir and everything under it); wildcard
|
|
5
|
+
entries match by ``fnmatch.fnmatchcase`` on the full posix relpath. Both the
|
|
6
|
+
``caches:``/``sync_excludes:`` validators (core) and the export/sync-back
|
|
7
|
+
planners (engine) consume these, so the semantics cannot drift.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def relative_parts(path: str) -> tuple[str, ...]:
|
|
16
|
+
"""Normalise a repo-relative path into its non-empty, non-``.`` segments."""
|
|
17
|
+
return tuple(seg for seg in path.replace("\\", "/").split("/")
|
|
18
|
+
if seg not in ("", "."))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _has_wildcards(spec: str) -> bool:
|
|
22
|
+
return any(ch in spec for ch in "*?[")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def path_excluded(rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
|
|
26
|
+
"""True when a repo-relative posix path is excluded by a declared
|
|
27
|
+
``sync_excludes:`` spec (028).
|
|
28
|
+
|
|
29
|
+
Literal entries exclude by component prefix; wildcard entries by
|
|
30
|
+
``fnmatch.fnmatchcase`` on the full posix relpath. ``*`` spans path
|
|
31
|
+
separators (fnmatch string semantics), so ``**/bin/**`` matches a literal
|
|
32
|
+
``/bin/`` segment at any depth and ``*.dll`` matches at any depth.
|
|
33
|
+
"""
|
|
34
|
+
parts = relative_parts(rel)
|
|
35
|
+
for spec in excludes:
|
|
36
|
+
if _has_wildcards(spec):
|
|
37
|
+
if fnmatch.fnmatchcase(rel, spec):
|
|
38
|
+
return True
|
|
39
|
+
else:
|
|
40
|
+
spec_parts = relative_parts(spec)
|
|
41
|
+
if not spec_parts:
|
|
42
|
+
# ``.`` (or a blank entry) excludes the whole tree.
|
|
43
|
+
return True
|
|
44
|
+
if parts[:len(spec_parts)] == spec_parts:
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dir_excluded(dir_rel: str, excludes: tuple[str, ...] | list[str]) -> bool:
|
|
50
|
+
"""True when a directory (as a posix relpath) must be pruned from a walk.
|
|
51
|
+
|
|
52
|
+
Literal excludes prune their subtree. Wildcard excludes prune only when
|
|
53
|
+
the directory itself is the matched target - checked with a trailing
|
|
54
|
+
separator so ``**/bin/**`` prunes ``src/proj/bin`` but ``*.dll`` never
|
|
55
|
+
prunes a directory.
|
|
56
|
+
"""
|
|
57
|
+
parts = relative_parts(dir_rel)
|
|
58
|
+
for spec in excludes:
|
|
59
|
+
if _has_wildcards(spec):
|
|
60
|
+
if fnmatch.fnmatchcase(dir_rel.rstrip("/") + "/", spec):
|
|
61
|
+
return True
|
|
62
|
+
else:
|
|
63
|
+
spec_parts = relative_parts(spec)
|
|
64
|
+
if not spec_parts:
|
|
65
|
+
# ``.`` (or a blank entry) excludes the whole tree.
|
|
66
|
+
return True
|
|
67
|
+
if parts[:len(spec_parts)] == spec_parts:
|
|
68
|
+
return True
|
|
69
|
+
return False
|
{agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -40,7 +40,6 @@ from agentic_dev_loop.core.config import IsolationConfig, PipelineConfig
|
|
|
40
40
|
from .egress_policy import EgressPolicy
|
|
41
41
|
from .egress_proxy import EgressProxy
|
|
42
42
|
from .mounts import Mount, MountSet
|
|
43
|
-
from .presets import CACHE_DIRS
|
|
44
43
|
from .proc import needs_shell, split_command
|
|
45
44
|
from .seccomp import resolve_seccomp_profile
|
|
46
45
|
from .step_workspace import StepWorkspace
|
|
@@ -230,21 +229,6 @@ class ContainerProfile:
|
|
|
230
229
|
return container_profile_key(self.mount_set, self.egress_policy)
|
|
231
230
|
|
|
232
231
|
|
|
233
|
-
def discover_cache_paths(workspace: str | Path,
|
|
234
|
-
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
235
|
-
"""The in-workspace cache dirs to bind-mount into the export (BR-3/F2).
|
|
236
|
-
|
|
237
|
-
Only dirs that actually exist are caches - an absent cache dir is simply
|
|
238
|
-
not a cache (the toolchain is unused), so materialisation never fails on
|
|
239
|
-
a repo that does not use, say, node. The relocated sandbox venv (022) is
|
|
240
|
-
never a cache: it bind-mounts from outside the workspace instead."""
|
|
241
|
-
ws = Path(workspace)
|
|
242
|
-
paths = [d for d in CACHE_DIRS if (ws / d).is_dir()]
|
|
243
|
-
if sandbox_venv_dir is not None:
|
|
244
|
-
paths = [d for d in paths if d != SANDBOX_VENV_DIR]
|
|
245
|
-
return paths
|
|
246
|
-
|
|
247
|
-
|
|
248
232
|
@dataclass
|
|
249
233
|
class _ProfileContainer:
|
|
250
234
|
"""One pooled execution container (D-1/BR-10): its resolved profile plus
|
|
@@ -523,6 +507,53 @@ def remove_sandbox_venv(workspace: str | Path, *,
|
|
|
523
507
|
f"- remove it manually and re-run (docs/setup.md §5)") from e
|
|
524
508
|
|
|
525
509
|
|
|
510
|
+
def repair_venv_shebangs(venv_dir: str | Path, *,
|
|
511
|
+
state_dir: str | Path,
|
|
512
|
+
export_root: str | Path) -> None:
|
|
513
|
+
"""Repair stale export-path shebangs in a sandbox venv (028).
|
|
514
|
+
|
|
515
|
+
Each run materialises a fresh export under ``state_dir/exports/<uuid>/``,
|
|
516
|
+
so a venv built by an earlier run carries console-script shebangs that bake
|
|
517
|
+
that (now dead) export path. Rewrite every regular file under
|
|
518
|
+
``<venv_dir>/bin/`` whose first line starts with ``#!`` and points at a
|
|
519
|
+
dead ``state_dir/exports/`` path to the current run's venv python
|
|
520
|
+
(``{export_root}/.venv-sandbox/bin/python``). Symlinks and files without a
|
|
521
|
+
shebang are left alone; idempotent; host-side, before any exec.
|
|
522
|
+
"""
|
|
523
|
+
bin_dir = Path(venv_dir) / "bin"
|
|
524
|
+
if not bin_dir.is_dir():
|
|
525
|
+
return
|
|
526
|
+
target = str(Path(export_root) / SANDBOX_VENV_DIR / "bin" / "python")
|
|
527
|
+
stale_prefix = str(Path(state_dir) / "exports").rstrip("/\\") + "/"
|
|
528
|
+
for entry in sorted(bin_dir.iterdir()):
|
|
529
|
+
if entry.is_symlink() or not entry.is_file():
|
|
530
|
+
continue
|
|
531
|
+
try:
|
|
532
|
+
with entry.open("rb") as fh:
|
|
533
|
+
first = fh.readline(256)
|
|
534
|
+
except OSError:
|
|
535
|
+
continue
|
|
536
|
+
if not first.startswith(b"#!"):
|
|
537
|
+
continue
|
|
538
|
+
line = first.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
|
|
539
|
+
interpreter = line[2:].strip()
|
|
540
|
+
if not interpreter or interpreter == target:
|
|
541
|
+
continue
|
|
542
|
+
if not interpreter.startswith(stale_prefix):
|
|
543
|
+
continue
|
|
544
|
+
_rewrite_shebang(entry, target)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _rewrite_shebang(path: Path, target: str) -> None:
|
|
548
|
+
"""Replace only the first line of a regular file, preserving the rest."""
|
|
549
|
+
data = path.read_bytes()
|
|
550
|
+
nl = data.find(b"\n")
|
|
551
|
+
if nl == -1:
|
|
552
|
+
return
|
|
553
|
+
body = data[nl + 1:]
|
|
554
|
+
path.write_bytes(f"#!{target}\n".encode("utf-8") + body)
|
|
555
|
+
|
|
556
|
+
|
|
526
557
|
def host_user_flag() -> str | None:
|
|
527
558
|
"""The ``docker --user`` value binding the sandbox to the host user.
|
|
528
559
|
|
|
@@ -759,7 +790,8 @@ class DockerBackend(Sandbox):
|
|
|
759
790
|
network: bool = False, sandbox_venv_dir: str | None = None,
|
|
760
791
|
seccomp_profile: str | None = None,
|
|
761
792
|
export_root: str | None = None,
|
|
762
|
-
cache_mounts: list[tuple[str, str, str]] | None = None
|
|
793
|
+
cache_mounts: list[tuple[str, str, str]] | None = None,
|
|
794
|
+
sync_excludes: list[str] | None = None):
|
|
763
795
|
self._image = image
|
|
764
796
|
self._workspace = workspace
|
|
765
797
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -783,6 +815,9 @@ class DockerBackend(Sandbox):
|
|
|
783
815
|
for _source, target, _mode in self._cache_mounts:
|
|
784
816
|
self._cache_paths.append(
|
|
785
817
|
os.path.relpath(target, export_root).replace("\\", "/"))
|
|
818
|
+
# 028: run-wide sync-back excludes (scratch paths - skipped from both
|
|
819
|
+
# the export copy and every snapshot).
|
|
820
|
+
self._sync_excludes = list(sync_excludes or ())
|
|
786
821
|
self._baseline: dict[str, str] = {}
|
|
787
822
|
# The profile-keyed pool (D-1/BR-10): profile key -> running container.
|
|
788
823
|
self._pool: dict[tuple[str, bool], _ProfileContainer] = {}
|
|
@@ -889,7 +924,8 @@ class DockerBackend(Sandbox):
|
|
|
889
924
|
self._started = True
|
|
890
925
|
if not self._baseline:
|
|
891
926
|
self._baseline = _snapshot(Path(self._export_root),
|
|
892
|
-
self._cache_paths
|
|
927
|
+
self._cache_paths,
|
|
928
|
+
self._sync_excludes)
|
|
893
929
|
|
|
894
930
|
def provision(self, setup_cmd: str, stamp_key: str, stamp_dir: str,
|
|
895
931
|
force: bool) -> None:
|
|
@@ -1043,14 +1079,15 @@ class DockerBackend(Sandbox):
|
|
|
1043
1079
|
report: SyncBackReport = sync_back(
|
|
1044
1080
|
self._export_root, self._workspace, list(mount_set.rw_paths()),
|
|
1045
1081
|
baseline=self._baseline, step_id=None,
|
|
1046
|
-
cache_paths=self._cache_paths)
|
|
1082
|
+
cache_paths=self._cache_paths, excludes=self._sync_excludes)
|
|
1047
1083
|
if report.conflicts:
|
|
1048
1084
|
raise SandboxError(
|
|
1049
1085
|
f"AE-SBX-016: sync-back conflict in "
|
|
1050
1086
|
f"{', '.join(report.conflicts)} - the real repo changed "
|
|
1051
1087
|
f"underneath; resolve before retrying (BR-4.4)")
|
|
1052
1088
|
self._baseline = _snapshot(Path(self._export_root),
|
|
1053
|
-
self._cache_paths
|
|
1089
|
+
self._cache_paths,
|
|
1090
|
+
self._sync_excludes)
|
|
1054
1091
|
append_audit_record(self._iso_state_dir, {
|
|
1055
1092
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1056
1093
|
"event": "sync_back",
|
|
@@ -1295,18 +1332,34 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1295
1332
|
cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
|
|
1296
1333
|
|
|
1297
1334
|
export_root = str(Path(state_dir) / "exports" / uuid.uuid4().hex)
|
|
1298
|
-
|
|
1335
|
+
# 028: cache paths are pipeline-author data (``caches:``), plus the engine's
|
|
1336
|
+
# own sandbox venv when it is not relocated (016/022 behaviour - the venv
|
|
1337
|
+
# is a cache only once it exists). Declared-but-missing caches are
|
|
1338
|
+
# auto-created host-side (uid-owned, empty) before export planning, so the
|
|
1339
|
+
# missing-cache guard in plan_step_workspace stays a race guard only.
|
|
1340
|
+
cache_paths = list(cfg.caches)
|
|
1341
|
+
if sandbox_venv_dir is None and (workspace / SANDBOX_VENV_DIR).is_dir():
|
|
1342
|
+
if SANDBOX_VENV_DIR not in cache_paths:
|
|
1343
|
+
cache_paths.append(SANDBOX_VENV_DIR)
|
|
1344
|
+
for cache in cfg.caches:
|
|
1345
|
+
(workspace / cache).mkdir(parents=True, exist_ok=True)
|
|
1299
1346
|
ws = StepWorkspace.materialise(
|
|
1300
1347
|
workspace, cache_paths, sys.platform, export_root,
|
|
1301
|
-
state_dir=str(state_dir))
|
|
1348
|
+
state_dir=str(state_dir), excludes=list(cfg.sync_excludes))
|
|
1302
1349
|
export_root = str(ws.export_root)
|
|
1303
1350
|
cache_mounts = ws.mount_points()[1:] # skip the export root itself
|
|
1351
|
+
# Repair console-script shebangs that bake a previous run's dead export
|
|
1352
|
+
# path (028): a venv built by export A must execute its ``bin/*`` scripts
|
|
1353
|
+
# when mounted into export B.
|
|
1354
|
+
venv_dir = Path(sandbox_venv_dir) if sandbox_venv_dir else workspace / SANDBOX_VENV_DIR
|
|
1355
|
+
repair_venv_shebangs(venv_dir, state_dir=state_dir, export_root=export_root)
|
|
1304
1356
|
|
|
1305
1357
|
backend = DockerBackend(
|
|
1306
1358
|
image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
|
|
1307
1359
|
profile=profile, runner=runner, network=needs_network,
|
|
1308
1360
|
sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
|
|
1309
|
-
export_root=export_root, cache_mounts=cache_mounts
|
|
1361
|
+
export_root=export_root, cache_mounts=cache_mounts,
|
|
1362
|
+
sync_excludes=list(cfg.sync_excludes))
|
|
1310
1363
|
backend.ensure_image()
|
|
1311
1364
|
backend.create_execution_container()
|
|
1312
1365
|
if refresh is None:
|
{agentic_dev_loop-1.0.0a20 → agentic_dev_loop-1.0.0a21}/src/agentic_dev_loop/engine/presets.py
RENAMED
|
@@ -2,26 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Preset data, never mechanism (D-2): one ``package-registries`` preset serves
|
|
4
4
|
every toolchain, so the restore step stays toolchain-agnostic. Adding a
|
|
5
|
-
toolchain adds a target here plus a
|
|
6
|
-
egress code exists.
|
|
5
|
+
toolchain adds a target here plus a ``caches:`` entry in the pipeline - no
|
|
6
|
+
per-toolchain egress code exists. Toolchain cache dirs are pipeline author
|
|
7
|
+
data (``caches:``), never engine preset data (028).
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
from agentic_dev_loop.core.config import KNOWN_EGRESS_PRESETS
|
|
12
13
|
|
|
13
|
-
# The per-toolchain cache dirs (BR-9.3, BR-3.1): in-workspace paths that are
|
|
14
|
-
# bind-mounted rw into the export rather than copied. Cache declarations are
|
|
15
|
-
# preset data (D-2) - adding a toolchain adds a path here, never mechanism.
|
|
16
|
-
CACHE_DIRS: tuple[str, ...] = (
|
|
17
|
-
".venv-sandbox",
|
|
18
|
-
"node_modules",
|
|
19
|
-
"qa/node_modules",
|
|
20
|
-
"qa/.browsers",
|
|
21
|
-
".nuget-packages",
|
|
22
|
-
"target",
|
|
23
|
-
)
|
|
24
|
-
|
|
25
14
|
|
|
26
15
|
# The package registries every toolchain's locked restore touches (BR-9.1):
|
|
27
16
|
# pypi, npm, NuGet, cargo, plus the VCS/source-install hosts shared by all.
|
|
@@ -14,6 +14,8 @@ import shutil
|
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from agentic_dev_loop.core.paths import dir_excluded, path_excluded
|
|
18
|
+
|
|
17
19
|
from .sync_back import SyncBackReport, _snapshot, sync_back
|
|
18
20
|
|
|
19
21
|
# Host-only dirs never copied into the export (022). ``.git`` is excluded by
|
|
@@ -48,16 +50,19 @@ def _relative_parts(path: str) -> tuple[str, ...]:
|
|
|
48
50
|
|
|
49
51
|
def plan_step_workspace(repo: str | Path, cache_paths: list[str],
|
|
50
52
|
platform: str, export_root: str | Path,
|
|
51
|
-
state_dir: str | None = None
|
|
53
|
+
state_dir: str | None = None,
|
|
54
|
+
excludes: list[str] | None = None) -> StepWorkspacePlan:
|
|
52
55
|
"""Plan the export (BR-2/BR-3): copy every workspace file except
|
|
53
|
-
``.git``/host-only dirs/cache paths/``state_dir
|
|
54
|
-
declared cache path rw. A missing declared cache path is a loud
|
|
55
|
-
(BR-3.2, pillar 5)
|
|
56
|
-
|
|
56
|
+
``.git``/host-only dirs/cache paths/``state_dir``/sync-excludes; bind-mount
|
|
57
|
+
each declared cache path rw. A missing declared cache path is a loud
|
|
58
|
+
failure (BR-3.2, pillar 5) - kept as a race guard; startup pre-creates the
|
|
59
|
+
dirs host-side (028). Excluded paths are scratch: skipped at copy time with
|
|
60
|
+
no mount. Pure over platform facts - deterministic given the repo state."""
|
|
57
61
|
repo = Path(repo)
|
|
58
62
|
export_root = Path(export_root)
|
|
59
63
|
cache_rels = [_relative_parts(c) for c in cache_paths]
|
|
60
64
|
state_dir_rel = _relative_parts(state_dir) if state_dir else None
|
|
65
|
+
excludes = list(excludes or ())
|
|
61
66
|
|
|
62
67
|
for cache in cache_paths:
|
|
63
68
|
if not (repo / cache).exists():
|
|
@@ -73,11 +78,14 @@ def plan_step_workspace(repo: str | Path, cache_paths: list[str],
|
|
|
73
78
|
d for d in dirnames
|
|
74
79
|
if d not in _EXCLUDED_DIRS
|
|
75
80
|
and not _excluded(rel_dir_parts + (d,), cache_rels, state_dir_rel)
|
|
81
|
+
and not dir_excluded((rel_dir / d).as_posix(), excludes)
|
|
76
82
|
]
|
|
77
83
|
for name in filenames:
|
|
78
84
|
parts = rel_dir_parts + (name,)
|
|
79
85
|
if _excluded(parts, cache_rels, state_dir_rel):
|
|
80
86
|
continue
|
|
87
|
+
if path_excluded("/".join(parts), excludes):
|
|
88
|
+
continue
|
|
81
89
|
copy_paths.append("/".join(parts))
|
|
82
90
|
|
|
83
91
|
cache_mounts = tuple(
|
|
@@ -122,16 +130,19 @@ class StepWorkspace:
|
|
|
122
130
|
@classmethod
|
|
123
131
|
def materialise(cls, repo: str | Path, cache_paths: list[str],
|
|
124
132
|
platform: str, export_root: str | Path,
|
|
125
|
-
state_dir: str | None = None
|
|
133
|
+
state_dir: str | None = None,
|
|
134
|
+
excludes: list[str] | None = None) -> "StepWorkspace":
|
|
126
135
|
"""Copy the workspace into the export, excluding ``.git``/host-only
|
|
127
|
-
dirs/caches/``state_dir
|
|
128
|
-
rw bind-mounts have uid-owned destinations (the
|
|
136
|
+
dirs/caches/``state_dir``/sync-excludes; pre-create the cache mount
|
|
137
|
+
targets so the rw bind-mounts have uid-owned destinations (the
|
|
138
|
+
AE-SBX-009 class)."""
|
|
129
139
|
plan = plan_step_workspace(repo, cache_paths, platform, export_root,
|
|
130
|
-
state_dir=state_dir)
|
|
140
|
+
state_dir=state_dir, excludes=excludes)
|
|
131
141
|
ws = cls(plan, real_repo=repo)
|
|
132
142
|
ws._copy(repo)
|
|
133
143
|
ws._baseline = _snapshot(
|
|
134
|
-
ws._export_root, cache_paths=[c.relative for c in plan.cache_mounts]
|
|
144
|
+
ws._export_root, cache_paths=[c.relative for c in plan.cache_mounts],
|
|
145
|
+
excludes=list(excludes or ()))
|
|
135
146
|
return ws
|
|
136
147
|
|
|
137
148
|
def _copy(self, repo: Path) -> None:
|
|
@@ -154,10 +165,12 @@ class StepWorkspace:
|
|
|
154
165
|
points.append((cache.source, f"{root}/{cache.relative}", "rw"))
|
|
155
166
|
return points
|
|
156
167
|
|
|
157
|
-
def sync_back(self, declared_rw: list[str]
|
|
168
|
+
def sync_back(self, declared_rw: list[str],
|
|
169
|
+
excludes: list[str] | None = None) -> SyncBackReport:
|
|
158
170
|
"""Git-aware diff-copy of declared-rw changes back to the real repo
|
|
159
171
|
(BR-4). Delegates to ``sync_back``; conflicts are loud."""
|
|
160
172
|
return sync_back(
|
|
161
173
|
self._export_root, self._real_repo, declared_rw,
|
|
162
174
|
baseline=getattr(self, "_baseline", None),
|
|
163
|
-
cache_paths=[c.relative for c in self._plan.cache_mounts]
|
|
175
|
+
cache_paths=[c.relative for c in self._plan.cache_mounts],
|
|
176
|
+
excludes=list(excludes or ()))
|