forgexa-cli 1.48.2__tar.gz → 1.48.3__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.
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/PKG-INFO +1 -1
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/daemon.py +332 -2
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/pyproject.toml +1 -1
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/README.md +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/setup.cfg +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.48.
|
|
2
|
+
__version__ = "1.48.3"
|
|
@@ -1120,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1120
1120
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1121
1121
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1122
1122
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1123
|
-
DAEMON_VERSION = "1.48.
|
|
1123
|
+
DAEMON_VERSION = "1.48.3"
|
|
1124
1124
|
|
|
1125
1125
|
|
|
1126
1126
|
def _detect_client_type() -> str:
|
|
@@ -5189,6 +5189,10 @@ class _IdleTimeoutError(asyncio.TimeoutError):
|
|
|
5189
5189
|
self.stderr = ""
|
|
5190
5190
|
|
|
5191
5191
|
|
|
5192
|
+
class _TaskAttachmentDownloadError(RuntimeError):
|
|
5193
|
+
"""Raised when a required task attachment cannot be staged safely."""
|
|
5194
|
+
|
|
5195
|
+
|
|
5192
5196
|
def _workspace_has_recent_activity(
|
|
5193
5197
|
workspace_path: "Path", since_monotonic: float, max_depth: int = 4
|
|
5194
5198
|
) -> bool:
|
|
@@ -8224,6 +8228,7 @@ class ServerConnection:
|
|
|
8224
8228
|
"platform": platform.platform(),
|
|
8225
8229
|
"python_version": platform.python_version(),
|
|
8226
8230
|
"local_workspace_bindings": {"protocol_version": 3},
|
|
8231
|
+
"task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
|
|
8227
8232
|
},
|
|
8228
8233
|
"server_url": self.server_url,
|
|
8229
8234
|
"expires_in_days": 90,
|
|
@@ -8623,7 +8628,7 @@ class ServerConnection:
|
|
|
8623
8628
|
"python_version": platform.python_version(),
|
|
8624
8629
|
"local_workspace_bindings": {"protocol_version": 3},
|
|
8625
8630
|
"local_evidence": {"protocol_version": 1, "structured_summary": True},
|
|
8626
|
-
"
|
|
8631
|
+
"task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
|
|
8627
8632
|
},
|
|
8628
8633
|
"server_url": self.server_url,
|
|
8629
8634
|
}
|
|
@@ -9257,6 +9262,10 @@ class RuntimeDaemon:
|
|
|
9257
9262
|
"""
|
|
9258
9263
|
|
|
9259
9264
|
AGENT_REFRESH_INTERVAL_SECONDS = 60
|
|
9265
|
+
_TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS = 120
|
|
9266
|
+
_TASK_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
|
|
9267
|
+
_TASK_ATTACHMENT_MAX_COUNT = 20
|
|
9268
|
+
_TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
9260
9269
|
|
|
9261
9270
|
def __init__(self):
|
|
9262
9271
|
# Compute stable hardware fingerprint from machine-id / MAC address.
|
|
@@ -9448,6 +9457,296 @@ class RuntimeDaemon:
|
|
|
9448
9457
|
|
|
9449
9458
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
9450
9459
|
|
|
9460
|
+
@staticmethod
|
|
9461
|
+
def _task_attachments_to_stage(task: TaskInfo) -> list[dict]:
|
|
9462
|
+
input_data = task.input_data if isinstance(task.input_data, dict) else {}
|
|
9463
|
+
attachment_groups = [input_data.get("attachments")]
|
|
9464
|
+
context = input_data.get("context")
|
|
9465
|
+
if isinstance(context, dict):
|
|
9466
|
+
attachment_groups.append(context.get("attachments"))
|
|
9467
|
+
fix_context = input_data.get("fix_context")
|
|
9468
|
+
if isinstance(fix_context, dict):
|
|
9469
|
+
attachment_groups.append(fix_context.get("gate_attachments"))
|
|
9470
|
+
|
|
9471
|
+
attachments: list[dict] = []
|
|
9472
|
+
attachments_by_artifact_id: dict[str, dict] = {}
|
|
9473
|
+
attachments_by_url: dict[str, dict] = {}
|
|
9474
|
+
for group in attachment_groups:
|
|
9475
|
+
if not isinstance(group, list):
|
|
9476
|
+
continue
|
|
9477
|
+
for attachment in group:
|
|
9478
|
+
if not isinstance(attachment, dict):
|
|
9479
|
+
continue
|
|
9480
|
+
artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "").strip()
|
|
9481
|
+
raw_download_url = attachment.get("download_url")
|
|
9482
|
+
download_url = raw_download_url.strip() if isinstance(raw_download_url, str) else ""
|
|
9483
|
+
if not artifact_id and not download_url:
|
|
9484
|
+
continue
|
|
9485
|
+
existing = (
|
|
9486
|
+
attachments_by_artifact_id.get(artifact_id)
|
|
9487
|
+
if artifact_id else None
|
|
9488
|
+
) or (attachments_by_url.get(download_url) if download_url else None)
|
|
9489
|
+
if existing is None:
|
|
9490
|
+
existing = dict(attachment)
|
|
9491
|
+
attachments.append(existing)
|
|
9492
|
+
else:
|
|
9493
|
+
for field, value in attachment.items():
|
|
9494
|
+
if value and not existing.get(field):
|
|
9495
|
+
existing[field] = value
|
|
9496
|
+
existing_artifact_id = str(
|
|
9497
|
+
existing.get("artifact_id") or existing.get("id") or ""
|
|
9498
|
+
).strip()
|
|
9499
|
+
if existing_artifact_id:
|
|
9500
|
+
attachments_by_artifact_id[existing_artifact_id] = existing
|
|
9501
|
+
if download_url:
|
|
9502
|
+
attachments_by_url[download_url] = existing
|
|
9503
|
+
|
|
9504
|
+
def _artifact_id_is_valid(attachment: dict) -> bool:
|
|
9505
|
+
try:
|
|
9506
|
+
UUID(str(attachment.get("artifact_id") or attachment.get("id") or ""))
|
|
9507
|
+
return True
|
|
9508
|
+
except (TypeError, ValueError, AttributeError):
|
|
9509
|
+
return False
|
|
9510
|
+
|
|
9511
|
+
def _legacy_key(attachment: dict) -> tuple[str, str, int]:
|
|
9512
|
+
try:
|
|
9513
|
+
size_bytes = int(attachment.get("size_bytes") or 0)
|
|
9514
|
+
except (TypeError, ValueError):
|
|
9515
|
+
size_bytes = 0
|
|
9516
|
+
return (
|
|
9517
|
+
str(attachment.get("file_path") or attachment.get("file_name") or ""),
|
|
9518
|
+
str(attachment.get("mime_type") or ""),
|
|
9519
|
+
size_bytes,
|
|
9520
|
+
)
|
|
9521
|
+
|
|
9522
|
+
identified_by_key: dict[tuple[str, str, int], list[dict]] = {}
|
|
9523
|
+
for attachment in attachments:
|
|
9524
|
+
if _artifact_id_is_valid(attachment):
|
|
9525
|
+
identified_by_key.setdefault(_legacy_key(attachment), []).append(attachment)
|
|
9526
|
+
|
|
9527
|
+
deduplicated: list[dict] = []
|
|
9528
|
+
for attachment in attachments:
|
|
9529
|
+
if _artifact_id_is_valid(attachment):
|
|
9530
|
+
deduplicated.append(attachment)
|
|
9531
|
+
continue
|
|
9532
|
+
candidates = identified_by_key.get(_legacy_key(attachment), [])
|
|
9533
|
+
if len(candidates) != 1:
|
|
9534
|
+
deduplicated.append(attachment)
|
|
9535
|
+
continue
|
|
9536
|
+
identified = candidates[0]
|
|
9537
|
+
for field, value in attachment.items():
|
|
9538
|
+
if value and not identified.get(field):
|
|
9539
|
+
identified[field] = value
|
|
9540
|
+
return deduplicated
|
|
9541
|
+
|
|
9542
|
+
@staticmethod
|
|
9543
|
+
def _task_attachment_filename(attachment: dict, position: int) -> str:
|
|
9544
|
+
raw_name = str(
|
|
9545
|
+
attachment.get("file_path") or attachment.get("file_name") or "attachment"
|
|
9546
|
+
)
|
|
9547
|
+
file_name = Path(raw_name.replace("\\", "/")).name
|
|
9548
|
+
file_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_name).strip("._")
|
|
9549
|
+
return f"{position:02d}-{file_name[:160] or 'attachment'}"
|
|
9550
|
+
|
|
9551
|
+
@staticmethod
|
|
9552
|
+
def _rewrite_staged_attachment_references(
|
|
9553
|
+
prompt: str,
|
|
9554
|
+
staged: list[tuple[str, str, str]],
|
|
9555
|
+
) -> str:
|
|
9556
|
+
if not staged:
|
|
9557
|
+
return prompt
|
|
9558
|
+
|
|
9559
|
+
local_files = []
|
|
9560
|
+
for file_name, download_url, local_path in staged:
|
|
9561
|
+
if download_url:
|
|
9562
|
+
command_pattern = r"`curl -L '" + re.escape(download_url) + r"' -o /tmp/[^`]+`"
|
|
9563
|
+
prompt = re.sub(command_pattern, lambda _match, path=local_path: f"`{path}`", prompt)
|
|
9564
|
+
prompt = prompt.replace(f"Download: {download_url}", f"Staged local file: `{local_path}`")
|
|
9565
|
+
prompt = prompt.replace(download_url, local_path)
|
|
9566
|
+
local_files.append(f"- `{local_path}` ({file_name})")
|
|
9567
|
+
|
|
9568
|
+
preamble = "\n".join([
|
|
9569
|
+
"## Runtime-Prepared Attachments",
|
|
9570
|
+
"The runtime downloaded these files before this task began. Use the local copies directly; do not download attachment URLs yourself.",
|
|
9571
|
+
*local_files,
|
|
9572
|
+
])
|
|
9573
|
+
return f"{preamble}\n\n{prompt}" if prompt else preamble
|
|
9574
|
+
|
|
9575
|
+
async def _download_task_attachment(
|
|
9576
|
+
self,
|
|
9577
|
+
client: httpx.AsyncClient,
|
|
9578
|
+
url: str,
|
|
9579
|
+
destination: Path,
|
|
9580
|
+
*,
|
|
9581
|
+
attachment_name: str,
|
|
9582
|
+
expected_size: int | None,
|
|
9583
|
+
expected_checksum: str | None,
|
|
9584
|
+
max_bytes: int,
|
|
9585
|
+
timeout_seconds: float,
|
|
9586
|
+
) -> None:
|
|
9587
|
+
parsed_url = urlparse(url)
|
|
9588
|
+
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
9589
|
+
raise _TaskAttachmentDownloadError(
|
|
9590
|
+
f"Attachment '{attachment_name}' has an unsupported download URL"
|
|
9591
|
+
)
|
|
9592
|
+
if expected_size is not None and expected_size < 0:
|
|
9593
|
+
raise _TaskAttachmentDownloadError(
|
|
9594
|
+
f"Attachment '{attachment_name}' has an invalid size"
|
|
9595
|
+
)
|
|
9596
|
+
if expected_size is not None and expected_size > max_bytes:
|
|
9597
|
+
raise _TaskAttachmentDownloadError(
|
|
9598
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9599
|
+
)
|
|
9600
|
+
if expected_checksum and not re.fullmatch(r"[0-9a-fA-F]{64}", expected_checksum):
|
|
9601
|
+
raise _TaskAttachmentDownloadError(
|
|
9602
|
+
f"Attachment '{attachment_name}' has an invalid integrity checksum"
|
|
9603
|
+
)
|
|
9604
|
+
|
|
9605
|
+
temporary_path = destination.with_name(f"{destination.name}.part")
|
|
9606
|
+
|
|
9607
|
+
async def download() -> None:
|
|
9608
|
+
try:
|
|
9609
|
+
async with client.stream(
|
|
9610
|
+
"GET",
|
|
9611
|
+
url,
|
|
9612
|
+
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
|
9613
|
+
) as response:
|
|
9614
|
+
if not 200 <= response.status_code < 300:
|
|
9615
|
+
raise _TaskAttachmentDownloadError(
|
|
9616
|
+
f"Attachment '{attachment_name}' download failed with HTTP {response.status_code}"
|
|
9617
|
+
)
|
|
9618
|
+
content_length = response.headers.get("content-length")
|
|
9619
|
+
if content_length:
|
|
9620
|
+
try:
|
|
9621
|
+
if int(content_length) > max_bytes:
|
|
9622
|
+
raise _TaskAttachmentDownloadError(
|
|
9623
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9624
|
+
)
|
|
9625
|
+
except ValueError:
|
|
9626
|
+
pass
|
|
9627
|
+
|
|
9628
|
+
total_bytes = 0
|
|
9629
|
+
digest = hashlib.sha256()
|
|
9630
|
+
with temporary_path.open("wb") as output:
|
|
9631
|
+
try:
|
|
9632
|
+
os.chmod(temporary_path, 0o600)
|
|
9633
|
+
except OSError:
|
|
9634
|
+
pass
|
|
9635
|
+
async for chunk in response.aiter_bytes():
|
|
9636
|
+
total_bytes += len(chunk)
|
|
9637
|
+
if total_bytes > max_bytes:
|
|
9638
|
+
raise _TaskAttachmentDownloadError(
|
|
9639
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9640
|
+
)
|
|
9641
|
+
output.write(chunk)
|
|
9642
|
+
digest.update(chunk)
|
|
9643
|
+
if expected_checksum:
|
|
9644
|
+
if not secrets.compare_digest(digest.hexdigest(), expected_checksum.lower()):
|
|
9645
|
+
raise _TaskAttachmentDownloadError(
|
|
9646
|
+
f"Attachment '{attachment_name}' failed its integrity check"
|
|
9647
|
+
)
|
|
9648
|
+
temporary_path.replace(destination)
|
|
9649
|
+
except _TaskAttachmentDownloadError:
|
|
9650
|
+
raise
|
|
9651
|
+
except httpx.HTTPError as exc:
|
|
9652
|
+
raise _TaskAttachmentDownloadError(
|
|
9653
|
+
f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
|
|
9654
|
+
) from exc
|
|
9655
|
+
except OSError as exc:
|
|
9656
|
+
raise _TaskAttachmentDownloadError(
|
|
9657
|
+
f"Attachment '{attachment_name}' could not be saved ({exc})"
|
|
9658
|
+
) from exc
|
|
9659
|
+
|
|
9660
|
+
try:
|
|
9661
|
+
await asyncio.wait_for(
|
|
9662
|
+
download(),
|
|
9663
|
+
timeout=timeout_seconds,
|
|
9664
|
+
)
|
|
9665
|
+
except asyncio.TimeoutError as exc:
|
|
9666
|
+
raise _TaskAttachmentDownloadError(
|
|
9667
|
+
f"Attachment '{attachment_name}' download timed out after "
|
|
9668
|
+
f"{timeout_seconds:.0f}s"
|
|
9669
|
+
) from exc
|
|
9670
|
+
finally:
|
|
9671
|
+
temporary_path.unlink(missing_ok=True)
|
|
9672
|
+
|
|
9673
|
+
async def _stage_task_attachments(
|
|
9674
|
+
self,
|
|
9675
|
+
task: TaskInfo,
|
|
9676
|
+
conn: ServerConnection,
|
|
9677
|
+
) -> Path | None:
|
|
9678
|
+
attachments = self._task_attachments_to_stage(task)
|
|
9679
|
+
if not attachments:
|
|
9680
|
+
return None
|
|
9681
|
+
if len(attachments) > self._TASK_ATTACHMENT_MAX_COUNT:
|
|
9682
|
+
raise _TaskAttachmentDownloadError(
|
|
9683
|
+
f"Task has {len(attachments)} attachments; the runtime limit is "
|
|
9684
|
+
f"{self._TASK_ATTACHMENT_MAX_COUNT}"
|
|
9685
|
+
)
|
|
9686
|
+
|
|
9687
|
+
staging_dir = Path(tempfile.mkdtemp(prefix=f"forgexa-attachments-{task.task_id[:8]}-"))
|
|
9688
|
+
staged: list[tuple[str, str, str]] = []
|
|
9689
|
+
deadline = time.monotonic() + self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS
|
|
9690
|
+
remaining_bytes = self._TASK_ATTACHMENT_MAX_TOTAL_BYTES
|
|
9691
|
+
try:
|
|
9692
|
+
for position, attachment in enumerate(attachments, start=1):
|
|
9693
|
+
file_name = str(
|
|
9694
|
+
attachment.get("file_path") or attachment.get("file_name") or "attachment"
|
|
9695
|
+
)
|
|
9696
|
+
download_url = str(attachment.get("download_url") or "")
|
|
9697
|
+
destination = staging_dir / self._task_attachment_filename(attachment, position)
|
|
9698
|
+
try:
|
|
9699
|
+
expected_size = int(attachment.get("size_bytes") or 0)
|
|
9700
|
+
except (TypeError, ValueError):
|
|
9701
|
+
expected_size = 0
|
|
9702
|
+
if expected_size > remaining_bytes:
|
|
9703
|
+
raise _TaskAttachmentDownloadError(
|
|
9704
|
+
f"Attachment '{file_name}' exceeds the task download budget"
|
|
9705
|
+
)
|
|
9706
|
+
artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "")
|
|
9707
|
+
try:
|
|
9708
|
+
UUID(artifact_id)
|
|
9709
|
+
except (TypeError, ValueError, AttributeError):
|
|
9710
|
+
raise _TaskAttachmentDownloadError(
|
|
9711
|
+
f"Attachment '{file_name}' cannot be staged securely because it has no artifact ID"
|
|
9712
|
+
)
|
|
9713
|
+
remaining_seconds = deadline - time.monotonic()
|
|
9714
|
+
if remaining_seconds <= 0:
|
|
9715
|
+
raise _TaskAttachmentDownloadError(
|
|
9716
|
+
f"Task attachment staging timed out after "
|
|
9717
|
+
f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
|
|
9718
|
+
)
|
|
9719
|
+
if not conn.runtime_id:
|
|
9720
|
+
raise _TaskAttachmentDownloadError(
|
|
9721
|
+
f"Attachment '{file_name}' cannot be staged because this runtime is not registered"
|
|
9722
|
+
)
|
|
9723
|
+
source_url = (
|
|
9724
|
+
f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/"
|
|
9725
|
+
f"tasks/{task.task_id}/attachments/{artifact_id}"
|
|
9726
|
+
)
|
|
9727
|
+
await self._download_task_attachment(
|
|
9728
|
+
conn.client,
|
|
9729
|
+
source_url,
|
|
9730
|
+
destination,
|
|
9731
|
+
attachment_name=file_name,
|
|
9732
|
+
expected_size=expected_size or None,
|
|
9733
|
+
expected_checksum=str(attachment.get("checksum") or ""),
|
|
9734
|
+
max_bytes=min(self._TASK_ATTACHMENT_MAX_BYTES, remaining_bytes),
|
|
9735
|
+
timeout_seconds=remaining_seconds,
|
|
9736
|
+
)
|
|
9737
|
+
remaining_bytes -= destination.stat().st_size
|
|
9738
|
+
staged.append((file_name, download_url, str(destination)))
|
|
9739
|
+
except Exception:
|
|
9740
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
9741
|
+
raise
|
|
9742
|
+
|
|
9743
|
+
task.input_prompt = self._rewrite_staged_attachment_references(task.input_prompt, staged)
|
|
9744
|
+
input_data = dict(task.input_data or {})
|
|
9745
|
+
input_data["prompt"] = task.input_prompt
|
|
9746
|
+
task.input_data = input_data
|
|
9747
|
+
logger.info("Prepared %d attachment(s) for task %s", len(staged), task.task_id)
|
|
9748
|
+
return staging_dir
|
|
9749
|
+
|
|
9451
9750
|
@staticmethod
|
|
9452
9751
|
async def _mint_local_dev_token() -> str:
|
|
9453
9752
|
"""Mint a JWT for local development when no explicit token is configured.
|
|
@@ -10292,6 +10591,7 @@ class RuntimeDaemon:
|
|
|
10292
10591
|
await project_lock.acquire()
|
|
10293
10592
|
workspace_execution_lock: _WorkspaceFileLock | None = None
|
|
10294
10593
|
local_bind_file_lock = None
|
|
10594
|
+
attachment_staging_dir: Path | None = None
|
|
10295
10595
|
if is_local_bind and task.local_workspace_binding_id:
|
|
10296
10596
|
# OS-level file lock keyed by binding_id (docs/designs/forgexa-
|
|
10297
10597
|
# ide-design.md §6.3): the DB lease (orchestrator.py) prevents
|
|
@@ -10387,6 +10687,34 @@ class RuntimeDaemon:
|
|
|
10387
10687
|
logger.info("Workspace ready: %s", workspace_path)
|
|
10388
10688
|
self._ensure_task_output_dir(workspace_path, task)
|
|
10389
10689
|
|
|
10690
|
+
try:
|
|
10691
|
+
if self._task_attachments_to_stage(task):
|
|
10692
|
+
await reporter.report_progress(
|
|
10693
|
+
task.task_id,
|
|
10694
|
+
8,
|
|
10695
|
+
"preparing_attachments",
|
|
10696
|
+
output_lines=["[daemon] Preparing task attachments"],
|
|
10697
|
+
)
|
|
10698
|
+
attachment_staging_dir = await self._stage_task_attachments(task, conn)
|
|
10699
|
+
except _TaskAttachmentDownloadError as exc:
|
|
10700
|
+
logger.warning("Task %s could not stage attachments: %s", task.task_id, exc)
|
|
10701
|
+
await reporter.report_progress(
|
|
10702
|
+
task.task_id,
|
|
10703
|
+
8,
|
|
10704
|
+
"attachment_download_failed",
|
|
10705
|
+
output_lines=["[daemon] Required task attachment could not be downloaded"],
|
|
10706
|
+
)
|
|
10707
|
+
await report_task_complete(TaskResult(
|
|
10708
|
+
status="failed",
|
|
10709
|
+
exit_code=-1,
|
|
10710
|
+
stdout="",
|
|
10711
|
+
stderr="",
|
|
10712
|
+
error=str(exc),
|
|
10713
|
+
failure_code="attachment_download_failed",
|
|
10714
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
10715
|
+
))
|
|
10716
|
+
return
|
|
10717
|
+
|
|
10390
10718
|
# 2.1 Workspace health check: detect broken checkout — skipped for
|
|
10391
10719
|
# local_bind (the user's own real directory, not a disposable
|
|
10392
10720
|
# checkout the daemon controls). See
|
|
@@ -11131,6 +11459,8 @@ class RuntimeDaemon:
|
|
|
11131
11459
|
execution_attempt_id=task.execution_attempt_id,
|
|
11132
11460
|
))
|
|
11133
11461
|
finally:
|
|
11462
|
+
if attachment_staging_dir is not None:
|
|
11463
|
+
shutil.rmtree(attachment_staging_dir, ignore_errors=True)
|
|
11134
11464
|
if workspace_execution_lock is not None:
|
|
11135
11465
|
workspace_execution_lock.release()
|
|
11136
11466
|
project_lock.release()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|