delegate-agent-cli 0.11.0__py3-none-any.whl
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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Dataclasses describing parsed CLI commands and built engine requests.
|
|
2
|
+
|
|
3
|
+
A dependency leaf for the CLI layer: it depends only on the command-argument
|
|
4
|
+
modules, isolation context, and prompt-transport constants, never on ``cli``,
|
|
5
|
+
so the parser, request builder, and argv builders can all share these models
|
|
6
|
+
without an import cycle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from delegate_agent import (
|
|
14
|
+
capability_commands,
|
|
15
|
+
config_commands,
|
|
16
|
+
inspection_commands,
|
|
17
|
+
profile_commands,
|
|
18
|
+
profiles,
|
|
19
|
+
run_output_commands,
|
|
20
|
+
wait_cancel_commands,
|
|
21
|
+
worktree_commands,
|
|
22
|
+
)
|
|
23
|
+
from delegate_agent.isolation import IsolationContext
|
|
24
|
+
from delegate_agent.json_types import JsonObject
|
|
25
|
+
from delegate_agent.prompt_transport import PROMPT_TRANSPORT_ARGV
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GlobalOptions:
|
|
30
|
+
json_mode: bool = False
|
|
31
|
+
cwd: str | None = None
|
|
32
|
+
pass_through: bool = False
|
|
33
|
+
completion_report: str | None = None
|
|
34
|
+
isolation: str | None = None
|
|
35
|
+
auth_profile: str | None = None
|
|
36
|
+
group: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LaunchOptions:
|
|
41
|
+
engine: str | None
|
|
42
|
+
mode: str | None
|
|
43
|
+
model_alias: str | None = None
|
|
44
|
+
prompt_parts: list[str] | None = None
|
|
45
|
+
prompt_file: str | None = None
|
|
46
|
+
output_schema: str | None = None
|
|
47
|
+
reasoning_effort: str | None = None
|
|
48
|
+
progress_intent: str | None = None
|
|
49
|
+
forbid_commit: bool = False
|
|
50
|
+
forbid_commit_implied_isolation: bool = False
|
|
51
|
+
include_dirty: bool = False
|
|
52
|
+
read_only: bool = False
|
|
53
|
+
dry_run: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class RunJsonOptions:
|
|
58
|
+
input_json: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class InspectionOptions:
|
|
63
|
+
summary: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(init=False)
|
|
67
|
+
class ParsedCommand:
|
|
68
|
+
subcommand: str
|
|
69
|
+
global_options: GlobalOptions
|
|
70
|
+
help_topic: str | None = None
|
|
71
|
+
launch: LaunchOptions | None = None
|
|
72
|
+
run_json: RunJsonOptions | None = None
|
|
73
|
+
snapshot: inspection_commands.SnapshotCommand | None = None
|
|
74
|
+
runs: inspection_commands.RunsCommand | None = None
|
|
75
|
+
run_output: run_output_commands.RunOutputCommand | None = None
|
|
76
|
+
wait_command: wait_cancel_commands.WaitCommand | None = None
|
|
77
|
+
cancel_command: wait_cancel_commands.CancelCommand | None = None
|
|
78
|
+
worktree: worktree_commands.WorktreeCommand | None = None
|
|
79
|
+
config_command: config_commands.ConfigCommand | None = None
|
|
80
|
+
capabilities: capability_commands.CapabilitiesCommand | None = None
|
|
81
|
+
profiles_command: profile_commands.ProfilesCommand | None = None
|
|
82
|
+
inspection: InspectionOptions | None = None
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
subcommand: str,
|
|
87
|
+
*,
|
|
88
|
+
help_topic: str | None = None,
|
|
89
|
+
global_options: GlobalOptions | None = None,
|
|
90
|
+
launch: LaunchOptions | None = None,
|
|
91
|
+
run_json: RunJsonOptions | None = None,
|
|
92
|
+
snapshot: inspection_commands.SnapshotCommand | None = None,
|
|
93
|
+
runs: inspection_commands.RunsCommand | None = None,
|
|
94
|
+
run_output: run_output_commands.RunOutputCommand | None = None,
|
|
95
|
+
wait_command: wait_cancel_commands.WaitCommand | None = None,
|
|
96
|
+
cancel_command: wait_cancel_commands.CancelCommand | None = None,
|
|
97
|
+
worktree: worktree_commands.WorktreeCommand | None = None,
|
|
98
|
+
config_command: config_commands.ConfigCommand | None = None,
|
|
99
|
+
capabilities: capability_commands.CapabilitiesCommand | None = None,
|
|
100
|
+
profiles_command: profile_commands.ProfilesCommand | None = None,
|
|
101
|
+
inspection: InspectionOptions | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.subcommand = subcommand
|
|
104
|
+
self.global_options = global_options or GlobalOptions()
|
|
105
|
+
self.help_topic = help_topic
|
|
106
|
+
self.launch = launch
|
|
107
|
+
self.run_json = run_json
|
|
108
|
+
self.snapshot = snapshot
|
|
109
|
+
self.runs = runs
|
|
110
|
+
self.run_output = run_output
|
|
111
|
+
self.wait_command = wait_command
|
|
112
|
+
self.cancel_command = cancel_command
|
|
113
|
+
self.worktree = worktree
|
|
114
|
+
self.config_command = config_command
|
|
115
|
+
self.capabilities = capabilities
|
|
116
|
+
self.profiles_command = profiles_command
|
|
117
|
+
self.inspection = inspection
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ResolvedWorkspace:
|
|
122
|
+
path: str
|
|
123
|
+
kind: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Request:
|
|
128
|
+
engine: str
|
|
129
|
+
mode: str
|
|
130
|
+
workspace: str
|
|
131
|
+
prompt: str
|
|
132
|
+
argv: list[str]
|
|
133
|
+
model: str | None
|
|
134
|
+
model_alias: str | None = None
|
|
135
|
+
output_schema: str | None = None
|
|
136
|
+
dry_run: bool = False
|
|
137
|
+
workspace_kind: str = "git"
|
|
138
|
+
isolation_context: IsolationContext | None = None
|
|
139
|
+
reasoning_effort: str | None = None
|
|
140
|
+
reasoning_effort_source: str | None = None
|
|
141
|
+
reasoning_capability_source: str | None = None
|
|
142
|
+
reasoning_transport: str | None = None
|
|
143
|
+
progress: bool = False
|
|
144
|
+
progress_initial_delay_sec: float = 30.0
|
|
145
|
+
progress_interval_sec: float = 60.0
|
|
146
|
+
forbid_commit: bool = False
|
|
147
|
+
warnings: tuple[str, ...] = ()
|
|
148
|
+
stdin_text: str | None = None
|
|
149
|
+
prompt_file_text: str | None = None
|
|
150
|
+
prompt_transport: str = PROMPT_TRANSPORT_ARGV
|
|
151
|
+
display_argv: list[str] | None = None
|
|
152
|
+
env_overrides: dict[str, str] | None = None
|
|
153
|
+
auth_profile: str | None = None
|
|
154
|
+
fallback_auth_profile: str | None = None
|
|
155
|
+
cleanup_workspace: bool = False
|
|
156
|
+
include_dirty: bool = False
|
|
157
|
+
group: str | None = None
|
|
158
|
+
profile_resolution: profiles.ProfileResolution = field(
|
|
159
|
+
default_factory=profiles.empty_profile_resolution
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class EngineRequestParts:
|
|
165
|
+
model: str | None
|
|
166
|
+
argv: list[str]
|
|
167
|
+
model_alias: str | None = None
|
|
168
|
+
prompt_transport: str = PROMPT_TRANSPORT_ARGV
|
|
169
|
+
display_argv: list[str] | None = None
|
|
170
|
+
warnings: tuple[str, ...] = ()
|
|
171
|
+
stdin_text: str | None = None
|
|
172
|
+
prompt_file_text: str | None = None
|
|
173
|
+
reasoning_effort: str | None = None
|
|
174
|
+
reasoning_effort_source: str | None = None
|
|
175
|
+
reasoning_capability_source: str | None = None
|
|
176
|
+
reasoning_transport: str | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class EngineBuildInput:
|
|
181
|
+
mode: str
|
|
182
|
+
model_alias: str | None
|
|
183
|
+
resolved: ResolvedWorkspace
|
|
184
|
+
prompt: str
|
|
185
|
+
config: JsonObject
|
|
186
|
+
stream_capture: bool
|
|
187
|
+
requested_effort: str | None
|
|
188
|
+
effort_source: str | None
|
|
189
|
+
cache: JsonObject | None
|
|
190
|
+
output_schema: str | None = None
|
|
191
|
+
call_read_only: bool = False
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tarfile
|
|
5
|
+
from collections import deque
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
from delegate_agent import archived_logs, log_output, run_registry
|
|
11
|
+
from delegate_agent.json_types import JsonObject, is_non_negative_int
|
|
12
|
+
from delegate_agent.log_output import LogOutput
|
|
13
|
+
|
|
14
|
+
ARCHIVE_MEMBER_NAMES = (
|
|
15
|
+
run_registry.STDOUT_LOG,
|
|
16
|
+
run_registry.STDERR_LOG,
|
|
17
|
+
run_registry.EVENTS_JSONL,
|
|
18
|
+
)
|
|
19
|
+
DEFAULT_RAW_LOG_RETENTION_DAYS = 7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def archive_dir(registry_root: Path) -> Path:
|
|
23
|
+
return archived_logs.archive_dir(registry_root)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def archive_path(registry_root: Path, run_id: str) -> Path:
|
|
27
|
+
return archived_logs.archive_path(registry_root, run_id)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def retention_settings(config: JsonObject) -> JsonObject:
|
|
31
|
+
tracking = config.get("tracking")
|
|
32
|
+
if not isinstance(tracking, dict):
|
|
33
|
+
return {}
|
|
34
|
+
retention = tracking.get("retention")
|
|
35
|
+
return retention if isinstance(retention, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def retention_enabled(config: JsonObject) -> bool:
|
|
39
|
+
enabled = retention_settings(config).get("enabled", True)
|
|
40
|
+
return enabled if isinstance(enabled, bool) else True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def raw_log_retention_days(config: JsonObject) -> int:
|
|
44
|
+
settings = retention_settings(config)
|
|
45
|
+
days = settings.get("rawLogDays", DEFAULT_RAW_LOG_RETENTION_DAYS)
|
|
46
|
+
if not is_non_negative_int(days):
|
|
47
|
+
return DEFAULT_RAW_LOG_RETENTION_DAYS
|
|
48
|
+
return days
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def should_skip_archival(registry_root: Path, run_id: str) -> bool:
|
|
52
|
+
state = run_registry.load_run_state_or_none(registry_root, run_id)
|
|
53
|
+
status = run_registry.effective_status(state)
|
|
54
|
+
return status in (
|
|
55
|
+
run_registry.STATUS_RUNNING,
|
|
56
|
+
run_registry.STATUS_STALE,
|
|
57
|
+
run_registry.STATUS_UNKNOWN,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def raw_logs_present(run_path: Path) -> bool:
|
|
62
|
+
return any((run_path / name).exists() for name in ARCHIVE_MEMBER_NAMES)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def raw_logs_archived(registry_root: Path, run_id: str) -> bool:
|
|
66
|
+
return archive_path(registry_root, run_id).exists()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_archive_member_name(member_name: str) -> None:
|
|
70
|
+
if member_name not in ARCHIVE_MEMBER_NAMES:
|
|
71
|
+
raise ValueError(f"unsupported archive member: {member_name}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _expected_member_sizes(run_path: Path, members: list[str]) -> dict[str, int]:
|
|
75
|
+
return {name: (run_path / name).stat().st_size for name in members}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _verify_archive_members(archive_file: Path, expected_sizes: dict[str, int]) -> bool:
|
|
79
|
+
if not expected_sizes:
|
|
80
|
+
return False
|
|
81
|
+
with tarfile.open(archive_file, "r:gz") as archive:
|
|
82
|
+
found: dict[str, int] = {}
|
|
83
|
+
for member in archive.getmembers():
|
|
84
|
+
if member.name in expected_sizes:
|
|
85
|
+
found[member.name] = member.size
|
|
86
|
+
return all(name in found and found[name] == size for name, size in expected_sizes.items())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tail_text_stream_output(stream: BinaryIO, lines: int) -> LogOutput:
|
|
90
|
+
if lines < 1:
|
|
91
|
+
raise ValueError("tail lines must be at least 1")
|
|
92
|
+
buffer: deque[str] = deque(maxlen=lines)
|
|
93
|
+
total_lines = 0
|
|
94
|
+
for raw_line in stream:
|
|
95
|
+
total_lines += 1
|
|
96
|
+
buffer.append(raw_line.decode("utf-8", errors="replace").rstrip("\r\n"))
|
|
97
|
+
if not buffer:
|
|
98
|
+
return LogOutput(content="", truncated=False)
|
|
99
|
+
return LogOutput(content="\n".join(buffer) + "\n", truncated=total_lines > lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def effective_log_byte_sizes(registry_root: Path, run_id: str) -> tuple[int, int]:
|
|
103
|
+
return run_registry.effective_log_byte_sizes(registry_root, run_id)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def archived_log_warning(alias: str | None, run_id: str, *, cwd: str | None = None) -> str:
|
|
107
|
+
handle = alias or run_id
|
|
108
|
+
return (
|
|
109
|
+
f"raw logs archived for {handle}; use "
|
|
110
|
+
f"{run_registry.run_output_command(handle, cwd=cwd)} --stdout|--stderr --tail N or --raw"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_eligible_for_archival(
|
|
115
|
+
registry_root: Path,
|
|
116
|
+
run_id: str,
|
|
117
|
+
*,
|
|
118
|
+
config: JsonObject,
|
|
119
|
+
now: datetime | None = None,
|
|
120
|
+
) -> bool:
|
|
121
|
+
if not retention_enabled(config):
|
|
122
|
+
return False
|
|
123
|
+
if should_skip_archival(registry_root, run_id):
|
|
124
|
+
return False
|
|
125
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
126
|
+
archive_exists = archive_path(registry_root, run_id).exists()
|
|
127
|
+
logs_present = raw_logs_present(run_path)
|
|
128
|
+
if archive_exists and not logs_present:
|
|
129
|
+
return False
|
|
130
|
+
if not archive_exists and not logs_present:
|
|
131
|
+
return False
|
|
132
|
+
moment = now or datetime.now(UTC)
|
|
133
|
+
activity = run_registry.activity_datetime(
|
|
134
|
+
run_registry.load_run_state_or_none(registry_root, run_id),
|
|
135
|
+
run_registry.load_run_manifest_or_none(registry_root, run_id),
|
|
136
|
+
run_id,
|
|
137
|
+
)
|
|
138
|
+
if activity is None:
|
|
139
|
+
return False
|
|
140
|
+
cutoff = moment - timedelta(days=raw_log_retention_days(config))
|
|
141
|
+
return activity < cutoff
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mark_raw_logs_archived(
|
|
145
|
+
run_path: Path,
|
|
146
|
+
*,
|
|
147
|
+
stdout_bytes: int = 0,
|
|
148
|
+
stderr_bytes: int = 0,
|
|
149
|
+
) -> None:
|
|
150
|
+
state_path = run_path / run_registry.STATE_FILE
|
|
151
|
+
state = run_registry.read_json_object_or_none(state_path)
|
|
152
|
+
if state is None:
|
|
153
|
+
state = {}
|
|
154
|
+
state["rawLogsArchivedAt"] = run_registry.utc_now_iso()
|
|
155
|
+
state["stdoutBytes"] = stdout_bytes
|
|
156
|
+
state["stderrBytes"] = stderr_bytes
|
|
157
|
+
run_registry.write_json_atomic(state_path, state)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _remove_archived_raw_logs(run_path: Path, members: list[str]) -> None:
|
|
161
|
+
for name in members:
|
|
162
|
+
(run_path / name).unlink()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _complete_archival_after_archive(
|
|
166
|
+
registry_root: Path,
|
|
167
|
+
run_id: str,
|
|
168
|
+
*,
|
|
169
|
+
destination: Path,
|
|
170
|
+
members: list[str],
|
|
171
|
+
) -> bool:
|
|
172
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
173
|
+
if not members:
|
|
174
|
+
return False
|
|
175
|
+
expected_sizes = _expected_member_sizes(run_path, members)
|
|
176
|
+
if not _verify_archive_members(destination, expected_sizes):
|
|
177
|
+
return False
|
|
178
|
+
_remove_archived_raw_logs(run_path, members)
|
|
179
|
+
_mark_raw_logs_archived(
|
|
180
|
+
run_path,
|
|
181
|
+
stdout_bytes=expected_sizes.get(run_registry.STDOUT_LOG, 0),
|
|
182
|
+
stderr_bytes=expected_sizes.get(run_registry.STDERR_LOG, 0),
|
|
183
|
+
)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def archive_run_raw_logs(registry_root: Path, run_id: str) -> bool:
|
|
188
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
189
|
+
members = [name for name in ARCHIVE_MEMBER_NAMES if (run_path / name).exists()]
|
|
190
|
+
if not members:
|
|
191
|
+
return False
|
|
192
|
+
destination = archive_path(registry_root, run_id)
|
|
193
|
+
if destination.exists():
|
|
194
|
+
return _complete_archival_after_archive(
|
|
195
|
+
registry_root,
|
|
196
|
+
run_id,
|
|
197
|
+
destination=destination,
|
|
198
|
+
members=members,
|
|
199
|
+
)
|
|
200
|
+
expected_sizes = _expected_member_sizes(run_path, members)
|
|
201
|
+
run_registry.ensure_private_dir(archive_dir(registry_root))
|
|
202
|
+
temp_destination = destination.with_name(f"{destination.name}.tmp")
|
|
203
|
+
if temp_destination.exists():
|
|
204
|
+
temp_destination.unlink()
|
|
205
|
+
try:
|
|
206
|
+
with tarfile.open(temp_destination, "w:gz") as archive:
|
|
207
|
+
for name in members:
|
|
208
|
+
archive.add(run_path / name, arcname=name)
|
|
209
|
+
run_registry.ensure_private_file(temp_destination)
|
|
210
|
+
if not _verify_archive_members(temp_destination, expected_sizes):
|
|
211
|
+
return False
|
|
212
|
+
os.replace(temp_destination, destination)
|
|
213
|
+
run_registry.ensure_private_file(destination)
|
|
214
|
+
finally:
|
|
215
|
+
if temp_destination.exists():
|
|
216
|
+
temp_destination.unlink()
|
|
217
|
+
return _complete_archival_after_archive(
|
|
218
|
+
registry_root,
|
|
219
|
+
run_id,
|
|
220
|
+
destination=destination,
|
|
221
|
+
members=members,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_retention_pass(
|
|
226
|
+
registry_root: Path,
|
|
227
|
+
config: JsonObject,
|
|
228
|
+
*,
|
|
229
|
+
now: datetime | None = None,
|
|
230
|
+
) -> dict[str, int]:
|
|
231
|
+
if not retention_enabled(config):
|
|
232
|
+
return {"scanned": 0, "archived": 0, "skipped": 0}
|
|
233
|
+
index = run_registry.load_index(registry_root)
|
|
234
|
+
scanned = 0
|
|
235
|
+
archived = 0
|
|
236
|
+
skipped = 0
|
|
237
|
+
with run_registry.registry_lock(registry_root):
|
|
238
|
+
for run_id in list(index.get("runs", {}).keys()):
|
|
239
|
+
if not isinstance(run_id, str):
|
|
240
|
+
continue
|
|
241
|
+
scanned += 1
|
|
242
|
+
if is_eligible_for_archival(registry_root, run_id, config=config, now=now):
|
|
243
|
+
if archive_run_raw_logs(registry_root, run_id):
|
|
244
|
+
archived += 1
|
|
245
|
+
else:
|
|
246
|
+
skipped += 1
|
|
247
|
+
else:
|
|
248
|
+
skipped += 1
|
|
249
|
+
return {"scanned": scanned, "archived": archived, "skipped": skipped}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_archived_member(archive_file: Path, member_name: str) -> str:
|
|
253
|
+
_validate_archive_member_name(member_name)
|
|
254
|
+
with tarfile.open(archive_file, "r:gz") as archive:
|
|
255
|
+
try:
|
|
256
|
+
member = archive.getmember(member_name)
|
|
257
|
+
except KeyError:
|
|
258
|
+
return ""
|
|
259
|
+
extracted = archive.extractfile(member)
|
|
260
|
+
if extracted is None:
|
|
261
|
+
return ""
|
|
262
|
+
return extracted.read().decode("utf-8", errors="replace")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def tail_archived_output(archive_file: Path, member_name: str, lines: int) -> LogOutput:
|
|
266
|
+
_validate_archive_member_name(member_name)
|
|
267
|
+
with tarfile.open(archive_file, "r:gz") as archive:
|
|
268
|
+
try:
|
|
269
|
+
extracted = archive.extractfile(member_name)
|
|
270
|
+
except KeyError:
|
|
271
|
+
return LogOutput(content="", truncated=False)
|
|
272
|
+
if extracted is None:
|
|
273
|
+
return LogOutput(content="", truncated=False)
|
|
274
|
+
return _tail_text_stream_output(extracted, lines)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def read_log_output(
|
|
278
|
+
registry_root: Path,
|
|
279
|
+
run_id: str,
|
|
280
|
+
log_name: str,
|
|
281
|
+
*,
|
|
282
|
+
tail: int | None,
|
|
283
|
+
raw: bool,
|
|
284
|
+
) -> LogOutput:
|
|
285
|
+
_validate_archive_member_name(log_name)
|
|
286
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
287
|
+
live_path = run_path / log_name
|
|
288
|
+
if live_path.exists():
|
|
289
|
+
return log_output.read_log_output(live_path, tail=tail, raw=raw)
|
|
290
|
+
archive_file = archive_path(registry_root, run_id)
|
|
291
|
+
if not archive_file.exists():
|
|
292
|
+
return LogOutput(content="", truncated=False)
|
|
293
|
+
if raw:
|
|
294
|
+
content = read_archived_member(archive_file, log_name)
|
|
295
|
+
return LogOutput(content=content, truncated=False)
|
|
296
|
+
if tail is None:
|
|
297
|
+
raise ValueError("add --tail N or --raw to read stdout/stderr log output")
|
|
298
|
+
return tail_archived_output(archive_file, log_name, tail)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def log_file_byte_size(registry_root: Path, run_id: str, log_name: str) -> int:
|
|
302
|
+
_validate_archive_member_name(log_name)
|
|
303
|
+
run_path = run_registry.run_directory(registry_root, run_id)
|
|
304
|
+
live_path = run_path / log_name
|
|
305
|
+
if live_path.exists():
|
|
306
|
+
return live_path.stat().st_size
|
|
307
|
+
state = run_registry.load_run_state(registry_root, run_id)
|
|
308
|
+
if state is not None:
|
|
309
|
+
key = "stdoutBytes" if log_name == run_registry.STDOUT_LOG else "stderrBytes"
|
|
310
|
+
value = state.get(key)
|
|
311
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
312
|
+
return value
|
|
313
|
+
archive_file = archive_path(registry_root, run_id)
|
|
314
|
+
try:
|
|
315
|
+
with tarfile.open(archive_file, "r:gz") as archive:
|
|
316
|
+
try:
|
|
317
|
+
return archive.getmember(log_name).size
|
|
318
|
+
except KeyError:
|
|
319
|
+
return 0
|
|
320
|
+
except FileNotFoundError:
|
|
321
|
+
return 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, TypeAlias
|
|
4
|
+
|
|
5
|
+
from delegate_agent.json_types import JsonObject
|
|
6
|
+
|
|
7
|
+
MetadataKeyGroup: TypeAlias = tuple[str, ...]
|
|
8
|
+
|
|
9
|
+
ISOLATION_METADATA_KEYS: MetadataKeyGroup = (
|
|
10
|
+
"isolatedWorkspace",
|
|
11
|
+
"isolationMode",
|
|
12
|
+
"effectiveIsolation",
|
|
13
|
+
"isolationLifecycle",
|
|
14
|
+
"preservedWorkspace",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PERSISTENT_WORKTREE_METADATA_KEYS: MetadataKeyGroup = (
|
|
18
|
+
"sourceGitRoot",
|
|
19
|
+
"branch",
|
|
20
|
+
"creationContext",
|
|
21
|
+
"worktreeStatus",
|
|
22
|
+
"safeWorkspaceMethod",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
REASONING_METADATA_KEYS: MetadataKeyGroup = (
|
|
26
|
+
"requestedReasoningEffort",
|
|
27
|
+
"resolvedReasoningEffort",
|
|
28
|
+
"reasoningEffortSource",
|
|
29
|
+
"reasoningCapabilitySource",
|
|
30
|
+
"reasoningTransport",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SNAPSHOT_STATE_FALLBACK_KEYS: MetadataKeyGroup = (
|
|
34
|
+
"worktreeStatus",
|
|
35
|
+
"safeWorkspaceMethod",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SNAPSHOT_MANIFEST_FALLBACK_KEYS: MetadataKeyGroup = (
|
|
39
|
+
*ISOLATION_METADATA_KEYS[1:],
|
|
40
|
+
*PERSISTENT_WORKTREE_METADATA_KEYS,
|
|
41
|
+
"worktreeCleanupCommands",
|
|
42
|
+
*REASONING_METADATA_KEYS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RunMetadataCarrier(Protocol):
|
|
47
|
+
isolated_workspace: bool
|
|
48
|
+
isolation_mode: str
|
|
49
|
+
effective_isolation: str
|
|
50
|
+
isolation_lifecycle: str
|
|
51
|
+
preserved_workspace: bool
|
|
52
|
+
source_git_root: str | None
|
|
53
|
+
branch: str | None
|
|
54
|
+
creation_context: JsonObject | None
|
|
55
|
+
worktree_status: str | None
|
|
56
|
+
safe_workspace_method: str | None
|
|
57
|
+
warnings: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def add_run_metadata_payload_fields(payload: JsonObject, carrier: RunMetadataCarrier) -> None:
|
|
61
|
+
payload["isolatedWorkspace"] = carrier.isolated_workspace
|
|
62
|
+
payload["isolationMode"] = carrier.isolation_mode
|
|
63
|
+
payload["effectiveIsolation"] = carrier.effective_isolation
|
|
64
|
+
payload["isolationLifecycle"] = carrier.isolation_lifecycle
|
|
65
|
+
payload["preservedWorkspace"] = carrier.preserved_workspace
|
|
66
|
+
|
|
67
|
+
if carrier.source_git_root is not None:
|
|
68
|
+
payload["sourceGitRoot"] = carrier.source_git_root
|
|
69
|
+
if carrier.branch is not None:
|
|
70
|
+
payload["branch"] = carrier.branch
|
|
71
|
+
if carrier.creation_context is not None:
|
|
72
|
+
payload["creationContext"] = carrier.creation_context
|
|
73
|
+
if carrier.worktree_status is not None:
|
|
74
|
+
payload["worktreeStatus"] = carrier.worktree_status
|
|
75
|
+
if carrier.safe_workspace_method is not None:
|
|
76
|
+
payload["safeWorkspaceMethod"] = carrier.safe_workspace_method
|
|
77
|
+
if carrier.warnings:
|
|
78
|
+
payload["warnings"] = list(carrier.warnings)
|