agent-orchestration-process 0.2.0__tar.gz → 0.2.2__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.
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/PKG-INFO +1 -1
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/pyproject.toml +1 -1
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/pyproject.toml.orig +1 -1
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/cli.py +101 -10
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/isolation.py +1 -1
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/models.py +29 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/prompt_transport.py +25 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/provider_versions.py +6 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/runner.py +239 -36
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/LICENSE +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/README.md +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__init__.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__main__.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/batch.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_auth.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_routes.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/dsh_runner.mjs +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress_bridge.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/integration.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/locks.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/migration.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_catalog.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_listing.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/pricing.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/worktrees.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode.py +0 -0
- {agent_orchestration_process-0.2.0 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode_protocol.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-orchestration-process
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance
|
|
5
5
|
Keywords: agent-orchestration,ai-agents,autoresearch,coding-agents,git-worktrees
|
|
6
6
|
Author: AOP contributors
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-orchestration-process"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-orchestration-process"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -22,8 +22,14 @@ from .isolation import PROFILES, explain_profile, resolve_policy
|
|
|
22
22
|
from .locks import exclusive_lock, task_lock_path
|
|
23
23
|
from .model_catalog import ModelCatalog, ensure_catalog_fresh
|
|
24
24
|
from .model_listing import AGENTS, AvailableModel, list_models
|
|
25
|
-
from .models import RunRequest, RunResult
|
|
26
|
-
from .runner import
|
|
25
|
+
from .models import PromptPart, RunRequest, RunResult
|
|
26
|
+
from .runner import (
|
|
27
|
+
AgentRunner,
|
|
28
|
+
RunStore,
|
|
29
|
+
adapter_for,
|
|
30
|
+
plan_inputs,
|
|
31
|
+
provider_runtime_record,
|
|
32
|
+
)
|
|
27
33
|
from .migration import migrate_runs, migration_roots, print_counts
|
|
28
34
|
from .worktrees import (
|
|
29
35
|
AOPError,
|
|
@@ -396,6 +402,25 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
396
402
|
input_names=tuple(Path(path).name for path in args.input_paths),
|
|
397
403
|
).to_dict()
|
|
398
404
|
preview["provider"] = args.agent
|
|
405
|
+
planned = plan_inputs(
|
|
406
|
+
args.input_paths,
|
|
407
|
+
excludes=args.input_excludes,
|
|
408
|
+
skip_external_links=args.skip_external_links,
|
|
409
|
+
protected=(manager.state_dir, manager.sealed_runtime_dir),
|
|
410
|
+
)
|
|
411
|
+
preview["inputs"]["planned"] = [
|
|
412
|
+
{
|
|
413
|
+
"name": plan.alias,
|
|
414
|
+
"source": os.fspath(plan.source),
|
|
415
|
+
"files": len(plan.entries) if plan.entries else 1,
|
|
416
|
+
"size_bytes": sum(path.stat().st_size for _, path in plan.entries)
|
|
417
|
+
if plan.source.is_dir()
|
|
418
|
+
else plan.source.stat().st_size,
|
|
419
|
+
"excluded": list(plan.excluded),
|
|
420
|
+
"links": list(plan.links),
|
|
421
|
+
}
|
|
422
|
+
for plan in planned
|
|
423
|
+
]
|
|
399
424
|
preview["provider_runtime"] = provider_runtime_record(
|
|
400
425
|
adapter_for(args.agent)
|
|
401
426
|
)
|
|
@@ -409,9 +434,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
409
434
|
prefix="aop: ",
|
|
410
435
|
stream=sys.stderr,
|
|
411
436
|
)
|
|
437
|
+
prompt, prompt_parts = _read_prompt(args)
|
|
412
438
|
result = AgentRunner(manager, adapter_for(args.agent)).run(
|
|
413
439
|
task=task,
|
|
414
|
-
prompt=
|
|
440
|
+
prompt=prompt,
|
|
441
|
+
prompt_parts=prompt_parts,
|
|
415
442
|
base=args.base,
|
|
416
443
|
model=args.model,
|
|
417
444
|
inference_provider=args.inference_provider,
|
|
@@ -422,6 +449,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
422
449
|
timeout_seconds=args.timeout,
|
|
423
450
|
artifacts=args.artifact,
|
|
424
451
|
input_paths=args.input_paths,
|
|
452
|
+
input_excludes=args.input_excludes,
|
|
453
|
+
skip_external_links=args.skip_external_links,
|
|
425
454
|
system_prompt=_read_system_prompt(args),
|
|
426
455
|
tools=args.tools,
|
|
427
456
|
provider_base_url=args.provider_base_url,
|
|
@@ -432,9 +461,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
432
461
|
return _report_run(result, manager, json_output=args.json)
|
|
433
462
|
|
|
434
463
|
if args.command == "resume":
|
|
464
|
+
prompt, prompt_parts = _read_prompt(args)
|
|
435
465
|
result = AgentRunner(manager).resume(
|
|
436
466
|
run_id=args.run_id,
|
|
437
|
-
prompt=
|
|
467
|
+
prompt=prompt,
|
|
468
|
+
prompt_parts=prompt_parts,
|
|
438
469
|
timeout_seconds=args.timeout,
|
|
439
470
|
artifacts=args.artifact,
|
|
440
471
|
input_paths=args.input_paths,
|
|
@@ -461,7 +492,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
461
492
|
print(f"aop: {error}", file=sys.stderr)
|
|
462
493
|
return 2
|
|
463
494
|
except FileNotFoundError as error:
|
|
464
|
-
print(
|
|
495
|
+
print(
|
|
496
|
+
f"aop: command not found: {error.filename} "
|
|
497
|
+
f"(PATH={os.environ.get('PATH', '')})",
|
|
498
|
+
file=sys.stderr,
|
|
499
|
+
)
|
|
465
500
|
return 127
|
|
466
501
|
except KeyboardInterrupt:
|
|
467
502
|
print("aop: interrupted", file=sys.stderr)
|
|
@@ -472,6 +507,13 @@ def _add_prompt_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
472
507
|
prompts = parser.add_mutually_exclusive_group(required=True)
|
|
473
508
|
prompts.add_argument("--prompt")
|
|
474
509
|
prompts.add_argument("--prompt-file", type=Path)
|
|
510
|
+
prompts.add_argument(
|
|
511
|
+
"--prompt-parts",
|
|
512
|
+
type=Path,
|
|
513
|
+
metavar="PATH",
|
|
514
|
+
help='JSON list of prompt blocks, each {"text": ..., "cache": true|false}; '
|
|
515
|
+
"Claude caches the prompt through the one cached block (claude only)",
|
|
516
|
+
)
|
|
475
517
|
|
|
476
518
|
|
|
477
519
|
def _add_artifact_arguments(parser: argparse.ArgumentParser) -> None:
|
|
@@ -503,6 +545,25 @@ def _add_input_arguments(
|
|
|
503
545
|
metavar="PATH",
|
|
504
546
|
help=help_text,
|
|
505
547
|
)
|
|
548
|
+
if resume:
|
|
549
|
+
return
|
|
550
|
+
parser.add_argument(
|
|
551
|
+
"--input-exclude",
|
|
552
|
+
action="append",
|
|
553
|
+
default=[],
|
|
554
|
+
dest="input_excludes",
|
|
555
|
+
metavar="GLOB",
|
|
556
|
+
help="leave matching paths out of every input directory; a pattern "
|
|
557
|
+
"without '/' matches names at any depth, one with '/' matches paths "
|
|
558
|
+
"from the input's root; repeat for more",
|
|
559
|
+
)
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"--skip-external-links",
|
|
562
|
+
action="store_true",
|
|
563
|
+
help="leave out symlinks that point outside their input instead of "
|
|
564
|
+
"failing; links that stay inside an input are always copied as their "
|
|
565
|
+
"targets",
|
|
566
|
+
)
|
|
506
567
|
|
|
507
568
|
|
|
508
569
|
def _tool_list(value: str) -> tuple[str, ...]:
|
|
@@ -520,15 +581,36 @@ def _read_system_prompt(args: argparse.Namespace) -> str | None:
|
|
|
520
581
|
) from error
|
|
521
582
|
|
|
522
583
|
|
|
523
|
-
def _read_prompt(args: argparse.Namespace) -> str:
|
|
584
|
+
def _read_prompt(args: argparse.Namespace) -> tuple[str, tuple[PromptPart, ...] | None]:
|
|
524
585
|
if args.prompt is not None:
|
|
525
|
-
return args.prompt
|
|
586
|
+
return args.prompt, None
|
|
587
|
+
path = args.prompt_file or args.prompt_parts
|
|
526
588
|
try:
|
|
527
|
-
|
|
589
|
+
text = path.read_text()
|
|
528
590
|
except OSError as error:
|
|
591
|
+
raise AOPError(f"could not read prompt file {path}: {error}") from error
|
|
592
|
+
if args.prompt_file is not None:
|
|
593
|
+
return text, None
|
|
594
|
+
try:
|
|
595
|
+
blocks = json.loads(text)
|
|
596
|
+
except json.JSONDecodeError as error:
|
|
597
|
+
raise AOPError(f"prompt parts file {path} is not JSON: {error}") from error
|
|
598
|
+
if not isinstance(blocks, list) or not all(
|
|
599
|
+
isinstance(block, dict)
|
|
600
|
+
and set(block) <= {"text", "cache"}
|
|
601
|
+
and isinstance(block.get("text"), str)
|
|
602
|
+
and isinstance(block.get("cache", False), bool)
|
|
603
|
+
for block in blocks
|
|
604
|
+
):
|
|
529
605
|
raise AOPError(
|
|
530
|
-
f"
|
|
531
|
-
|
|
606
|
+
f"prompt parts file {path} must be a list of "
|
|
607
|
+
'{"text": string, "cache": boolean} objects'
|
|
608
|
+
)
|
|
609
|
+
parts = tuple(
|
|
610
|
+
PromptPart(chars=len(block["text"]), cache=block.get("cache", False))
|
|
611
|
+
for block in blocks
|
|
612
|
+
)
|
|
613
|
+
return "".join(block["text"] for block in blocks), parts
|
|
532
614
|
|
|
533
615
|
|
|
534
616
|
def _report_policy(
|
|
@@ -574,6 +656,15 @@ def _report_policy(
|
|
|
574
656
|
f"{prefix}inputs: {inputs['mode']} ({len(inputs.get('names', []))} declared)",
|
|
575
657
|
file=stream,
|
|
576
658
|
)
|
|
659
|
+
for item in inputs.get("planned", []):
|
|
660
|
+
skipped = sum(link["action"] == "skipped" for link in item["links"])
|
|
661
|
+
print(
|
|
662
|
+
f"{prefix} {item['name']}: {item['files']} files, "
|
|
663
|
+
f"{item['size_bytes'] / 1e6:.1f} MB; {len(item['excluded'])} paths "
|
|
664
|
+
f"excluded; {len(item['links']) - skipped} symlinks followed, "
|
|
665
|
+
f"{skipped} skipped",
|
|
666
|
+
file=stream,
|
|
667
|
+
)
|
|
577
668
|
print(
|
|
578
669
|
f"{prefix}inherited local instructions: {instructions['inherited_local']}; "
|
|
579
670
|
f"provider built-in prompt: {instructions['provider_builtin']}; "
|
|
@@ -157,7 +157,7 @@ def resolve_policy(
|
|
|
157
157
|
host={
|
|
158
158
|
"access": selected.host_access,
|
|
159
159
|
"runtime_paths": (
|
|
160
|
-
["/usr", "/bin", "/lib", "/lib64", "/etc runtime files"]
|
|
160
|
+
["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc runtime files"]
|
|
161
161
|
if selected.host_access == "runtime-only"
|
|
162
162
|
else ["/"]
|
|
163
163
|
),
|
|
@@ -28,11 +28,19 @@ class InputSnapshot:
|
|
|
28
28
|
size_bytes: int
|
|
29
29
|
sha256: str
|
|
30
30
|
files: tuple[InputFile, ...]
|
|
31
|
+
# Paths left out of the snapshot, relative to the input: matches of the
|
|
32
|
+
# run's input excludes and AOP's own state directories.
|
|
33
|
+
excluded: tuple[str, ...] = ()
|
|
34
|
+
# Symlinks found in the input: {"path", "target", "action"}, where action
|
|
35
|
+
# is "followed" for a target inside the input and "skipped" otherwise.
|
|
36
|
+
links: tuple[dict[str, str], ...] = ()
|
|
31
37
|
|
|
32
38
|
@classmethod
|
|
33
39
|
def from_dict(cls, value: dict[str, Any]) -> InputSnapshot:
|
|
34
40
|
fields = dict(value)
|
|
35
41
|
fields["files"] = tuple(InputFile(**item) for item in fields.get("files", ()))
|
|
42
|
+
fields["excluded"] = tuple(fields.get("excluded", ()))
|
|
43
|
+
fields["links"] = tuple(fields.get("links", ()))
|
|
36
44
|
return cls(**fields)
|
|
37
45
|
|
|
38
46
|
|
|
@@ -57,6 +65,15 @@ class InferenceRoute:
|
|
|
57
65
|
return cls(**fields)
|
|
58
66
|
|
|
59
67
|
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class PromptPart:
|
|
70
|
+
"""One content block of a prompt; `cache` asks Claude to cache the prompt
|
|
71
|
+
up to and including this block. The text itself stays in RunRequest.prompt."""
|
|
72
|
+
|
|
73
|
+
chars: int
|
|
74
|
+
cache: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
60
77
|
@dataclass(frozen=True)
|
|
61
78
|
class RunRequest:
|
|
62
79
|
run_id: str
|
|
@@ -90,6 +107,13 @@ class RunRequest:
|
|
|
90
107
|
allowed_urls: tuple[str, ...] = ()
|
|
91
108
|
# A sealed run's private, initially empty /workspace is writable.
|
|
92
109
|
writable_workspace: bool = False
|
|
110
|
+
# The prompt split into content blocks, in order; their lengths add up to
|
|
111
|
+
# len(prompt). None sends the prompt as one block.
|
|
112
|
+
prompt_parts: tuple[PromptPart, ...] | None = None
|
|
113
|
+
# Glob patterns left out of every input directory, and whether symlinks
|
|
114
|
+
# that leave their input are skipped instead of failing the run.
|
|
115
|
+
input_excludes: tuple[str, ...] = ()
|
|
116
|
+
skip_external_links: bool = False
|
|
93
117
|
# How `aop migrate-results` converted an older record, if it did.
|
|
94
118
|
migration: dict[str, Any] | None = None
|
|
95
119
|
|
|
@@ -106,6 +130,11 @@ class RunRequest:
|
|
|
106
130
|
if fields.get("tools") is not None:
|
|
107
131
|
fields["tools"] = tuple(fields["tools"])
|
|
108
132
|
fields["allowed_urls"] = tuple(fields.get("allowed_urls", ()))
|
|
133
|
+
fields["input_excludes"] = tuple(fields.get("input_excludes", ()))
|
|
134
|
+
if fields.get("prompt_parts") is not None:
|
|
135
|
+
fields["prompt_parts"] = tuple(
|
|
136
|
+
PromptPart(**part) for part in fields["prompt_parts"]
|
|
137
|
+
)
|
|
109
138
|
fields["inputs"] = tuple(
|
|
110
139
|
InputSnapshot.from_dict(item) for item in fields["inputs"]
|
|
111
140
|
)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""Prompt transport independent of the OS argument and environment limits."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Sequence
|
|
3
4
|
from contextlib import contextmanager
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
import tempfile
|
|
8
9
|
|
|
10
|
+
from .models import PromptPart
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
# Native protocols remain in their adapters. Runtime-file is only for CLIs
|
|
11
14
|
# whose one-shot parser accepts text exclusively as an argument.
|
|
@@ -47,6 +50,28 @@ def stdin_prompt(provider: str, prompt: str) -> str | None:
|
|
|
47
50
|
return None
|
|
48
51
|
|
|
49
52
|
|
|
53
|
+
def claude_message(prompt: str, parts: Sequence[PromptPart]) -> str:
|
|
54
|
+
"""A stream-json user message whose content blocks follow `parts`.
|
|
55
|
+
|
|
56
|
+
Claude Code marks its own system blocks and the context it appends after
|
|
57
|
+
the prompt with one-hour cache marks, and a five-minute mark must not
|
|
58
|
+
precede a one-hour one, so a caller's mark uses one hour too.
|
|
59
|
+
"""
|
|
60
|
+
content = []
|
|
61
|
+
start = 0
|
|
62
|
+
for part in parts:
|
|
63
|
+
block: dict[str, object] = {
|
|
64
|
+
"type": "text",
|
|
65
|
+
"text": prompt[start : start + part.chars],
|
|
66
|
+
}
|
|
67
|
+
if part.cache:
|
|
68
|
+
block["cache_control"] = {"type": "ephemeral", "ttl": "1h"}
|
|
69
|
+
content.append(block)
|
|
70
|
+
start += part.chars
|
|
71
|
+
message = {"type": "user", "message": {"role": "user", "content": content}}
|
|
72
|
+
return json.dumps(message) + "\n"
|
|
73
|
+
|
|
74
|
+
|
|
50
75
|
@contextmanager
|
|
51
76
|
def prompt_stream(prompt: str | None):
|
|
52
77
|
"""A seekable stdin avoids blocking on pipe writes before the timeout starts."""
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
|
|
@@ -24,6 +25,11 @@ def require_supported_agy(binary: str) -> str:
|
|
|
24
25
|
capture_output=True,
|
|
25
26
|
timeout=5,
|
|
26
27
|
)
|
|
28
|
+
except FileNotFoundError as error:
|
|
29
|
+
raise AOPError(
|
|
30
|
+
f"{binary} not found on PATH ({os.environ.get('PATH', '')}); set "
|
|
31
|
+
"AOP_AGY_BIN to its absolute path"
|
|
32
|
+
) from error
|
|
27
33
|
except OSError as error:
|
|
28
34
|
raise AOPError(f"could not query Agy version: {error}") from error
|
|
29
35
|
except subprocess.TimeoutExpired as error:
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
6
|
import contextlib
|
|
7
|
+
import fnmatch
|
|
7
8
|
import hashlib
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
@@ -39,6 +40,7 @@ from .models import (
|
|
|
39
40
|
InputFile,
|
|
40
41
|
InputSnapshot,
|
|
41
42
|
InferenceRoute,
|
|
43
|
+
PromptPart,
|
|
42
44
|
RunArtifact,
|
|
43
45
|
RunRequest,
|
|
44
46
|
RunResult,
|
|
@@ -1074,7 +1076,10 @@ def _capture_process(
|
|
|
1074
1076
|
prompt: str | None,
|
|
1075
1077
|
timeout_seconds: float | None,
|
|
1076
1078
|
is_response: Callable[[str], bool],
|
|
1079
|
+
live_dir: Path | None = None,
|
|
1077
1080
|
) -> _ProcessCapture:
|
|
1081
|
+
"""Run a harness to completion; with `live_dir`, its stdout and stderr also
|
|
1082
|
+
grow in live_dir/events.jsonl and live_dir/stderr.log while it runs."""
|
|
1078
1083
|
started = time.monotonic()
|
|
1079
1084
|
timed_out = False
|
|
1080
1085
|
first_event_seconds = None
|
|
@@ -1105,6 +1110,11 @@ def _capture_process(
|
|
|
1105
1110
|
|
|
1106
1111
|
stdout_parts: list[str] = []
|
|
1107
1112
|
stderr_parts: list[str] = []
|
|
1113
|
+
live = contextlib.ExitStack()
|
|
1114
|
+
live_stdout = live_stderr = None
|
|
1115
|
+
if live_dir is not None:
|
|
1116
|
+
live_stdout = live.enter_context((live_dir / "events.jsonl").open("w"))
|
|
1117
|
+
live_stderr = live.enter_context((live_dir / "stderr.log").open("w"))
|
|
1108
1118
|
|
|
1109
1119
|
def read_stdout() -> None:
|
|
1110
1120
|
nonlocal first_event_seconds, first_response_seconds
|
|
@@ -1116,10 +1126,17 @@ def _capture_process(
|
|
|
1116
1126
|
if first_response_seconds is None and is_response(line):
|
|
1117
1127
|
first_response_seconds = elapsed
|
|
1118
1128
|
stdout_parts.append(line)
|
|
1129
|
+
if live_stdout is not None:
|
|
1130
|
+
live_stdout.write(line)
|
|
1131
|
+
live_stdout.flush()
|
|
1119
1132
|
|
|
1120
1133
|
def read_stderr() -> None:
|
|
1121
1134
|
assert process.stderr is not None
|
|
1122
|
-
|
|
1135
|
+
for line in process.stderr:
|
|
1136
|
+
stderr_parts.append(line)
|
|
1137
|
+
if live_stderr is not None:
|
|
1138
|
+
live_stderr.write(line)
|
|
1139
|
+
live_stderr.flush()
|
|
1123
1140
|
|
|
1124
1141
|
stdout_reader = threading.Thread(target=read_stdout, daemon=True)
|
|
1125
1142
|
stderr_reader = threading.Thread(target=read_stderr, daemon=True)
|
|
@@ -1149,6 +1166,8 @@ def _capture_process(
|
|
|
1149
1166
|
_close_finished_streams(
|
|
1150
1167
|
(stdout_reader, process.stdout), (stderr_reader, process.stderr)
|
|
1151
1168
|
)
|
|
1169
|
+
if not (stdout_reader.is_alive() or stderr_reader.is_alive()):
|
|
1170
|
+
live.close()
|
|
1152
1171
|
return _ProcessCapture(
|
|
1153
1172
|
stdout="".join(stdout_parts),
|
|
1154
1173
|
stderr="".join(stderr_parts),
|
|
@@ -1221,9 +1240,14 @@ class ClaudeAdapter:
|
|
|
1221
1240
|
command,
|
|
1222
1241
|
cwd=worktree.path,
|
|
1223
1242
|
environment=environment,
|
|
1224
|
-
prompt=
|
|
1243
|
+
prompt=(
|
|
1244
|
+
prompt_transport.claude_message(request.prompt, request.prompt_parts)
|
|
1245
|
+
if request.prompt_parts is not None
|
|
1246
|
+
else prompt_transport.stdin_prompt(self.provider, request.prompt)
|
|
1247
|
+
),
|
|
1225
1248
|
timeout_seconds=request.timeout_seconds,
|
|
1226
1249
|
is_response=self._is_response,
|
|
1250
|
+
live_dir=run_dir,
|
|
1227
1251
|
)
|
|
1228
1252
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
1229
1253
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -1396,6 +1420,8 @@ class ClaudeAdapter:
|
|
|
1396
1420
|
command.extend(["--system-prompt", request.system_prompt])
|
|
1397
1421
|
if request.thinking_display is not None:
|
|
1398
1422
|
command.extend(["--thinking-display", request.thinking_display])
|
|
1423
|
+
if request.prompt_parts is not None:
|
|
1424
|
+
command.extend(["--input-format", "stream-json"])
|
|
1399
1425
|
if request.session_id:
|
|
1400
1426
|
command.extend(["--resume", request.session_id])
|
|
1401
1427
|
else:
|
|
@@ -1861,6 +1887,7 @@ class GrokAdapter:
|
|
|
1861
1887
|
prompt=None,
|
|
1862
1888
|
timeout_seconds=request.timeout_seconds,
|
|
1863
1889
|
is_response=self._is_response,
|
|
1890
|
+
live_dir=run_dir,
|
|
1864
1891
|
)
|
|
1865
1892
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
1866
1893
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -2194,6 +2221,7 @@ class CursorAdapter:
|
|
|
2194
2221
|
prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
|
|
2195
2222
|
timeout_seconds=request.timeout_seconds,
|
|
2196
2223
|
is_response=self._is_response,
|
|
2224
|
+
live_dir=run_dir,
|
|
2197
2225
|
)
|
|
2198
2226
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
2199
2227
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -2412,6 +2440,7 @@ class DevinAdapter:
|
|
|
2412
2440
|
prompt=None,
|
|
2413
2441
|
timeout_seconds=request.timeout_seconds,
|
|
2414
2442
|
is_response=lambda line: bool(line.strip()),
|
|
2443
|
+
live_dir=run_dir,
|
|
2415
2444
|
)
|
|
2416
2445
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
2417
2446
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -2673,6 +2702,7 @@ class OpenCodeAdapter:
|
|
|
2673
2702
|
prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
|
|
2674
2703
|
timeout_seconds=request.timeout_seconds,
|
|
2675
2704
|
is_response=self._is_response,
|
|
2705
|
+
live_dir=run_dir,
|
|
2676
2706
|
)
|
|
2677
2707
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
2678
2708
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -3033,6 +3063,7 @@ class AgyAdapter:
|
|
|
3033
3063
|
prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
|
|
3034
3064
|
timeout_seconds=request.timeout_seconds,
|
|
3035
3065
|
is_response=self._is_response,
|
|
3066
|
+
live_dir=run_dir,
|
|
3036
3067
|
)
|
|
3037
3068
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
3038
3069
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -3317,6 +3348,7 @@ class HermesAdapter:
|
|
|
3317
3348
|
prompt=None,
|
|
3318
3349
|
timeout_seconds=request.timeout_seconds,
|
|
3319
3350
|
is_response=lambda line: bool(line.strip()),
|
|
3351
|
+
live_dir=run_dir,
|
|
3320
3352
|
)
|
|
3321
3353
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
3322
3354
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -3765,6 +3797,7 @@ class DeepSeekHarnessAdapter:
|
|
|
3765
3797
|
prompt=None,
|
|
3766
3798
|
timeout_seconds=request.timeout_seconds,
|
|
3767
3799
|
is_response=self._is_response,
|
|
3800
|
+
live_dir=run_dir,
|
|
3768
3801
|
)
|
|
3769
3802
|
_atomic_write(run_dir / "events.jsonl", capture.stdout)
|
|
3770
3803
|
_atomic_write(run_dir / "stderr.log", capture.stderr)
|
|
@@ -4205,7 +4238,7 @@ def _isolated_root_command(bwrap: str) -> list[str]:
|
|
|
4205
4238
|
"--tmpfs",
|
|
4206
4239
|
"/tmp",
|
|
4207
4240
|
]
|
|
4208
|
-
for path in ("/usr", "/bin", "/lib", "/lib64"):
|
|
4241
|
+
for path in ("/usr", "/bin", "/sbin", "/lib", "/lib64"):
|
|
4209
4242
|
source = Path(path)
|
|
4210
4243
|
if source.exists():
|
|
4211
4244
|
command.extend(["--ro-bind", path, path])
|
|
@@ -4214,6 +4247,9 @@ def _isolated_root_command(bwrap: str) -> list[str]:
|
|
|
4214
4247
|
"/etc/ca-certificates",
|
|
4215
4248
|
"/etc/crypto-policies",
|
|
4216
4249
|
"/etc/hosts",
|
|
4250
|
+
# The dynamic linker finds libraries in folders registered under
|
|
4251
|
+
# /etc/ld.so.conf.d only through this cache.
|
|
4252
|
+
"/etc/ld.so.cache",
|
|
4217
4253
|
"/etc/localtime",
|
|
4218
4254
|
"/etc/nsswitch.conf",
|
|
4219
4255
|
"/etc/pki",
|
|
@@ -5775,8 +5811,20 @@ class AgentRunner:
|
|
|
5775
5811
|
allowed_urls: Sequence[str] = (),
|
|
5776
5812
|
writable_workspace: bool = False,
|
|
5777
5813
|
thinking_display: str | None = None,
|
|
5814
|
+
prompt_parts: Sequence[PromptPart] | None = None,
|
|
5815
|
+
input_excludes: Sequence[str] = (),
|
|
5816
|
+
skip_external_links: bool = False,
|
|
5778
5817
|
) -> RunResult:
|
|
5779
5818
|
self._validate_mode(mode)
|
|
5819
|
+
prompt_parts = self._validated_prompt_parts(prompt, prompt_parts)
|
|
5820
|
+
input_excludes = tuple(input_excludes)
|
|
5821
|
+
# Every input problem fails here, before any state is created.
|
|
5822
|
+
plan_inputs(
|
|
5823
|
+
input_paths,
|
|
5824
|
+
excludes=input_excludes,
|
|
5825
|
+
skip_external_links=skip_external_links,
|
|
5826
|
+
protected=self._state_roots(),
|
|
5827
|
+
)
|
|
5780
5828
|
if writable_workspace and profile != "sealed":
|
|
5781
5829
|
raise AOPError("--writable-workspace applies only to the sealed profile")
|
|
5782
5830
|
if thinking_display is not None:
|
|
@@ -5822,6 +5870,9 @@ class AgentRunner:
|
|
|
5822
5870
|
allowed_urls=allowed_urls,
|
|
5823
5871
|
writable_workspace=writable_workspace,
|
|
5824
5872
|
thinking_display=thinking_display,
|
|
5873
|
+
prompt_parts=prompt_parts,
|
|
5874
|
+
input_excludes=input_excludes,
|
|
5875
|
+
skip_external_links=skip_external_links,
|
|
5825
5876
|
)
|
|
5826
5877
|
worktree = (
|
|
5827
5878
|
self._create_sealed_workspace(request.run_id)
|
|
@@ -5854,6 +5905,7 @@ class AgentRunner:
|
|
|
5854
5905
|
timeout_seconds: float | None = None,
|
|
5855
5906
|
artifacts: Sequence[str] = (),
|
|
5856
5907
|
input_paths: Sequence[str | os.PathLike[str]] | None = None,
|
|
5908
|
+
prompt_parts: Sequence[PromptPart] | None = None,
|
|
5857
5909
|
_task_lock_held: bool = False,
|
|
5858
5910
|
) -> RunResult:
|
|
5859
5911
|
parent_request = self.store.load_request(run_id)
|
|
@@ -5865,6 +5917,7 @@ class AgentRunner:
|
|
|
5865
5917
|
)
|
|
5866
5918
|
self.adapter = adapter_for(parent_request.provider)
|
|
5867
5919
|
self._validate_mode(parent_request.mode)
|
|
5920
|
+
prompt_parts = self._validated_prompt_parts(prompt, prompt_parts)
|
|
5868
5921
|
self._validate_no_web(
|
|
5869
5922
|
parent_request.no_web, parent_request.mode, parent_request.profile
|
|
5870
5923
|
)
|
|
@@ -5952,6 +6005,9 @@ class AgentRunner:
|
|
|
5952
6005
|
allowed_urls=parent_request.allowed_urls,
|
|
5953
6006
|
writable_workspace=parent_request.writable_workspace,
|
|
5954
6007
|
thinking_display=parent_request.thinking_display,
|
|
6008
|
+
prompt_parts=prompt_parts,
|
|
6009
|
+
input_excludes=parent_request.input_excludes,
|
|
6010
|
+
skip_external_links=parent_request.skip_external_links,
|
|
5955
6011
|
)
|
|
5956
6012
|
return self._execute(
|
|
5957
6013
|
request,
|
|
@@ -6068,7 +6124,12 @@ class AgentRunner:
|
|
|
6068
6124
|
cache_dir = self.manager.cache_dir
|
|
6069
6125
|
cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
6070
6126
|
declared_inputs = _prepare_inputs(
|
|
6071
|
-
|
|
6127
|
+
plan_inputs(
|
|
6128
|
+
input_paths,
|
|
6129
|
+
excludes=request.input_excludes,
|
|
6130
|
+
skip_external_links=request.skip_external_links,
|
|
6131
|
+
protected=self._state_roots() if input_provenance is None else (),
|
|
6132
|
+
),
|
|
6072
6133
|
input_dir,
|
|
6073
6134
|
guest_paths=request.profile != "host",
|
|
6074
6135
|
provenance=input_provenance,
|
|
@@ -6308,6 +6369,9 @@ class AgentRunner:
|
|
|
6308
6369
|
allowed_urls: tuple[str, ...] = (),
|
|
6309
6370
|
writable_workspace: bool = False,
|
|
6310
6371
|
thinking_display: str | None = None,
|
|
6372
|
+
prompt_parts: tuple[PromptPart, ...] | None = None,
|
|
6373
|
+
input_excludes: tuple[str, ...] = (),
|
|
6374
|
+
skip_external_links: bool = False,
|
|
6311
6375
|
) -> RunRequest:
|
|
6312
6376
|
return RunRequest(
|
|
6313
6377
|
run_id=str(uuid.uuid4()),
|
|
@@ -6335,8 +6399,15 @@ class AgentRunner:
|
|
|
6335
6399
|
allowed_urls=allowed_urls,
|
|
6336
6400
|
writable_workspace=writable_workspace,
|
|
6337
6401
|
thinking_display=thinking_display,
|
|
6402
|
+
prompt_parts=prompt_parts,
|
|
6403
|
+
input_excludes=input_excludes,
|
|
6404
|
+
skip_external_links=skip_external_links,
|
|
6338
6405
|
)
|
|
6339
6406
|
|
|
6407
|
+
def _state_roots(self) -> tuple[Path, ...]:
|
|
6408
|
+
"""AOP's own state, which no input snapshot may copy into itself."""
|
|
6409
|
+
return (self.manager.state_dir, self.manager.sealed_runtime_dir)
|
|
6410
|
+
|
|
6340
6411
|
def _validate_provider_base_url(self, url: str | None, profile: str) -> None:
|
|
6341
6412
|
variable = PROVIDER_BASE_URL_VARIABLES.get(self.adapter.provider)
|
|
6342
6413
|
if url is None:
|
|
@@ -6356,6 +6427,27 @@ class AgentRunner:
|
|
|
6356
6427
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
6357
6428
|
raise AOPError(f"--provider-base-url must be an http(s) URL: {url!r}")
|
|
6358
6429
|
|
|
6430
|
+
def _validated_prompt_parts(
|
|
6431
|
+
self, prompt: str, parts: Sequence[PromptPart] | None
|
|
6432
|
+
) -> tuple[PromptPart, ...] | None:
|
|
6433
|
+
if parts is None:
|
|
6434
|
+
return None
|
|
6435
|
+
if self.adapter.provider != "claude":
|
|
6436
|
+
raise AOPError(f"{self.adapter.provider} does not support --prompt-parts")
|
|
6437
|
+
parts = tuple(parts)
|
|
6438
|
+
if not parts or any(part.chars <= 0 for part in parts):
|
|
6439
|
+
raise AOPError("--prompt-parts needs at least one part, none of them empty")
|
|
6440
|
+
if sum(part.chars for part in parts) != len(prompt):
|
|
6441
|
+
raise AOPError("prompt parts do not add up to the prompt")
|
|
6442
|
+
# Anthropic accepts four cache marks per request, and Claude Code
|
|
6443
|
+
# places three: two on its system prompt and one on the context it
|
|
6444
|
+
# appends after the prompt.
|
|
6445
|
+
if sum(part.cache for part in parts) > 1:
|
|
6446
|
+
raise AOPError(
|
|
6447
|
+
"Claude allows one cached prompt part; Claude Code uses the other marks"
|
|
6448
|
+
)
|
|
6449
|
+
return parts
|
|
6450
|
+
|
|
6359
6451
|
def _validated_framing(
|
|
6360
6452
|
self, system_prompt: str | None, tools: Sequence[str] | None, no_web: bool
|
|
6361
6453
|
) -> tuple[str, ...] | None:
|
|
@@ -6578,18 +6670,39 @@ def normalize_artifacts(artifacts: Sequence[str]) -> tuple[str, ...]:
|
|
|
6578
6670
|
return tuple(normalized)
|
|
6579
6671
|
|
|
6580
6672
|
|
|
6581
|
-
|
|
6673
|
+
@dataclass(frozen=True)
|
|
6674
|
+
class InputPlan:
|
|
6675
|
+
"""One declared input, checked and resolved before anything is copied."""
|
|
6676
|
+
|
|
6677
|
+
source: Path
|
|
6678
|
+
alias: str
|
|
6679
|
+
# (path relative to the input, host file to copy); empty for a file input.
|
|
6680
|
+
entries: tuple[tuple[str, Path], ...]
|
|
6681
|
+
excluded: tuple[str, ...]
|
|
6682
|
+
links: tuple[dict[str, str], ...]
|
|
6683
|
+
|
|
6684
|
+
|
|
6685
|
+
def plan_inputs(
|
|
6582
6686
|
values: Sequence[str | os.PathLike[str]],
|
|
6583
|
-
input_dir: Path,
|
|
6584
6687
|
*,
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6688
|
+
excludes: Sequence[str] = (),
|
|
6689
|
+
skip_external_links: bool = False,
|
|
6690
|
+
protected: Sequence[Path] = (),
|
|
6691
|
+
) -> tuple[InputPlan, ...]:
|
|
6692
|
+
"""Check every input as a run would, without copying.
|
|
6693
|
+
|
|
6694
|
+
Directories under `protected` (AOP's own state) are left out of any input
|
|
6695
|
+
that contains them. A symlink whose target lies inside its input is copied
|
|
6696
|
+
as the target's contents; one that leaves the input, or is dangling, fails
|
|
6697
|
+
the run unless `skip_external_links` is set.
|
|
6698
|
+
"""
|
|
6699
|
+
for pattern in excludes:
|
|
6700
|
+
if not pattern or "\0" in pattern or pattern.startswith("/"):
|
|
6701
|
+
raise AOPError(f"input exclude must be a relative glob pattern: {pattern!r}")
|
|
6702
|
+
protected = tuple(path.resolve() for path in protected)
|
|
6703
|
+
plans: list[InputPlan] = []
|
|
6591
6704
|
aliases: set[str] = set()
|
|
6592
|
-
for
|
|
6705
|
+
for value in values:
|
|
6593
6706
|
raw = os.fspath(value)
|
|
6594
6707
|
if not raw or "\0" in raw:
|
|
6595
6708
|
raise AOPError("input path must be a non-empty filesystem path")
|
|
@@ -6609,46 +6722,136 @@ def _prepare_inputs(
|
|
|
6609
6722
|
if alias in aliases:
|
|
6610
6723
|
raise AOPError(f"input paths have the same basename: {alias}")
|
|
6611
6724
|
aliases.add(alias)
|
|
6612
|
-
|
|
6613
|
-
|
|
6725
|
+
if any(source == root or source.is_relative_to(root) for root in protected):
|
|
6726
|
+
raise AOPError(f"input path is inside AOP's state directory: {source}")
|
|
6614
6727
|
if source.is_dir():
|
|
6615
|
-
|
|
6728
|
+
plans.append(
|
|
6729
|
+
_plan_directory(source, alias, excludes, skip_external_links, protected)
|
|
6730
|
+
)
|
|
6731
|
+
elif source.is_file():
|
|
6732
|
+
plans.append(InputPlan(source, alias, (), (), ()))
|
|
6616
6733
|
else:
|
|
6617
|
-
|
|
6734
|
+
raise AOPError(f"input path is not a regular file or directory: {source}")
|
|
6735
|
+
return tuple(plans)
|
|
6736
|
+
|
|
6737
|
+
|
|
6738
|
+
def _plan_directory(
|
|
6739
|
+
root: Path,
|
|
6740
|
+
alias: str,
|
|
6741
|
+
excludes: Sequence[str],
|
|
6742
|
+
skip_external_links: bool,
|
|
6743
|
+
protected: tuple[Path, ...],
|
|
6744
|
+
) -> InputPlan:
|
|
6745
|
+
entries: list[tuple[str, Path]] = []
|
|
6746
|
+
excluded: list[str] = []
|
|
6747
|
+
links: list[dict[str, str]] = []
|
|
6748
|
+
|
|
6749
|
+
def is_excluded(relative: str) -> bool:
|
|
6750
|
+
name = relative.rsplit("/", 1)[-1]
|
|
6751
|
+
return any(
|
|
6752
|
+
fnmatch.fnmatchcase(relative, pattern)
|
|
6753
|
+
or ("/" not in pattern and fnmatch.fnmatchcase(name, pattern))
|
|
6754
|
+
for pattern in excludes
|
|
6755
|
+
)
|
|
6756
|
+
|
|
6757
|
+
def is_protected(path: Path) -> bool:
|
|
6758
|
+
return any(path == item or path.is_relative_to(item) for item in protected)
|
|
6759
|
+
|
|
6760
|
+
def walk(directory: Path, relative: PurePosixPath, chain: tuple[Path, ...]) -> None:
|
|
6761
|
+
try:
|
|
6762
|
+
children = sorted(directory.iterdir(), key=lambda child: child.name)
|
|
6763
|
+
except OSError as error:
|
|
6764
|
+
raise AOPError(
|
|
6765
|
+
f"could not read input directory {directory}: {error}"
|
|
6766
|
+
) from error
|
|
6767
|
+
for child in children:
|
|
6768
|
+
logical = (relative / child.name).as_posix()
|
|
6769
|
+
if is_excluded(logical):
|
|
6770
|
+
excluded.append(logical)
|
|
6771
|
+
continue
|
|
6772
|
+
status = child.lstat()
|
|
6773
|
+
path = child
|
|
6774
|
+
if stat.S_ISLNK(status.st_mode):
|
|
6775
|
+
target = child.resolve()
|
|
6776
|
+
inside = target.is_relative_to(root) and target.exists()
|
|
6777
|
+
if not inside:
|
|
6778
|
+
if not skip_external_links:
|
|
6779
|
+
raise AOPError(
|
|
6780
|
+
f"input contains a symlink that leaves it: {child} -> "
|
|
6781
|
+
f"{os.readlink(child)}; pass --skip-external-links "
|
|
6782
|
+
"to leave such links out, or --input-exclude to "
|
|
6783
|
+
"exclude them"
|
|
6784
|
+
)
|
|
6785
|
+
links.append(
|
|
6786
|
+
{"path": logical, "target": os.readlink(child), "action": "skipped"}
|
|
6787
|
+
)
|
|
6788
|
+
continue
|
|
6789
|
+
if target in chain:
|
|
6790
|
+
raise AOPError(f"input contains a symlink loop: {child}")
|
|
6791
|
+
links.append(
|
|
6792
|
+
{"path": logical, "target": os.readlink(child), "action": "followed"}
|
|
6793
|
+
)
|
|
6794
|
+
path = target
|
|
6795
|
+
status = target.stat()
|
|
6796
|
+
if stat.S_ISDIR(status.st_mode):
|
|
6797
|
+
resolved = path.resolve()
|
|
6798
|
+
if is_protected(resolved):
|
|
6799
|
+
excluded.append(logical)
|
|
6800
|
+
elif resolved in chain:
|
|
6801
|
+
raise AOPError(f"input contains a symlink loop: {child}")
|
|
6802
|
+
else:
|
|
6803
|
+
walk(path, PurePosixPath(logical), (*chain, resolved))
|
|
6804
|
+
elif stat.S_ISREG(status.st_mode):
|
|
6805
|
+
entries.append((logical, path))
|
|
6806
|
+
else:
|
|
6807
|
+
raise AOPError(f"read path contains a special file: {child}")
|
|
6808
|
+
|
|
6809
|
+
walk(root, PurePosixPath(), (root,))
|
|
6810
|
+
return InputPlan(root, alias, tuple(entries), tuple(excluded), tuple(links))
|
|
6811
|
+
|
|
6812
|
+
|
|
6813
|
+
def _prepare_inputs(
|
|
6814
|
+
plans: Sequence[InputPlan],
|
|
6815
|
+
input_dir: Path,
|
|
6816
|
+
*,
|
|
6817
|
+
guest_paths: bool = True,
|
|
6818
|
+
provenance: Sequence[str] | None = None,
|
|
6819
|
+
) -> tuple[InputSnapshot, ...]:
|
|
6820
|
+
if provenance is not None and len(provenance) != len(plans):
|
|
6821
|
+
raise AOPError("input provenance does not match the input snapshot set")
|
|
6822
|
+
prepared: list[InputSnapshot] = []
|
|
6823
|
+
for index, plan in enumerate(plans):
|
|
6824
|
+
mounted = input_dir / plan.alias
|
|
6825
|
+
if plan.source.is_dir():
|
|
6826
|
+
mounted.mkdir()
|
|
6827
|
+
for relative, path in plan.entries:
|
|
6828
|
+
target = mounted / relative
|
|
6829
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
6830
|
+
shutil.copyfile(path, target)
|
|
6831
|
+
else:
|
|
6832
|
+
shutil.copyfile(plan.source, mounted)
|
|
6618
6833
|
kind, files = _inspect_read_path(mounted)
|
|
6619
|
-
size_bytes = sum(item.size_bytes for item in files)
|
|
6620
6834
|
prepared.append(
|
|
6621
6835
|
InputSnapshot(
|
|
6622
6836
|
source_path=(
|
|
6623
|
-
provenance[index]
|
|
6837
|
+
provenance[index]
|
|
6838
|
+
if provenance is not None
|
|
6839
|
+
else os.fspath(plan.source)
|
|
6624
6840
|
),
|
|
6625
6841
|
mounted_path=(
|
|
6626
|
-
f"/inputs/{alias}" if guest_paths else os.fspath(mounted)
|
|
6842
|
+
f"/inputs/{plan.alias}" if guest_paths else os.fspath(mounted)
|
|
6627
6843
|
),
|
|
6628
6844
|
kind=kind,
|
|
6629
|
-
size_bytes=size_bytes,
|
|
6845
|
+
size_bytes=sum(item.size_bytes for item in files),
|
|
6630
6846
|
sha256=_read_path_digest(kind, files),
|
|
6631
6847
|
files=files,
|
|
6848
|
+
excluded=plan.excluded,
|
|
6849
|
+
links=plan.links,
|
|
6632
6850
|
)
|
|
6633
6851
|
)
|
|
6634
6852
|
return tuple(prepared)
|
|
6635
6853
|
|
|
6636
6854
|
|
|
6637
|
-
def _copy_input_directory(source: Path, destination: Path) -> None:
|
|
6638
|
-
destination.mkdir()
|
|
6639
|
-
for child in sorted(source.iterdir(), key=lambda item: item.name):
|
|
6640
|
-
status = child.lstat()
|
|
6641
|
-
target = destination / child.name
|
|
6642
|
-
if stat.S_ISLNK(status.st_mode):
|
|
6643
|
-
raise AOPError(f"read path contains a symlink: {child}")
|
|
6644
|
-
if stat.S_ISDIR(status.st_mode):
|
|
6645
|
-
_copy_input_directory(child, target)
|
|
6646
|
-
elif stat.S_ISREG(status.st_mode):
|
|
6647
|
-
shutil.copyfile(child, target)
|
|
6648
|
-
else:
|
|
6649
|
-
raise AOPError(f"read path contains a special file: {child}")
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
6855
|
def _make_snapshot_private(root: Path) -> None:
|
|
6653
6856
|
for directory, names, files in os.walk(root):
|
|
6654
6857
|
path = Path(directory)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|