@invokehq/cli 0.2.7 → 0.2.9
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.
- package/README.md +1 -1
- package/agentify.py +11 -4
- package/invoke/deploy.py +14 -0
- package/invoke/sandbox.py +361 -79
- package/package.json +1 -1
package/README.md
CHANGED
package/agentify.py
CHANGED
|
@@ -1236,12 +1236,18 @@ def read_project_config(root: Path) -> dict[str, Any]:
|
|
|
1236
1236
|
return config
|
|
1237
1237
|
|
|
1238
1238
|
|
|
1239
|
-
def is_agent_project(config: dict[str, Any]) -> bool:
|
|
1239
|
+
def is_agent_project(root: Path, config: dict[str, Any]) -> bool:
|
|
1240
1240
|
runtime = str(config.get("runtime") or "").lower()
|
|
1241
1241
|
agent_type = str(config.get("agent_type") or config.get("type") or "").lower()
|
|
1242
|
-
|
|
1242
|
+
has_entrypoint = bool(config.get("entrypoint")) or any(
|
|
1243
|
+
(root / candidate).exists() for candidate in ("src/index.ts", "src/index.js", "index.ts", "index.js")
|
|
1244
|
+
)
|
|
1245
|
+
tools = config.get("tools")
|
|
1246
|
+
empty_tools = tools is None or tools == []
|
|
1247
|
+
return has_entrypoint and (
|
|
1243
1248
|
runtime in {"node", "python", "claude-agent-sdk"}
|
|
1244
1249
|
or agent_type in {"agent", "claude-agent", "claude-agent-sdk"}
|
|
1250
|
+
or empty_tools
|
|
1245
1251
|
)
|
|
1246
1252
|
|
|
1247
1253
|
|
|
@@ -1403,7 +1409,7 @@ def save_deployment(record: dict[str, Any]) -> None:
|
|
|
1403
1409
|
def deploy_command(args: argparse.Namespace) -> int:
|
|
1404
1410
|
root = Path(args.path)
|
|
1405
1411
|
raw_config = read_project_config(root)
|
|
1406
|
-
if is_agent_project(raw_config):
|
|
1412
|
+
if is_agent_project(root, raw_config):
|
|
1407
1413
|
from dataclasses import asdict
|
|
1408
1414
|
|
|
1409
1415
|
from invoke.deploy import deploy_claude_agent
|
|
@@ -1418,7 +1424,8 @@ def deploy_command(args: argparse.Namespace) -> int:
|
|
|
1418
1424
|
"name": result.plan.app_name,
|
|
1419
1425
|
"provider_id": result.deployment_id,
|
|
1420
1426
|
"slug": result.plan.app_name,
|
|
1421
|
-
"gateway_url": result.
|
|
1427
|
+
"gateway_url": result.endpoint_url,
|
|
1428
|
+
"dashboard_url": result.dashboard_url,
|
|
1422
1429
|
"base_url": "modal",
|
|
1423
1430
|
"tools": ["agent.run"],
|
|
1424
1431
|
"deployed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
package/invoke/deploy.py
CHANGED
|
@@ -34,6 +34,7 @@ class DeployPlan:
|
|
|
34
34
|
modal_volume: str
|
|
35
35
|
modal_image: str = DEFAULT_MODAL_IMAGE
|
|
36
36
|
env: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
endpoint_name: str = "invoke"
|
|
37
38
|
tracing_enabled: bool = True
|
|
38
39
|
persistence: dict[str, str] = field(default_factory=dict)
|
|
39
40
|
robustness: dict[str, bool] = field(default_factory=dict)
|
|
@@ -49,7 +50,10 @@ class DeployResult:
|
|
|
49
50
|
status: str
|
|
50
51
|
message: str
|
|
51
52
|
modal_app_name: str | None = None
|
|
53
|
+
endpoint_url: str | None = None
|
|
54
|
+
dashboard_url: str | None = None
|
|
52
55
|
trace_path: str | None = None
|
|
56
|
+
modal_source_path: str | None = None
|
|
53
57
|
onyx_suggestions: list[OnyxSuggestion] = field(default_factory=list)
|
|
54
58
|
|
|
55
59
|
|
|
@@ -175,6 +179,11 @@ def deploy_claude_agent(
|
|
|
175
179
|
|
|
176
180
|
suggestions = analyze_traces(trace_store.recent(limit=100)) if run_onyx else []
|
|
177
181
|
|
|
182
|
+
from .sandbox import write_modal_source
|
|
183
|
+
|
|
184
|
+
modal_source_path = write_modal_source(plan)
|
|
185
|
+
dashboard_url = f"https://modal.com/apps/{plan.app_name}"
|
|
186
|
+
|
|
178
187
|
if dry_run:
|
|
179
188
|
result = DeployResult(
|
|
180
189
|
success=True,
|
|
@@ -182,7 +191,9 @@ def deploy_claude_agent(
|
|
|
182
191
|
deployment_id=deployment_id,
|
|
183
192
|
status="planned",
|
|
184
193
|
message="Deployment plan generated. Modal deploy was not executed.",
|
|
194
|
+
dashboard_url=dashboard_url,
|
|
185
195
|
trace_path=str(trace_store.path),
|
|
196
|
+
modal_source_path=str(modal_source_path),
|
|
186
197
|
onyx_suggestions=suggestions,
|
|
187
198
|
)
|
|
188
199
|
write_local_deploy_record(result)
|
|
@@ -198,7 +209,10 @@ def deploy_claude_agent(
|
|
|
198
209
|
status="deployed",
|
|
199
210
|
message="Agent deployed to Modal with Invoke tracing and persistence enabled.",
|
|
200
211
|
modal_app_name=modal_result.get("app_name", plan.app_name),
|
|
212
|
+
endpoint_url=modal_result.get("endpoint_url"),
|
|
213
|
+
dashboard_url=modal_result.get("dashboard_url", dashboard_url),
|
|
201
214
|
trace_path=str(trace_store.path),
|
|
215
|
+
modal_source_path=str(modal_source_path),
|
|
202
216
|
onyx_suggestions=suggestions,
|
|
203
217
|
)
|
|
204
218
|
write_local_deploy_record(result)
|
package/invoke/sandbox.py
CHANGED
|
@@ -1,106 +1,388 @@
|
|
|
1
|
-
"""Modal sandbox
|
|
1
|
+
"""Modal sandbox generation for hosted Invoke agents."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import textwrap
|
|
6
10
|
from pathlib import Path
|
|
7
11
|
from typing import Any
|
|
8
12
|
|
|
9
13
|
from .deploy import DeployPlan
|
|
10
14
|
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
try:
|
|
14
|
-
import modal # type: ignore
|
|
15
|
-
except ImportError as exc:
|
|
16
|
-
raise RuntimeError(
|
|
17
|
-
"Modal is required for hosted agent deployment. Install it with `pip install modal` "
|
|
18
|
-
"and run `modal setup`, or use `--dry-run` to inspect the deployment plan."
|
|
19
|
-
) from exc
|
|
20
|
-
return modal
|
|
16
|
+
ENDPOINT_RE = re.compile(r"https://[^\s\"']+modal\.run[^\s\"']*")
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.apt_install("nodejs", "npm")
|
|
31
|
-
.pip_install("anthropic", "httpx")
|
|
32
|
-
.env(plan.env)
|
|
19
|
+
def _require_modal_cli() -> None:
|
|
20
|
+
if shutil.which("modal"):
|
|
21
|
+
return
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"Modal CLI is required for `invoke deploy` agent deployments. "
|
|
24
|
+
"Install and authenticate it with `pip install modal` and `modal setup`, "
|
|
25
|
+
"or run `invoke deploy --dry-run` to inspect the generated deployment first."
|
|
33
26
|
)
|
|
34
|
-
volume = modal.Volume.from_name(plan.modal_volume, create_if_missing=True)
|
|
35
|
-
return modal, app, image, volume
|
|
36
27
|
|
|
37
28
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
volumes={"/state": volume},
|
|
53
|
-
timeout=60 * 20,
|
|
54
|
-
secrets=[],
|
|
29
|
+
def _remote_entrypoint_command(entrypoint: str) -> str:
|
|
30
|
+
quoted = json.dumps(entrypoint)
|
|
31
|
+
return textwrap.dedent(
|
|
32
|
+
f"""\
|
|
33
|
+
def agent_command(prompt):
|
|
34
|
+
entrypoint = {quoted}
|
|
35
|
+
source = Path("/workspace") / entrypoint
|
|
36
|
+
if entrypoint.endswith(".ts"):
|
|
37
|
+
compiled = Path("/workspace/dist") / Path(entrypoint).with_suffix(".js").name
|
|
38
|
+
if compiled.exists():
|
|
39
|
+
return ["node", str(compiled), prompt]
|
|
40
|
+
return ["npx", "tsx", str(source), prompt]
|
|
41
|
+
return ["node", str(source), prompt]
|
|
42
|
+
"""
|
|
55
43
|
)
|
|
56
|
-
|
|
57
|
-
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def modal_source(plan: DeployPlan) -> str:
|
|
47
|
+
"""Build the deployable Modal source file for one agent project."""
|
|
48
|
+
|
|
49
|
+
app_name = json.dumps(plan.app_name)
|
|
50
|
+
volume_name = json.dumps(plan.modal_volume)
|
|
51
|
+
project_root = json.dumps(plan.project_root)
|
|
52
|
+
env = json.dumps(plan.env, sort_keys=True)
|
|
53
|
+
endpoint_name = json.dumps(plan.endpoint_name)
|
|
54
|
+
agent_command_source = _remote_entrypoint_command(plan.entrypoint).rstrip()
|
|
55
|
+
|
|
56
|
+
template = textwrap.dedent(
|
|
57
|
+
f"""\
|
|
58
|
+
# Generated by Invoke. Do not edit by hand; rerun `invoke deploy`.
|
|
59
|
+
from __future__ import annotations
|
|
58
60
|
|
|
59
61
|
import json
|
|
62
|
+
import os
|
|
60
63
|
import subprocess
|
|
61
64
|
import time
|
|
62
65
|
import uuid
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Any
|
|
68
|
+
|
|
69
|
+
import modal
|
|
70
|
+
from fastapi import Request
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
|
|
73
|
+
APP_NAME = {app_name}
|
|
74
|
+
VOLUME_NAME = {volume_name}
|
|
75
|
+
PROJECT_ROOT = {project_root}
|
|
76
|
+
INVOKE_ENV = {env}
|
|
77
|
+
|
|
78
|
+
app = modal.App(APP_NAME)
|
|
79
|
+
volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True)
|
|
80
|
+
|
|
81
|
+
image = (
|
|
82
|
+
modal.Image.debian_slim(python_version="3.11")
|
|
83
|
+
.apt_install("nodejs", "npm")
|
|
84
|
+
.pip_install("fastapi[standard]")
|
|
85
|
+
.add_local_dir(
|
|
86
|
+
PROJECT_ROOT,
|
|
87
|
+
remote_path="/workspace",
|
|
88
|
+
copy=True,
|
|
89
|
+
ignore=[
|
|
90
|
+
".git",
|
|
91
|
+
".invoke",
|
|
92
|
+
"node_modules",
|
|
93
|
+
"dist",
|
|
94
|
+
".next",
|
|
95
|
+
"__pycache__",
|
|
96
|
+
"*.tgz",
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
.run_commands(
|
|
100
|
+
"cd /workspace && if [ -f package-lock.json ]; then npm ci; elif [ -f package.json ]; then npm install; fi",
|
|
101
|
+
"cd /workspace && if [ -f package.json ]; then npm run build --if-present; fi",
|
|
102
|
+
)
|
|
103
|
+
.env(INVOKE_ENV)
|
|
85
104
|
)
|
|
86
|
-
final = {
|
|
87
|
-
"execution_id": execution_id,
|
|
88
|
-
"event": "agent_run_completed",
|
|
89
|
-
"returncode": completed.returncode,
|
|
90
|
-
"stdout": completed.stdout[-8000:],
|
|
91
|
-
"stderr": completed.stderr[-8000:],
|
|
92
|
-
"latency_ms": round((time.time() - started_at) * 1000, 2),
|
|
93
|
-
}
|
|
94
|
-
with open("/state/traces.jsonl", "a", encoding="utf-8") as fh:
|
|
95
|
-
fh.write(json.dumps(final, sort_keys=True) + "\n")
|
|
96
|
-
return final
|
|
97
105
|
|
|
106
|
+
|
|
107
|
+
def utc_ms():
|
|
108
|
+
return int(time.time() * 1000)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def append_jsonl(path, payload):
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
114
|
+
fh.write(json.dumps(payload, sort_keys=True) + "\\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_jsonl(path):
|
|
118
|
+
if not path.exists():
|
|
119
|
+
return []
|
|
120
|
+
rows = []
|
|
121
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
122
|
+
try:
|
|
123
|
+
rows.append(json.loads(line))
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
continue
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def evaluate_policy(action, params):
|
|
130
|
+
lowered = str(action).lower()
|
|
131
|
+
sql = str(params.get("sql", "")).lower() if isinstance(params, dict) else ""
|
|
132
|
+
if action == "database.execute" or "disable row level security" in sql or "drop table" in sql:
|
|
133
|
+
return {{
|
|
134
|
+
"effect": "block",
|
|
135
|
+
"risk": "high",
|
|
136
|
+
"reason": "Direct database execution can bypass application policy or row-level security.",
|
|
137
|
+
"guardrails": ["policy_block", "sandbox_required"],
|
|
138
|
+
}}
|
|
139
|
+
if any(word in lowered for word in ("delete", "refund", "charge", "transfer")):
|
|
140
|
+
return {{
|
|
141
|
+
"effect": "require_approval",
|
|
142
|
+
"risk": "high",
|
|
143
|
+
"reason": "Financial or destructive action requires approval and reconciliation.",
|
|
144
|
+
"guardrails": ["approval", "idempotency_key", "state_reconciliation"],
|
|
145
|
+
}}
|
|
146
|
+
return {{
|
|
147
|
+
"effect": "allow",
|
|
148
|
+
"risk": "low",
|
|
149
|
+
"reason": "No blocking policy matched.",
|
|
150
|
+
"guardrails": ["trace"],
|
|
151
|
+
}}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reconcile_state(expected, live):
|
|
155
|
+
drift = []
|
|
156
|
+
if isinstance(expected, dict) and isinstance(live, dict):
|
|
157
|
+
for key, expected_value in expected.items():
|
|
158
|
+
if live.get(key) != expected_value:
|
|
159
|
+
drift.append({{"key": key, "expected": expected_value, "live": live.get(key)}})
|
|
160
|
+
return {{"status": "changed" if drift else "valid", "drift": drift, "checked_at_ms": utc_ms()}}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_action(payload):
|
|
164
|
+
raw = payload.get("action") or {{}}
|
|
165
|
+
if isinstance(raw, str):
|
|
166
|
+
return raw, payload.get("params") or {{}}
|
|
167
|
+
if isinstance(raw, dict):
|
|
168
|
+
return raw.get("action") or raw.get("name") or "agent.run", raw.get("params") or {{}}
|
|
169
|
+
return "agent.run", {{}}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def checkpoint_path():
|
|
173
|
+
return Path("/state/checkpoints.jsonl")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def trace_path():
|
|
177
|
+
return Path("/state/traces.jsonl")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def freeze_execution(execution_id, action, params, context):
|
|
181
|
+
checkpoint = {{
|
|
182
|
+
"checkpoint_id": "freeze_" + uuid.uuid4().hex[:12],
|
|
183
|
+
"execution_id": execution_id,
|
|
184
|
+
"action": action,
|
|
185
|
+
"params": params,
|
|
186
|
+
"context_snapshot": context,
|
|
187
|
+
"created_at_ms": utc_ms(),
|
|
188
|
+
}}
|
|
189
|
+
append_jsonl(checkpoint_path(), checkpoint)
|
|
190
|
+
return checkpoint
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def find_checkpoint(checkpoint_id):
|
|
194
|
+
for checkpoint in reversed(read_jsonl(checkpoint_path())):
|
|
195
|
+
if checkpoint.get("checkpoint_id") == checkpoint_id:
|
|
196
|
+
return checkpoint
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__AGENT_COMMAND_SOURCE__
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.function(image=image, volumes={{"/state": volume}}, timeout=60 * 20)
|
|
204
|
+
@modal.fastapi_endpoint(method="POST", label={endpoint_name})
|
|
205
|
+
async def invoke(request: Request):
|
|
206
|
+
started = utc_ms()
|
|
207
|
+
payload = await request.json()
|
|
208
|
+
execution_id = payload.get("execution_id") or "exec_" + uuid.uuid4().hex[:12]
|
|
209
|
+
prompt = payload.get("prompt") or payload.get("input") or ""
|
|
210
|
+
agent_id = payload.get("agent_id") or APP_NAME
|
|
211
|
+
action, params = extract_action(payload)
|
|
212
|
+
events = []
|
|
213
|
+
|
|
214
|
+
def event(step, status, detail=None):
|
|
215
|
+
item = {{"step": step, "status": status, "timestamp_ms": utc_ms(), "detail": detail or {{}}}}
|
|
216
|
+
events.append(item)
|
|
217
|
+
return item
|
|
218
|
+
|
|
219
|
+
event("request_received", "ok", {{"agent_id": agent_id, "action": action}})
|
|
220
|
+
|
|
221
|
+
if not isinstance(params, dict):
|
|
222
|
+
event("schema_checked", "failed", {{"reason": "params must be an object"}})
|
|
223
|
+
trace = {{
|
|
224
|
+
"execution_id": execution_id,
|
|
225
|
+
"agent_id": agent_id,
|
|
226
|
+
"action": action,
|
|
227
|
+
"status": "blocked",
|
|
228
|
+
"risk": "medium",
|
|
229
|
+
"final_outcome": "blocked_by_schema",
|
|
230
|
+
"events": events,
|
|
231
|
+
"latency_ms": utc_ms() - started,
|
|
232
|
+
}}
|
|
233
|
+
append_jsonl(trace_path(), trace)
|
|
234
|
+
return {{"success": False, "execution": trace, "error": "params must be an object"}}
|
|
235
|
+
event("schema_checked", "ok")
|
|
236
|
+
|
|
237
|
+
if payload.get("resume_checkpoint_id"):
|
|
238
|
+
checkpoint = find_checkpoint(payload["resume_checkpoint_id"])
|
|
239
|
+
if not checkpoint:
|
|
240
|
+
event("checkpoint_loaded", "missing", {{"checkpoint_id": payload["resume_checkpoint_id"]}})
|
|
241
|
+
return {{"success": False, "execution_id": execution_id, "error": "checkpoint not found", "events": events}}
|
|
242
|
+
event("checkpoint_loaded", "ok", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
|
|
243
|
+
revalidation = reconcile_state(checkpoint.get("context_snapshot") or {{}}, payload.get("live_context") or {{}})
|
|
244
|
+
event("state_revalidated", revalidation["status"], revalidation)
|
|
245
|
+
if revalidation["status"] == "changed":
|
|
246
|
+
trace = {{
|
|
247
|
+
"execution_id": execution_id,
|
|
248
|
+
"agent_id": agent_id,
|
|
249
|
+
"action": action,
|
|
250
|
+
"status": "requeued",
|
|
251
|
+
"risk": "high",
|
|
252
|
+
"final_outcome": "requeued_due_to_state_drift",
|
|
253
|
+
"events": events,
|
|
254
|
+
"latency_ms": utc_ms() - started,
|
|
255
|
+
}}
|
|
256
|
+
append_jsonl(trace_path(), trace)
|
|
257
|
+
return {{"success": True, "status": "requeued", "execution": trace}}
|
|
258
|
+
|
|
259
|
+
policy = evaluate_policy(action, params)
|
|
260
|
+
event("policy_evaluated", "ok", policy)
|
|
261
|
+
if policy["effect"] == "block":
|
|
262
|
+
trace = {{
|
|
263
|
+
"execution_id": execution_id,
|
|
264
|
+
"agent_id": agent_id,
|
|
265
|
+
"action": action,
|
|
266
|
+
"status": "blocked",
|
|
267
|
+
"risk": policy["risk"],
|
|
268
|
+
"policy": policy,
|
|
269
|
+
"final_outcome": "blocked_by_policy",
|
|
270
|
+
"events": events,
|
|
271
|
+
"latency_ms": utc_ms() - started,
|
|
272
|
+
}}
|
|
273
|
+
append_jsonl(trace_path(), trace)
|
|
274
|
+
return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
|
|
275
|
+
|
|
276
|
+
if policy["effect"] == "require_approval" and not payload.get("approved"):
|
|
277
|
+
checkpoint = freeze_execution(
|
|
278
|
+
execution_id,
|
|
279
|
+
action,
|
|
280
|
+
params,
|
|
281
|
+
payload.get("context_snapshot") or payload.get("expected_state") or {{}},
|
|
282
|
+
)
|
|
283
|
+
event("execution_frozen", "pending_approval", {{"checkpoint_id": checkpoint["checkpoint_id"]}})
|
|
284
|
+
trace = {{
|
|
285
|
+
"execution_id": execution_id,
|
|
286
|
+
"agent_id": agent_id,
|
|
287
|
+
"action": action,
|
|
288
|
+
"status": "pending_approval",
|
|
289
|
+
"risk": policy["risk"],
|
|
290
|
+
"policy": policy,
|
|
291
|
+
"checkpoint": checkpoint,
|
|
292
|
+
"final_outcome": "frozen_for_human_review",
|
|
293
|
+
"events": events,
|
|
294
|
+
"latency_ms": utc_ms() - started,
|
|
295
|
+
}}
|
|
296
|
+
append_jsonl(trace_path(), trace)
|
|
297
|
+
return {{"success": True, "status": "pending_approval", "execution": trace, "certificate_returned": True}}
|
|
298
|
+
|
|
299
|
+
if payload.get("expected_state") is not None or payload.get("live_state") is not None:
|
|
300
|
+
reconciliation = reconcile_state(payload.get("expected_state") or {{}}, payload.get("live_state") or {{}})
|
|
301
|
+
event("state_reconciled", reconciliation["status"], reconciliation)
|
|
302
|
+
if reconciliation["status"] == "changed":
|
|
303
|
+
trace = {{
|
|
304
|
+
"execution_id": execution_id,
|
|
305
|
+
"agent_id": agent_id,
|
|
306
|
+
"action": action,
|
|
307
|
+
"status": "blocked",
|
|
308
|
+
"risk": "high",
|
|
309
|
+
"policy": policy,
|
|
310
|
+
"reconciliation": reconciliation,
|
|
311
|
+
"final_outcome": "blocked_due_to_state_drift",
|
|
312
|
+
"events": events,
|
|
313
|
+
"latency_ms": utc_ms() - started,
|
|
314
|
+
}}
|
|
315
|
+
append_jsonl(trace_path(), trace)
|
|
316
|
+
return {{"success": True, "status": "blocked", "execution": trace, "certificate_returned": True}}
|
|
317
|
+
|
|
318
|
+
event("agent_execution_started", "ok")
|
|
319
|
+
completed = subprocess.run(
|
|
320
|
+
agent_command(str(prompt)),
|
|
321
|
+
cwd="/workspace",
|
|
322
|
+
text=True,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
timeout=int(payload.get("timeout_seconds") or 60 * 15),
|
|
325
|
+
check=False,
|
|
326
|
+
)
|
|
327
|
+
status = "succeeded" if completed.returncode == 0 else "failed"
|
|
328
|
+
event("agent_execution_completed", status, {{"returncode": completed.returncode}})
|
|
329
|
+
trace = {{
|
|
330
|
+
"execution_id": execution_id,
|
|
331
|
+
"agent_id": agent_id,
|
|
332
|
+
"action": action,
|
|
333
|
+
"status": status,
|
|
334
|
+
"risk": policy["risk"],
|
|
335
|
+
"policy": policy,
|
|
336
|
+
"final_outcome": status,
|
|
337
|
+
"events": events,
|
|
338
|
+
"latency_ms": utc_ms() - started,
|
|
339
|
+
"stdout": completed.stdout[-8000:],
|
|
340
|
+
"stderr": completed.stderr[-8000:],
|
|
341
|
+
"certificate_returned": True,
|
|
342
|
+
}}
|
|
343
|
+
append_jsonl(trace_path(), trace)
|
|
344
|
+
return {{"success": completed.returncode == 0, "execution": trace, "certificate_returned": True}}
|
|
345
|
+
"""
|
|
346
|
+
)
|
|
347
|
+
return template.replace("__AGENT_COMMAND_SOURCE__", agent_command_source)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def write_modal_source(plan: DeployPlan) -> Path:
|
|
351
|
+
state_dir = Path(plan.project_root) / ".invoke"
|
|
352
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
353
|
+
path = state_dir / "modal_app.py"
|
|
354
|
+
path.write_text(modal_source(plan), encoding="utf-8")
|
|
355
|
+
return path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parse_endpoint(output: str) -> str | None:
|
|
359
|
+
match = ENDPOINT_RE.search(output)
|
|
360
|
+
return match.group(0).rstrip(".,)")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def deploy_modal_app(plan: DeployPlan) -> dict[str, Any]:
|
|
364
|
+
"""Deploy the generated Modal app and return endpoint metadata."""
|
|
365
|
+
|
|
366
|
+
_require_modal_cli()
|
|
367
|
+
source_path = write_modal_source(plan)
|
|
368
|
+
completed = subprocess.run(
|
|
369
|
+
["modal", "deploy", str(source_path)],
|
|
370
|
+
cwd=plan.project_root,
|
|
371
|
+
text=True,
|
|
372
|
+
capture_output=True,
|
|
373
|
+
timeout=60 * 10,
|
|
374
|
+
check=False,
|
|
375
|
+
)
|
|
376
|
+
output = (completed.stdout or "") + "\n" + (completed.stderr or "")
|
|
377
|
+
if completed.returncode != 0:
|
|
378
|
+
raise RuntimeError(f"Modal deploy failed with exit code {completed.returncode}:\n{output.strip()}")
|
|
379
|
+
|
|
380
|
+
endpoint_url = _parse_endpoint(output)
|
|
98
381
|
return {
|
|
99
382
|
"app_name": plan.app_name,
|
|
100
383
|
"volume": plan.modal_volume,
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"modal_run_function": run_agent,
|
|
384
|
+
"endpoint_url": endpoint_url,
|
|
385
|
+
"dashboard_url": f"https://modal.com/apps/{plan.app_name}",
|
|
386
|
+
"source_path": str(source_path),
|
|
387
|
+
"deploy_output": output.strip(),
|
|
106
388
|
}
|