keystone-cli 0.1.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.
- keystone_cli/__init__.py +4 -0
- keystone_cli/__main__.py +24 -0
- keystone_cli/auth/__init__.py +5 -0
- keystone_cli/auth/device_flow.py +197 -0
- keystone_cli/auth/token_store.py +71 -0
- keystone_cli/commands/__init__.py +1 -0
- keystone_cli/commands/agent.py +1017 -0
- keystone_cli/commands/dev.py +57 -0
- keystone_cli/commands/login.py +207 -0
- keystone_cli/commands/workspace.py +87 -0
- keystone_cli/devloop.py +183 -0
- keystone_cli/platform_client.py +75 -0
- keystone_cli/runner.py +87 -0
- keystone_cli/scaffold.py +81 -0
- keystone_cli/templates/blank/README.md.tmpl +25 -0
- keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
- keystone_cli/templates/blank/env.tmpl +10 -0
- keystone_cli/templates/blank/gitignore.tmpl +7 -0
- keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
- keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/hitl/README.md.tmpl +33 -0
- keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
- keystone_cli/templates/hitl/env.tmpl +10 -0
- keystone_cli/templates/hitl/gitignore.tmpl +7 -0
- keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
- keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/llm/README.md.tmpl +29 -0
- keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
- keystone_cli/templates/llm/env.tmpl +10 -0
- keystone_cli/templates/llm/gitignore.tmpl +7 -0
- keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
- keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
- keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
- keystone_cli/templates/rag-qa/env.tmpl +10 -0
- keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
- keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
- keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
- keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
- keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
- keystone_cli/templates/tool-agent/env.tmpl +10 -0
- keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
- keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
- keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
- keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
- keystone_cli-0.1.0.dist-info/METADATA +13 -0
- keystone_cli-0.1.0.dist-info/RECORD +54 -0
- keystone_cli-0.1.0.dist-info/WHEEL +5 -0
- keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
- keystone_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
"""``keystone agent`` — validate + run (local dev loop, FDP-3120).
|
|
2
|
+
|
|
3
|
+
Thin layer: ``validate`` wraps ``keystone.agent_sdk.validate``; ``run`` loads the graph via the
|
|
4
|
+
SDK loader and runs it locally (see :mod:`keystone_cli.runner`). No graph/manifest logic here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import typer
|
|
17
|
+
from keystone.agent_sdk import validate as sdk_validate
|
|
18
|
+
from keystone.agent_sdk.bundle import SecretInBundleError, create_bundle
|
|
19
|
+
from keystone.agent_sdk.provenance import SourceProvenance
|
|
20
|
+
from keystone.agent_sdk.provenance import detect as detect_provenance
|
|
21
|
+
|
|
22
|
+
from keystone_cli.auth import device_flow as df
|
|
23
|
+
from keystone_cli.auth import token_store as ts
|
|
24
|
+
from keystone_cli.runner import run_agent
|
|
25
|
+
from keystone_cli.scaffold import TEMPLATES, ScaffoldError, scaffold
|
|
26
|
+
|
|
27
|
+
agent_app = typer.Typer(
|
|
28
|
+
help="Author, validate, and run pro-code agents locally.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _manifest_path(path: str) -> str:
|
|
34
|
+
"""Accept either an agent.yaml file OR an agent project directory (→ its agent.yaml)."""
|
|
35
|
+
candidate = Path(path)
|
|
36
|
+
return str(candidate / "agent.yaml") if candidate.is_dir() else path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _upload_fields(manifest_text: str, sha256: str, provenance: SourceProvenance | None) -> dict[str, str]:
|
|
40
|
+
"""Multipart form fields for POST /bundles.
|
|
41
|
+
|
|
42
|
+
``source_provenance`` is OMITTED rather than sent empty when there is no git info: the server
|
|
43
|
+
treats absent as "unknown", and an empty-string field would have to be special-cased there. It is
|
|
44
|
+
also why the field is optional server-side — an older CLI must keep deploying.
|
|
45
|
+
"""
|
|
46
|
+
fields = {"manifest": manifest_text, "sha256": sha256}
|
|
47
|
+
if provenance is not None:
|
|
48
|
+
fields["source_provenance"] = json.dumps(provenance.as_payload(), separators=(",", ":"))
|
|
49
|
+
return fields
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@agent_app.command()
|
|
53
|
+
def init(
|
|
54
|
+
name: str = typer.Argument(..., help="Agent name (kebab-case) — also the project directory created."),
|
|
55
|
+
template: str = typer.Option("blank", "--template", help=f"One of: {', '.join(TEMPLATES)}."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Scaffold a new agent project (FDP-3440 / US-3b) — passes validate + run --offline as generated."""
|
|
58
|
+
# Workspace pre-fill: the session's `keystone workspace use` choice, else a placeholder.
|
|
59
|
+
workspace = ts.load().workspace_name or "my-team"
|
|
60
|
+
try:
|
|
61
|
+
dest = scaffold(name, template, Path.cwd(), workspace)
|
|
62
|
+
except ScaffoldError as exc:
|
|
63
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
64
|
+
raise typer.Exit(1) from exc
|
|
65
|
+
typer.secho(f"✓ created {dest} (template: {template})", fg=typer.colors.GREEN)
|
|
66
|
+
typer.echo("Next steps:")
|
|
67
|
+
typer.echo(f" keystone agent validate {name}")
|
|
68
|
+
typer.echo(f" keystone agent run {name} --offline --input '<json>' # see {name}/README.md")
|
|
69
|
+
typer.echo(f" keystone agent deploy {name} --publish")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@agent_app.command()
|
|
73
|
+
def validate(
|
|
74
|
+
path: str = typer.Argument("agent.yaml", help="Path to agent.yaml, or the agent project directory."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Validate an agent.yaml (manifest schema + entrypoint importable). Exit 1 on errors."""
|
|
77
|
+
result = sdk_validate(_manifest_path(path))
|
|
78
|
+
if result.ok:
|
|
79
|
+
typer.secho(f"✓ {path} is valid", fg=typer.colors.GREEN)
|
|
80
|
+
return
|
|
81
|
+
for err in result.errors:
|
|
82
|
+
typer.secho(f"✗ {err}", fg=typer.colors.RED)
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@agent_app.command()
|
|
87
|
+
def run(
|
|
88
|
+
path: str = typer.Argument("agent.yaml", help="Path to agent.yaml, or the agent project directory."),
|
|
89
|
+
input_: str = typer.Option("{}", "--input", help="JSON input for the agent graph."),
|
|
90
|
+
offline: bool = typer.Option(
|
|
91
|
+
False, "--offline", help="Mock rag/gateway calls — run with no live services or quota."
|
|
92
|
+
),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Load the agent's graph via the SDK and run it locally with a per-node trace."""
|
|
95
|
+
manifest_path = _manifest_path(path)
|
|
96
|
+
result = sdk_validate(manifest_path)
|
|
97
|
+
if not result.ok or result.manifest is None:
|
|
98
|
+
for err in result.errors:
|
|
99
|
+
typer.secho(f"✗ {err}", fg=typer.colors.RED)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
inputs = json.loads(input_)
|
|
104
|
+
except json.JSONDecodeError as exc:
|
|
105
|
+
typer.secho(f"✗ --input is not valid JSON: {exc}", fg=typer.colors.RED)
|
|
106
|
+
raise typer.Exit(1) from exc
|
|
107
|
+
|
|
108
|
+
final = run_agent(
|
|
109
|
+
entrypoint=result.manifest.entrypoint,
|
|
110
|
+
project_dir=Path(manifest_path).resolve().parent,
|
|
111
|
+
inputs=inputs,
|
|
112
|
+
offline=offline,
|
|
113
|
+
)
|
|
114
|
+
typer.secho(f"\n✓ result: {json.dumps(final, default=str)}", fg=typer.colors.GREEN)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@agent_app.command()
|
|
118
|
+
def deploy(
|
|
119
|
+
path: str = typer.Argument(".", help="Agent project directory (or its agent.yaml)."),
|
|
120
|
+
api_base: str | None = typer.Option(
|
|
121
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
122
|
+
),
|
|
123
|
+
workspace_id: str | None = typer.Option(
|
|
124
|
+
None,
|
|
125
|
+
"--workspace-id",
|
|
126
|
+
help="Target workspace UUID. Defaults to the one set via `keystone workspace use`.",
|
|
127
|
+
),
|
|
128
|
+
no_create: bool = typer.Option(
|
|
129
|
+
False,
|
|
130
|
+
"--no-create",
|
|
131
|
+
help="Don't auto-register the agent on first deploy — fail if it doesn't exist yet.",
|
|
132
|
+
),
|
|
133
|
+
publish: bool = typer.Option(
|
|
134
|
+
False,
|
|
135
|
+
"--publish",
|
|
136
|
+
help="Once the build reaches staged, publish it to Live (deploy → build → publish in one command).",
|
|
137
|
+
),
|
|
138
|
+
require_clean: bool = typer.Option(
|
|
139
|
+
False,
|
|
140
|
+
"--require-clean",
|
|
141
|
+
help="Refuse to deploy uncommitted changes. For CI, where an unreproducible build is a defect.",
|
|
142
|
+
),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Package the project + upload to agent-hub for a server-side build. Requires `keystone login`.
|
|
145
|
+
|
|
146
|
+
validate → bundle (tar honouring .gitignore + SHA-256) → auth (cached/refreshed token) →
|
|
147
|
+
``POST /api/agent-hub/v1/bundles`` through Kong. Idempotent by SHA-256 (re-deploy = same version).
|
|
148
|
+
On the FIRST deploy the agent header doesn't exist yet, so ``deploy`` auto-registers it
|
|
149
|
+
(``internal_pro_code``) and retries — one command for dev self-serve; opt out with ``--no-create``.
|
|
150
|
+
"""
|
|
151
|
+
manifest_path = _manifest_path(path)
|
|
152
|
+
result = sdk_validate(manifest_path) # preflight: fail fast before packaging/upload
|
|
153
|
+
if not result.ok or result.manifest is None:
|
|
154
|
+
for err in result.errors:
|
|
155
|
+
typer.secho(f"✗ {err}", fg=typer.colors.RED)
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
project_dir = Path(manifest_path).resolve().parent
|
|
159
|
+
try:
|
|
160
|
+
bundle = create_bundle(project_dir)
|
|
161
|
+
except SecretInBundleError as exc:
|
|
162
|
+
# Fail the deploy, don't warn-and-continue: once the bundle is uploaded the key is in S3 and
|
|
163
|
+
# in the image, and "we'll rotate it later" is not something a build step gets to decide.
|
|
164
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
165
|
+
raise typer.Exit(1) from exc
|
|
166
|
+
except (NotADirectoryError, FileNotFoundError) as exc:
|
|
167
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
168
|
+
raise typer.Exit(1) from exc
|
|
169
|
+
# Where this bundle came from. Recorded because the bundle is the WORKING DIRECTORY, not a git
|
|
170
|
+
# ref: without it, `bundle_sha256` proves two uploads were identical but never says which commit
|
|
171
|
+
# produced them — and says nothing at all if the tree had uncommitted edits.
|
|
172
|
+
provenance = detect_provenance(project_dir)
|
|
173
|
+
if provenance is None:
|
|
174
|
+
typer.secho(
|
|
175
|
+
"• no git provenance (not a repo, or no commits yet) — this version will not record a source commit.",
|
|
176
|
+
fg=typer.colors.YELLOW,
|
|
177
|
+
)
|
|
178
|
+
elif provenance.dirty:
|
|
179
|
+
# The one case that is genuinely unrecoverable: pinning the commit does not reproduce a tree
|
|
180
|
+
# that did not match it. Loud in dev, fatal under --require-clean.
|
|
181
|
+
message = (
|
|
182
|
+
f"uncommitted changes — the bundle is NOT reproducible from {provenance.summary()}. "
|
|
183
|
+
"Whatever ships is only in your working directory."
|
|
184
|
+
)
|
|
185
|
+
if require_clean:
|
|
186
|
+
typer.secho(f"✗ {message} (--require-clean)", fg=typer.colors.RED)
|
|
187
|
+
raise typer.Exit(1)
|
|
188
|
+
typer.secho(f"⚠ {message}", fg=typer.colors.YELLOW)
|
|
189
|
+
else:
|
|
190
|
+
typer.secho(f"• source {provenance.summary()}", fg=typer.colors.BLUE)
|
|
191
|
+
|
|
192
|
+
for env_file in bundle.skipped_env:
|
|
193
|
+
# Say it out loud. Silently dropping a file the dev put config in is how "it worked locally"
|
|
194
|
+
# turns into an hour of debugging a missing variable in the pod.
|
|
195
|
+
typer.secho(
|
|
196
|
+
f"• {env_file} not packaged (never is — secrets stay out of S3/ECR). Non-secret per-agent "
|
|
197
|
+
"config goes in agent.yaml `config:`; the API key is injected into the pod, not bundled.",
|
|
198
|
+
fg=typer.colors.YELLOW,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
cfg = ts.load()
|
|
202
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
203
|
+
if not base:
|
|
204
|
+
typer.secho("✗ no --api-base (or KEYSTONE_API_BASE) configured", fg=typer.colors.RED)
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
# Workspace is caller-supplied (not in the JWT — the user picks it): --workspace-id wins, else the
|
|
207
|
+
# one saved by `keystone workspace use`. entity/user are edge-injected from the token, so they are
|
|
208
|
+
# NOT sent here (a client-set X-Entity-ID would be spoofable; the edge overrides it anyway).
|
|
209
|
+
workspace = workspace_id or cfg.workspace_id
|
|
210
|
+
if not workspace:
|
|
211
|
+
typer.secho(
|
|
212
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
213
|
+
fg=typer.colors.RED,
|
|
214
|
+
)
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
try:
|
|
217
|
+
token = df.get_access_token(cfg, save=ts.save) # cached, or refreshed via the stored refresh token
|
|
218
|
+
except df.DeviceFlowError as exc:
|
|
219
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
220
|
+
raise typer.Exit(1) from exc
|
|
221
|
+
|
|
222
|
+
name = result.manifest.name
|
|
223
|
+
manifest_text = Path(manifest_path).read_text(encoding="utf-8")
|
|
224
|
+
typer.echo(f"Uploading {name} — {bundle.file_count} files, sha256 {bundle.sha256[:12]}…")
|
|
225
|
+
|
|
226
|
+
def _upload(client: httpx.Client) -> httpx.Response:
|
|
227
|
+
return client.post(
|
|
228
|
+
f"{base}/api/agent-hub/v1/bundles",
|
|
229
|
+
headers={"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace},
|
|
230
|
+
data=_upload_fields(manifest_text, bundle.sha256, provenance),
|
|
231
|
+
files={"bundle": ("bundle.tar.gz", bundle.data, "application/gzip")},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
with httpx.Client(timeout=120) as client:
|
|
236
|
+
resp = _upload(client)
|
|
237
|
+
# First deploy: the agent header doesn't exist yet (404 AGENT_NOT_FOUND). Auto-register it
|
|
238
|
+
# as a pro-code agent and retry, so `deploy` is one command for dev self-serve — the server
|
|
239
|
+
# still scopes it to the caller's workspace. Opt out with --no-create.
|
|
240
|
+
if resp.status_code == 404 and "AGENT_NOT_FOUND" in resp.text and not no_create:
|
|
241
|
+
typer.echo(f" agent '{name}' not found — registering (internal_pro_code)…")
|
|
242
|
+
reg = client.post(
|
|
243
|
+
f"{base}/api/agent-hub/v1/agents",
|
|
244
|
+
headers={
|
|
245
|
+
"Authorization": f"Bearer {token}",
|
|
246
|
+
"X-Workspace-ID": workspace,
|
|
247
|
+
"Content-Type": "application/json",
|
|
248
|
+
},
|
|
249
|
+
json={
|
|
250
|
+
"name": name,
|
|
251
|
+
"display_name": name.replace("-", " ").title(),
|
|
252
|
+
"agent_type": "internal_pro_code",
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
# 409 = a concurrent deploy already registered it → proceed to re-upload regardless.
|
|
256
|
+
if reg.status_code not in (200, 201, 409):
|
|
257
|
+
typer.secho(f"✗ auto-register failed ({reg.status_code}): {reg.text}", fg=typer.colors.RED)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
resp = _upload(client)
|
|
260
|
+
except httpx.RequestError as exc:
|
|
261
|
+
typer.secho(f"✗ upload failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
262
|
+
raise typer.Exit(1) from exc
|
|
263
|
+
|
|
264
|
+
if resp.status_code not in (200, 201, 202):
|
|
265
|
+
typer.secho(f"✗ deploy rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
body = resp.json() if resp.content else {}
|
|
268
|
+
version = body.get("version") or body.get("version_id") or "?"
|
|
269
|
+
typer.secho(
|
|
270
|
+
f"✓ deployed {name} — version {version} (status {body.get('status', 'accepted')})",
|
|
271
|
+
fg=typer.colors.GREEN,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if publish:
|
|
275
|
+
version_id = body.get("version_id")
|
|
276
|
+
if not version_id:
|
|
277
|
+
typer.secho("✗ deploy did not return a version_id — cannot --publish.", fg=typer.colors.RED)
|
|
278
|
+
raise typer.Exit(1)
|
|
279
|
+
pub_headers = {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
|
|
280
|
+
try:
|
|
281
|
+
with httpx.Client(timeout=60) as client:
|
|
282
|
+
agent_id = _resolve_agent_id(client, base, pub_headers, name)
|
|
283
|
+
status = _wait_for_staged(client, base, pub_headers, agent_id, str(version_id))
|
|
284
|
+
if status == "live":
|
|
285
|
+
typer.echo(f" {version} is already live — nothing to publish.")
|
|
286
|
+
else:
|
|
287
|
+
_do_publish(client, base, pub_headers, agent_id, str(version_id), name)
|
|
288
|
+
except httpx.RequestError as exc:
|
|
289
|
+
typer.secho(f"✗ publish failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
290
|
+
raise typer.Exit(1) from exc
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@agent_app.command()
|
|
294
|
+
def invoke(
|
|
295
|
+
agent: str = typer.Argument(..., help="Deployed agent name (as routed by the gateway), e.g. rag-qa."),
|
|
296
|
+
input_: str = typer.Option("{}", "--input", help="JSON input for the agent (matches its io_schema)."),
|
|
297
|
+
api_base: str | None = typer.Option(
|
|
298
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
299
|
+
),
|
|
300
|
+
workspace_id: str | None = typer.Option(
|
|
301
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
302
|
+
),
|
|
303
|
+
async_: bool = typer.Option(
|
|
304
|
+
False, "--async", help="Queue a background run (FDP-3332) and return the run_id immediately."
|
|
305
|
+
),
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Invoke a deployed agent through the gateway (Kong/JWT). Requires `keystone login`.
|
|
308
|
+
|
|
309
|
+
Sends the caller-supplied tenant scope the runtime needs downstream: ``X-Workspace-ID`` and a
|
|
310
|
+
fresh ``X-Correlation-ID``. The Stage tier is fully retired (FDP-2071) — no ``X-Stage-ID`` is
|
|
311
|
+
resolved or sent anywhere any more. entity/user are edge-injected
|
|
312
|
+
from the token (not sent here — a client-set value is spoofable and the edge overrides it
|
|
313
|
+
anyway). With ``--async`` the run is queued (202) — poll it with ``keystone agent runs get``.
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
inputs = json.loads(input_)
|
|
317
|
+
except json.JSONDecodeError as exc:
|
|
318
|
+
typer.secho(f"✗ --input is not valid JSON: {exc}", fg=typer.colors.RED)
|
|
319
|
+
raise typer.Exit(1) from exc
|
|
320
|
+
|
|
321
|
+
cfg = ts.load()
|
|
322
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
323
|
+
if not base:
|
|
324
|
+
typer.secho("✗ no --api-base (or KEYSTONE_API_BASE) configured", fg=typer.colors.RED)
|
|
325
|
+
raise typer.Exit(1)
|
|
326
|
+
workspace = workspace_id or cfg.workspace_id
|
|
327
|
+
if not workspace:
|
|
328
|
+
typer.secho(
|
|
329
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
330
|
+
fg=typer.colors.RED,
|
|
331
|
+
)
|
|
332
|
+
raise typer.Exit(1)
|
|
333
|
+
try:
|
|
334
|
+
token = df.get_access_token(cfg, save=ts.save) # cached, or refreshed via the stored refresh token
|
|
335
|
+
except df.DeviceFlowError as exc:
|
|
336
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
337
|
+
raise typer.Exit(1) from exc
|
|
338
|
+
|
|
339
|
+
endpoint = "invoke-async" if async_ else "invoke"
|
|
340
|
+
gw_base = _agent_api_base(cfg, base)
|
|
341
|
+
url = f"{gw_base}/api/agent-service/v1/agents/{agent}/{endpoint}"
|
|
342
|
+
headers = {
|
|
343
|
+
"Authorization": f"Bearer {token}",
|
|
344
|
+
"X-Workspace-ID": workspace,
|
|
345
|
+
"X-Correlation-ID": str(uuid.uuid4()),
|
|
346
|
+
}
|
|
347
|
+
body: dict = {}
|
|
348
|
+
try:
|
|
349
|
+
with httpx.Client(timeout=120) as client:
|
|
350
|
+
for _attempt in range(_WARMING_MAX_ATTEMPTS):
|
|
351
|
+
resp = client.post(url, headers=headers, json={"inputs": inputs})
|
|
352
|
+
if resp.status_code not in (200, 202):
|
|
353
|
+
typer.secho(f"✗ invoke rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
354
|
+
raise typer.Exit(1)
|
|
355
|
+
body = resp.json() if resp.content else {}
|
|
356
|
+
if body.get("status") != "warming":
|
|
357
|
+
break
|
|
358
|
+
# 202 warming = the gateway woke a scaled-to-zero/not-ready version and did
|
|
359
|
+
# NOT forward the request — no run exists, so re-POSTing is safe. Honour
|
|
360
|
+
# Retry-After (the gateway sets it) before trying again.
|
|
361
|
+
wait_s = float(resp.headers.get("retry-after", 5))
|
|
362
|
+
typer.secho(f"… {agent} — warming (cold start), retrying in {wait_s:.0f}s", fg=typer.colors.YELLOW)
|
|
363
|
+
time.sleep(wait_s)
|
|
364
|
+
else:
|
|
365
|
+
typer.secho(
|
|
366
|
+
f"✗ {agent} — still warming after {_WARMING_MAX_ATTEMPTS} tries; retry shortly", fg=typer.colors.RED
|
|
367
|
+
)
|
|
368
|
+
raise typer.Exit(1)
|
|
369
|
+
except httpx.RequestError as exc:
|
|
370
|
+
typer.secho(f"✗ invoke failed (is {gw_base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
371
|
+
raise typer.Exit(1) from exc
|
|
372
|
+
|
|
373
|
+
_print_run_outcome(agent, body)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# How many 5s polls to ride out the post-create authz propagation window (FDP-3443).
|
|
377
|
+
# Observed on dev: the row lands in ~30s; 12 polls = 60s of headroom before giving up.
|
|
378
|
+
_REGISTRY_PROPAGATION_POLLS = 12
|
|
379
|
+
_FAILURE_STATUSES = ("failed", "cancelled", "expired")
|
|
380
|
+
_PENDING_STATUSES = ("queued", "running", "cancelling", "awaiting_human", "warming")
|
|
381
|
+
_WARMING_MAX_ATTEMPTS = 6
|
|
382
|
+
# Transient run states the --wait poll keeps polling through ("warming" = the gateway is
|
|
383
|
+
# still cold-starting the version pod; the GET itself was not served by the runtime).
|
|
384
|
+
_WAIT_TRANSIENT_STATUSES = ("queued", "running", "cancelling", "warming")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _print_run_outcome(agent: str, body: dict) -> None:
|
|
388
|
+
"""Shared outcome printer (FDP-3332): pending statuses are NOT failures — a queued or
|
|
389
|
+
HITL-paused run exits 0 with the handle to poll/respond with."""
|
|
390
|
+
status = body.get("status", "?")
|
|
391
|
+
run_id = body.get("run_id") or body.get("session_id", "?")
|
|
392
|
+
if status in _FAILURE_STATUSES:
|
|
393
|
+
typer.secho(f"✗ {agent} — {status}: {body.get('error') or 'no result'}", fg=typer.colors.RED)
|
|
394
|
+
raise typer.Exit(1)
|
|
395
|
+
if status in _PENDING_STATUSES:
|
|
396
|
+
typer.secho(f"… {agent} — {status} (run {run_id})", fg=typer.colors.YELLOW)
|
|
397
|
+
typer.echo(f" poll with: keystone agent runs get {agent} {run_id} --wait")
|
|
398
|
+
return
|
|
399
|
+
typer.secho(f"✓ {agent} — {status} (session {body.get('session_id', run_id)})", fg=typer.colors.GREEN)
|
|
400
|
+
typer.echo(json.dumps(body.get("result"), indent=2, default=str, ensure_ascii=False))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _agent_api_base(cfg: ts.AuthConfig, control_base: str) -> str:
|
|
404
|
+
"""Data-plane base for gateway calls — invoke/hitl/runs (FDP-3072 §11.4b).
|
|
405
|
+
|
|
406
|
+
The pro-code cluster gets its own edge hostname (``keystone-agent-api-<env>.aws.int…``);
|
|
407
|
+
control-plane calls (login/deploy/status, /users/me) always stay on ``api_base``. Until Ops
|
|
408
|
+
lands the new domain this falls back to the control-plane base, so behavior is unchanged.
|
|
409
|
+
Resolution: ``KEYSTONE_AGENT_API_BASE`` env → ``agent_api_base`` in the config file → fallback.
|
|
410
|
+
"""
|
|
411
|
+
return (os.environ.get("KEYSTONE_AGENT_API_BASE") or cfg.agent_api_base or control_base).rstrip("/")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _gateway_ctx(api_base: str | None, workspace_id: str | None) -> tuple[str, dict[str, str]]:
|
|
415
|
+
"""(base, headers) for gateway calls — same auth/tenant plumbing as `invoke` (FDP-3332).
|
|
416
|
+
|
|
417
|
+
The returned base is the DATA-PLANE one (`_agent_api_base`). The Stage tier is fully retired
|
|
418
|
+
(FDP-2071) — nothing is resolved or sent."""
|
|
419
|
+
cfg = ts.load()
|
|
420
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
421
|
+
workspace = workspace_id or cfg.workspace_id
|
|
422
|
+
if not base or not workspace:
|
|
423
|
+
typer.secho("✗ need an api base + workspace — run `keystone workspace use` first.", fg=typer.colors.RED)
|
|
424
|
+
raise typer.Exit(1)
|
|
425
|
+
try:
|
|
426
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
427
|
+
except df.DeviceFlowError as exc:
|
|
428
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
429
|
+
raise typer.Exit(1) from exc
|
|
430
|
+
headers = {
|
|
431
|
+
"Authorization": f"Bearer {token}",
|
|
432
|
+
"X-Workspace-ID": workspace,
|
|
433
|
+
"X-Correlation-ID": str(uuid.uuid4()),
|
|
434
|
+
}
|
|
435
|
+
return _agent_api_base(cfg, base), headers
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
runs_app = typer.Typer(help="Background runs (FDP-3332): poll or cancel an async invoke.")
|
|
439
|
+
agent_app.add_typer(runs_app, name="runs")
|
|
440
|
+
|
|
441
|
+
hitl_app = typer.Typer(help="Human-in-the-loop approvals (FDP-3336): list paused runs, respond.")
|
|
442
|
+
agent_app.add_typer(hitl_app, name="hitl")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@hitl_app.command("list")
|
|
446
|
+
def hitl_list(
|
|
447
|
+
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
448
|
+
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
449
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Approver inbox: this agent's runs paused ``awaiting_human`` (newest first)."""
|
|
452
|
+
base, headers = _gateway_ctx(api_base, workspace_id)
|
|
453
|
+
try:
|
|
454
|
+
with httpx.Client(timeout=30) as client:
|
|
455
|
+
resp = client.get(f"{base}/api/agent-service/v1/agents/{agent}/hitl/pending", headers=headers)
|
|
456
|
+
except httpx.RequestError as exc:
|
|
457
|
+
typer.secho(f"✗ hitl list failed (is {base} reachable?): {exc}", fg=typer.colors.RED)
|
|
458
|
+
raise typer.Exit(1) from exc
|
|
459
|
+
if resp.status_code != 200:
|
|
460
|
+
typer.secho(f"✗ hitl list rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
461
|
+
raise typer.Exit(1)
|
|
462
|
+
items = (resp.json() or {}).get("items", [])
|
|
463
|
+
if not items:
|
|
464
|
+
typer.echo(f"{agent}: no runs awaiting approval.")
|
|
465
|
+
return
|
|
466
|
+
for it in items:
|
|
467
|
+
# agent_version already carries its "v" prefix (platform convention) — print raw.
|
|
468
|
+
expiry = f" expires {it['expires_at']} ({it.get('on_timeout') or 'expire'})" if it.get("expires_at") else ""
|
|
469
|
+
typer.echo(f"{it['run_id']} {it.get('agent_version') or '?'} {it.get('created_at') or ''}{expiry}")
|
|
470
|
+
typer.echo(f" {json.dumps(it.get('payload', {}), ensure_ascii=False)}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@hitl_app.command("respond")
|
|
474
|
+
def hitl_respond(
|
|
475
|
+
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
476
|
+
run_id: str = typer.Argument(..., help="Paused run to answer."),
|
|
477
|
+
approve: bool = typer.Option(..., "--approve/--reject", help="The verdict."),
|
|
478
|
+
note: str = typer.Option("", "--note", help="Optional note passed to the agent."),
|
|
479
|
+
input_json: str | None = typer.Option(None, "--input", help="Optional extra JSON handed to the agent."),
|
|
480
|
+
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
481
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Answer a paused run — the decision becomes ``request_approval``'s return value.
|
|
484
|
+
|
|
485
|
+
202 = re-queued (poll ``runs get``); 409 = already answered (double-submit guard)."""
|
|
486
|
+
body: dict = {"approved": approve, "note": note}
|
|
487
|
+
if input_json:
|
|
488
|
+
try:
|
|
489
|
+
body["input"] = json.loads(input_json)
|
|
490
|
+
except json.JSONDecodeError as exc:
|
|
491
|
+
typer.secho(f"✗ --input is not valid JSON: {exc}", fg=typer.colors.RED)
|
|
492
|
+
raise typer.Exit(2) from exc
|
|
493
|
+
base, headers = _gateway_ctx(api_base, workspace_id)
|
|
494
|
+
# Pin the gateway to the version that owns this run's thread (D-M3-A session pin).
|
|
495
|
+
headers = {**headers, "X-Session-ID": run_id}
|
|
496
|
+
try:
|
|
497
|
+
with httpx.Client(timeout=30) as client:
|
|
498
|
+
resp = client.post(
|
|
499
|
+
f"{base}/api/agent-service/v1/agents/{agent}/hitl/{run_id}/respond", headers=headers, json=body
|
|
500
|
+
)
|
|
501
|
+
except httpx.RequestError as exc:
|
|
502
|
+
typer.secho(f"✗ respond failed (is {base} reachable?): {exc}", fg=typer.colors.RED)
|
|
503
|
+
raise typer.Exit(1) from exc
|
|
504
|
+
if resp.status_code == 403:
|
|
505
|
+
typer.secho(f"✗ you are not in this run's approver list (run {run_id})", fg=typer.colors.RED)
|
|
506
|
+
raise typer.Exit(1)
|
|
507
|
+
if resp.status_code == 409:
|
|
508
|
+
typer.secho(f"✗ run {run_id} has no open approval (already answered?)", fg=typer.colors.RED)
|
|
509
|
+
raise typer.Exit(1)
|
|
510
|
+
if resp.status_code != 202:
|
|
511
|
+
typer.secho(f"✗ respond rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
512
|
+
raise typer.Exit(1)
|
|
513
|
+
verdict = "approved" if approve else "rejected"
|
|
514
|
+
typer.secho(f"✓ {agent} — run {run_id} {verdict}; resuming (poll: keystone agent runs get)", fg=typer.colors.GREEN)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@runs_app.command("get")
|
|
518
|
+
def runs_get(
|
|
519
|
+
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
520
|
+
run_id: str = typer.Argument(..., help="run_id returned by `invoke --async`."),
|
|
521
|
+
wait: bool = typer.Option(False, "--wait", help="Poll until the run leaves queued/running."),
|
|
522
|
+
poll_seconds: float = typer.Option(3.0, "--poll-seconds", help="Poll interval with --wait."),
|
|
523
|
+
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
524
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Fetch a background run's state (`--wait` polls to a settled state)."""
|
|
527
|
+
base, headers = _gateway_ctx(api_base, workspace_id)
|
|
528
|
+
url = f"{base}/api/agent-service/v1/agents/{agent}/runs/{run_id}"
|
|
529
|
+
try:
|
|
530
|
+
with httpx.Client(timeout=30) as client:
|
|
531
|
+
while True:
|
|
532
|
+
resp = client.get(url, headers=headers)
|
|
533
|
+
# 202 = gateway `warming` (cold-starting the version pod) — transient.
|
|
534
|
+
if resp.status_code not in (200, 202):
|
|
535
|
+
typer.secho(f"✗ runs get failed ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
536
|
+
raise typer.Exit(1)
|
|
537
|
+
body = resp.json() if resp.content else {}
|
|
538
|
+
if not wait or body.get("status") not in _WAIT_TRANSIENT_STATUSES:
|
|
539
|
+
break
|
|
540
|
+
time.sleep(poll_seconds)
|
|
541
|
+
except httpx.RequestError as exc:
|
|
542
|
+
typer.secho(f"✗ runs get failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
543
|
+
raise typer.Exit(1) from exc
|
|
544
|
+
_print_run_outcome(agent, body)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@runs_app.command("cancel")
|
|
548
|
+
def runs_cancel(
|
|
549
|
+
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
550
|
+
run_id: str = typer.Argument(..., help="run_id to cancel."),
|
|
551
|
+
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
552
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
553
|
+
) -> None:
|
|
554
|
+
"""Cancel a queued/running background run (terminal runs → 409)."""
|
|
555
|
+
base, headers = _gateway_ctx(api_base, workspace_id)
|
|
556
|
+
try:
|
|
557
|
+
with httpx.Client(timeout=30) as client:
|
|
558
|
+
resp = client.post(f"{base}/api/agent-service/v1/agents/{agent}/runs/{run_id}/cancel", headers=headers)
|
|
559
|
+
except httpx.RequestError as exc:
|
|
560
|
+
typer.secho(f"✗ cancel failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
561
|
+
raise typer.Exit(1) from exc
|
|
562
|
+
if resp.status_code not in (200, 202):
|
|
563
|
+
typer.secho(f"✗ cancel rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
564
|
+
raise typer.Exit(1)
|
|
565
|
+
body = resp.json() if resp.content else {}
|
|
566
|
+
typer.secho(f"✓ {agent} — run {run_id}: {body.get('status', '?')}", fg=typer.colors.GREEN)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@runs_app.command("list")
|
|
570
|
+
def runs_list(
|
|
571
|
+
agent: str = typer.Argument(..., help="Deployed agent name."),
|
|
572
|
+
status: str | None = typer.Option(None, "--status", help="Filter by run status."),
|
|
573
|
+
limit: int = typer.Option(20, "--limit", help="Page size."),
|
|
574
|
+
offset: int = typer.Option(0, "--offset", help="Page offset (newest first)."),
|
|
575
|
+
api_base: str | None = typer.Option(None, "--api-base", envvar="KEYSTONE_API_BASE"),
|
|
576
|
+
workspace_id: str | None = typer.Option(None, "--workspace-id"),
|
|
577
|
+
) -> None:
|
|
578
|
+
"""List an agent's runs, newest first (FDP-3099 / FDP-4318 P1). Tenant+agent+user scoped server-side."""
|
|
579
|
+
base, headers = _gateway_ctx(api_base, workspace_id)
|
|
580
|
+
params: list[tuple[str, str]] = [("limit", str(limit)), ("offset", str(offset))]
|
|
581
|
+
if status:
|
|
582
|
+
params.append(("status", status))
|
|
583
|
+
url = f"{base}/api/agent-service/v1/agents/{agent}/runs"
|
|
584
|
+
try:
|
|
585
|
+
with httpx.Client(timeout=30) as client:
|
|
586
|
+
resp = client.get(url, headers=headers, params=params)
|
|
587
|
+
except httpx.RequestError as exc:
|
|
588
|
+
typer.secho(f"✗ runs list failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
589
|
+
raise typer.Exit(1) from exc
|
|
590
|
+
if resp.status_code == 202: # gateway `warming` (cold-starting the version pod)
|
|
591
|
+
typer.secho(f"… {agent} is warming — retry in a moment.", fg=typer.colors.YELLOW)
|
|
592
|
+
raise typer.Exit(0)
|
|
593
|
+
if resp.status_code != 200:
|
|
594
|
+
typer.secho(f"✗ runs list failed ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
595
|
+
raise typer.Exit(1)
|
|
596
|
+
body = resp.json() if resp.content else {}
|
|
597
|
+
items = body.get("items", [])
|
|
598
|
+
total = body.get("total", len(items))
|
|
599
|
+
if not items:
|
|
600
|
+
typer.echo(f"no runs for {agent}.")
|
|
601
|
+
return
|
|
602
|
+
typer.echo(f" {'RUN_ID':<36} {'STATUS':<14} {'VER':<5} CREATED")
|
|
603
|
+
for it in items:
|
|
604
|
+
typer.echo(
|
|
605
|
+
f" {it.get('run_id', ''):<36} {it.get('status', ''):<14} "
|
|
606
|
+
f"{(it.get('agent_version') or '-'):<5} {it.get('created_at') or ''}"
|
|
607
|
+
)
|
|
608
|
+
typer.echo(f"\n showing {len(items)} of {total} (offset {offset})")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _resolve_agent_id(client: httpx.Client, base: str, headers: dict[str, str], name: str) -> str:
|
|
612
|
+
"""Look up an agent's UUID by its (workspace-unique) name."""
|
|
613
|
+
# `limit` is capped at 100 server-side; `q` narrows by name/display_name (substring) so the exact
|
|
614
|
+
# match is on the first page even in a busy workspace. Still exact-match client-side (q is fuzzy).
|
|
615
|
+
resp = client.get(f"{base}/api/agent-hub/v1/agents", headers=headers, params={"limit": 100, "q": name})
|
|
616
|
+
if resp.status_code != 200:
|
|
617
|
+
typer.secho(f"✗ could not list agents ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
for a in resp.json().get("data", []):
|
|
620
|
+
if a.get("name") == name:
|
|
621
|
+
return str(a["id"])
|
|
622
|
+
typer.secho(
|
|
623
|
+
f"✗ no agent '{name}' in this workspace — deploy it first (`keystone agent deploy`).",
|
|
624
|
+
fg=typer.colors.RED,
|
|
625
|
+
)
|
|
626
|
+
raise typer.Exit(1)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _pick_version(
|
|
630
|
+
client: httpx.Client, base: str, headers: dict[str, str], agent_id: str, version_tag: str | None
|
|
631
|
+
) -> tuple[str, str]:
|
|
632
|
+
"""Return (version_id, version_tag) to publish — a specific `--version`, else the latest staged."""
|
|
633
|
+
params: dict[str, object] = {"limit": 100, "sort_by": "created_at", "sort_order": "desc"}
|
|
634
|
+
if not version_tag:
|
|
635
|
+
params["status"] = "staged"
|
|
636
|
+
resp = client.get(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions", headers=headers, params=params)
|
|
637
|
+
if resp.status_code != 200:
|
|
638
|
+
typer.secho(f"✗ could not list versions ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
639
|
+
raise typer.Exit(1)
|
|
640
|
+
versions = resp.json().get("data", [])
|
|
641
|
+
if version_tag:
|
|
642
|
+
for v in versions:
|
|
643
|
+
if v.get("version") == version_tag:
|
|
644
|
+
return str(v["id"]), version_tag
|
|
645
|
+
typer.secho(f"✗ version '{version_tag}' not found for '{agent_id}'.", fg=typer.colors.RED)
|
|
646
|
+
raise typer.Exit(1)
|
|
647
|
+
if not versions:
|
|
648
|
+
typer.secho(
|
|
649
|
+
"✗ no staged version to publish — run `keystone agent deploy` (or wait for the build to finish).",
|
|
650
|
+
fg=typer.colors.RED,
|
|
651
|
+
)
|
|
652
|
+
raise typer.Exit(1)
|
|
653
|
+
top = versions[0]
|
|
654
|
+
return str(top["id"]), str(top.get("version", "?"))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _registry_still_propagating(resp: httpx.Response) -> bool:
|
|
658
|
+
"""True for the 403 a JUST-created agent answers with (FDP-3443, dev-observed 2026-07-31).
|
|
659
|
+
|
|
660
|
+
Authz resolves the agent's owner from the **platform resource registry**, whose row is
|
|
661
|
+
published asynchronously on create. Between "agent registered" and "row visible" every read
|
|
662
|
+
fail-closes with ``AGENT_FORBIDDEN`` / ``reason: resource_registry_miss`` — so a first deploy
|
|
663
|
+
would abort right after uploading, `--publish` would never run, and the version silently sat
|
|
664
|
+
in `staged` while the operator thought it had gone Live. Retrying THIS reason (and only this
|
|
665
|
+
one — a real permission denial must still fail fast) rides out the propagation window.
|
|
666
|
+
"""
|
|
667
|
+
if resp.status_code != 403:
|
|
668
|
+
return False
|
|
669
|
+
try:
|
|
670
|
+
details = resp.json().get("error", {}).get("details", {})
|
|
671
|
+
except ValueError:
|
|
672
|
+
return False
|
|
673
|
+
return details.get("reason") == "resource_registry_miss"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _wait_for_staged(client: httpx.Client, base: str, headers: dict[str, str], agent_id: str, version_id: str) -> str:
|
|
677
|
+
"""Poll a version until it leaves `building`. Returns the terminal status (`staged`/`live`);
|
|
678
|
+
exits on `build_failed` or after ~4 min."""
|
|
679
|
+
propagation_retries = 0
|
|
680
|
+
for _ in range(48):
|
|
681
|
+
r = client.get(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{version_id}", headers=headers)
|
|
682
|
+
if _registry_still_propagating(r):
|
|
683
|
+
propagation_retries += 1
|
|
684
|
+
if propagation_retries > _REGISTRY_PROPAGATION_POLLS:
|
|
685
|
+
typer.secho(
|
|
686
|
+
"✗ the agent is registered but still not visible to authz "
|
|
687
|
+
f"(resource_registry_miss for >{_REGISTRY_PROPAGATION_POLLS * 5}s). "
|
|
688
|
+
"The build continues server-side — retry with `keystone agent status` / "
|
|
689
|
+
"`keystone agent publish` in a moment.",
|
|
690
|
+
fg=typer.colors.RED,
|
|
691
|
+
)
|
|
692
|
+
raise typer.Exit(1)
|
|
693
|
+
if propagation_retries == 1:
|
|
694
|
+
typer.echo(" waiting for the new agent to become visible to authz…")
|
|
695
|
+
time.sleep(5)
|
|
696
|
+
continue
|
|
697
|
+
if r.status_code != 200:
|
|
698
|
+
typer.secho(f"✗ could not read version status ({r.status_code}): {r.text}", fg=typer.colors.RED)
|
|
699
|
+
raise typer.Exit(1)
|
|
700
|
+
status = str(r.json().get("data", {}).get("status", ""))
|
|
701
|
+
if status in ("staged", "live"):
|
|
702
|
+
return status
|
|
703
|
+
if status == "build_failed":
|
|
704
|
+
typer.secho("✗ build failed — check the build logs (agent-hub / Kaniko Job).", fg=typer.colors.RED)
|
|
705
|
+
raise typer.Exit(1)
|
|
706
|
+
typer.echo(f" build: {status}…")
|
|
707
|
+
time.sleep(5)
|
|
708
|
+
typer.secho(
|
|
709
|
+
"✗ timed out waiting for the build — check status, then run `keystone agent publish`.",
|
|
710
|
+
fg=typer.colors.RED,
|
|
711
|
+
)
|
|
712
|
+
raise typer.Exit(1)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _do_publish(
|
|
716
|
+
client: httpx.Client, base: str, headers: dict[str, str], agent_id: str, version_id: str, label: str
|
|
717
|
+
) -> None:
|
|
718
|
+
"""POST .../publish and report the result (shared by `publish` and `deploy --publish`)."""
|
|
719
|
+
resp = client.post(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{version_id}/publish", headers=headers)
|
|
720
|
+
if resp.status_code not in (200, 201):
|
|
721
|
+
typer.secho(f"✗ publish rejected ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
722
|
+
raise typer.Exit(1)
|
|
723
|
+
body = resp.json().get("data", {}) if resp.content else {}
|
|
724
|
+
prev = body.get("previous_live_version")
|
|
725
|
+
demoted = f" (demoted {prev} → staged)" if prev else ""
|
|
726
|
+
typer.secho(f"✓ published {label} {body.get('version', '?')} → live{demoted}", fg=typer.colors.GREEN)
|
|
727
|
+
typer.echo(" the operator is deploying it now — `keystone agent invoke` works once the pod is Running.")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@agent_app.command()
|
|
731
|
+
def publish(
|
|
732
|
+
agent: str = typer.Argument(..., help="Agent name to publish, e.g. rag-qa."),
|
|
733
|
+
version: str | None = typer.Option(
|
|
734
|
+
None, "--version", help="Version tag to publish. Defaults to the latest staged version."
|
|
735
|
+
),
|
|
736
|
+
api_base: str | None = typer.Option(
|
|
737
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
738
|
+
),
|
|
739
|
+
workspace_id: str | None = typer.Option(
|
|
740
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
741
|
+
),
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Promote a staged version to Live (staged → live). Requires `keystone login`.
|
|
744
|
+
|
|
745
|
+
Resolves the agent by name, picks the version (``--version`` tag, else the latest staged), and
|
|
746
|
+
publishes it. The operator then deploys the per-agent pod and the gateway routes to it —
|
|
747
|
+
``keystone agent invoke`` works once the pod is Running (a few seconds).
|
|
748
|
+
"""
|
|
749
|
+
cfg = ts.load()
|
|
750
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
751
|
+
workspace = workspace_id or cfg.workspace_id
|
|
752
|
+
if not workspace:
|
|
753
|
+
typer.secho(
|
|
754
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
755
|
+
fg=typer.colors.RED,
|
|
756
|
+
)
|
|
757
|
+
raise typer.Exit(1)
|
|
758
|
+
try:
|
|
759
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
760
|
+
except df.DeviceFlowError as exc:
|
|
761
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
762
|
+
raise typer.Exit(1) from exc
|
|
763
|
+
|
|
764
|
+
headers = {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
|
|
765
|
+
try:
|
|
766
|
+
with httpx.Client(timeout=60) as client:
|
|
767
|
+
agent_id = _resolve_agent_id(client, base, headers, agent)
|
|
768
|
+
version_id, _ = _pick_version(client, base, headers, agent_id, version)
|
|
769
|
+
_do_publish(client, base, headers, agent_id, version_id, agent)
|
|
770
|
+
except httpx.RequestError as exc:
|
|
771
|
+
typer.secho(f"✗ publish failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
772
|
+
raise typer.Exit(1) from exc
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@agent_app.command()
|
|
776
|
+
def status(
|
|
777
|
+
agent: str = typer.Argument(..., help="Agent name, e.g. docs-qa."),
|
|
778
|
+
api_base: str | None = typer.Option(
|
|
779
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
780
|
+
),
|
|
781
|
+
workspace_id: str | None = typer.Option(
|
|
782
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
783
|
+
),
|
|
784
|
+
) -> None:
|
|
785
|
+
"""List an agent's versions + their status — the `live` (currently published) one is marked ``*``."""
|
|
786
|
+
cfg = ts.load()
|
|
787
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
788
|
+
workspace = workspace_id or cfg.workspace_id
|
|
789
|
+
if not workspace:
|
|
790
|
+
typer.secho(
|
|
791
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
792
|
+
fg=typer.colors.RED,
|
|
793
|
+
)
|
|
794
|
+
raise typer.Exit(1)
|
|
795
|
+
try:
|
|
796
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
797
|
+
except df.DeviceFlowError as exc:
|
|
798
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
799
|
+
raise typer.Exit(1) from exc
|
|
800
|
+
|
|
801
|
+
headers = {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
|
|
802
|
+
try:
|
|
803
|
+
with httpx.Client(timeout=60) as client:
|
|
804
|
+
agent_id = _resolve_agent_id(client, base, headers, agent)
|
|
805
|
+
resp = client.get(
|
|
806
|
+
f"{base}/api/agent-hub/v1/agents/{agent_id}/versions",
|
|
807
|
+
headers=headers,
|
|
808
|
+
params={"limit": 100, "sort_by": "created_at", "sort_order": "desc"},
|
|
809
|
+
)
|
|
810
|
+
except httpx.RequestError as exc:
|
|
811
|
+
typer.secho(f"✗ status failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
812
|
+
raise typer.Exit(1) from exc
|
|
813
|
+
|
|
814
|
+
if resp.status_code != 200:
|
|
815
|
+
typer.secho(f"✗ could not list versions ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
816
|
+
raise typer.Exit(1)
|
|
817
|
+
versions = resp.json().get("data", [])
|
|
818
|
+
if not versions:
|
|
819
|
+
typer.echo(f"{agent}: no versions yet — run `keystone agent deploy`.")
|
|
820
|
+
return
|
|
821
|
+
width = max(len("VERSION"), *(len(str(v.get("version", "?"))) for v in versions))
|
|
822
|
+
typer.echo(f" {'VERSION':<{width}} STATUS")
|
|
823
|
+
for v in versions:
|
|
824
|
+
ver = str(v.get("version", "?"))
|
|
825
|
+
st = str(v.get("status", "?"))
|
|
826
|
+
line = f"{'*' if st == 'live' else ' '} {ver:<{width}} {st}"
|
|
827
|
+
typer.secho(line, fg=typer.colors.GREEN) if st == "live" else typer.echo(line)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# ── retire / delete (FDP-3439 / US-24 — M4.4a) ──────────────────────────────
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _hub_auth(api_base: str | None, workspace_id: str | None) -> tuple[str, dict[str, str]]:
|
|
834
|
+
"""(base, headers) for agent-hub lifecycle calls — the login/workspace dance."""
|
|
835
|
+
cfg = ts.load()
|
|
836
|
+
base = (api_base or cfg.api_base or ts.DEFAULT_API_BASE).rstrip("/")
|
|
837
|
+
workspace = workspace_id or cfg.workspace_id
|
|
838
|
+
if not workspace:
|
|
839
|
+
typer.secho(
|
|
840
|
+
"✗ no workspace selected — run `keystone workspace use <name|id>` (or pass --workspace-id).",
|
|
841
|
+
fg=typer.colors.RED,
|
|
842
|
+
)
|
|
843
|
+
raise typer.Exit(1)
|
|
844
|
+
try:
|
|
845
|
+
token = df.get_access_token(cfg, save=ts.save)
|
|
846
|
+
except df.DeviceFlowError as exc:
|
|
847
|
+
typer.secho(f"✗ {exc}", fg=typer.colors.RED)
|
|
848
|
+
raise typer.Exit(1) from exc
|
|
849
|
+
return base, {"Authorization": f"Bearer {token}", "X-Workspace-ID": workspace}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _list_versions(client: httpx.Client, base: str, headers: dict[str, str], agent_id: str) -> list[dict]:
|
|
853
|
+
resp = client.get(
|
|
854
|
+
f"{base}/api/agent-hub/v1/agents/{agent_id}/versions",
|
|
855
|
+
headers=headers,
|
|
856
|
+
params={"limit": 100, "sort_by": "created_at", "sort_order": "desc"},
|
|
857
|
+
)
|
|
858
|
+
if resp.status_code != 200:
|
|
859
|
+
typer.secho(f"✗ could not list versions ({resp.status_code}): {resp.text}", fg=typer.colors.RED)
|
|
860
|
+
raise typer.Exit(1)
|
|
861
|
+
return list(resp.json().get("data", []))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _open_approvals(client: httpx.Client, base: str, agent: str, hub_headers: dict[str, str]) -> list[dict] | None:
|
|
865
|
+
"""Best-effort HITL guard (OQ-M4-6): open approvals via the gateway inbox. ``None`` =
|
|
866
|
+
could not check (cold pod / network) — callers WARN, they don't block.
|
|
867
|
+
|
|
868
|
+
FDP-2071: the retired stage lookup is gone — it used to make this guard silently SKIP
|
|
869
|
+
(return ``None``) on every post-stage workspace tree, i.e. the guard never actually ran."""
|
|
870
|
+
try:
|
|
871
|
+
resp = client.get(
|
|
872
|
+
f"{base}/api/agent-service/v1/agents/{agent}/hitl/pending",
|
|
873
|
+
headers={**hub_headers, "X-Correlation-ID": str(uuid.uuid4())},
|
|
874
|
+
)
|
|
875
|
+
except httpx.RequestError:
|
|
876
|
+
return None
|
|
877
|
+
if resp.status_code != 200:
|
|
878
|
+
return None
|
|
879
|
+
return list(resp.json().get("items", []))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _guard_open_approvals(
|
|
883
|
+
client: httpx.Client, base: str, agent: str, headers: dict[str, str], version: str | None
|
|
884
|
+
) -> None:
|
|
885
|
+
"""Block on open approvals for the target (all versions when ``version`` is None)."""
|
|
886
|
+
pending = _open_approvals(client, base, agent, headers)
|
|
887
|
+
if pending is None:
|
|
888
|
+
typer.secho("… could not check open approvals (inbox unreachable) — continuing", fg=typer.colors.YELLOW)
|
|
889
|
+
return
|
|
890
|
+
hits = [p for p in pending if version is None or p.get("agent_version") in (None, version)]
|
|
891
|
+
if hits:
|
|
892
|
+
typer.secho(
|
|
893
|
+
f"✗ {agent} has {len(hits)} open approval(s) awaiting a human — respond to them "
|
|
894
|
+
"(`keystone agent hitl list/respond`) or re-run with --force.",
|
|
895
|
+
fg=typer.colors.RED,
|
|
896
|
+
)
|
|
897
|
+
raise typer.Exit(1)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@agent_app.command()
|
|
901
|
+
def retire(
|
|
902
|
+
agent: str = typer.Argument(..., help="Agent name, e.g. rag-qa."),
|
|
903
|
+
version: str = typer.Option(..., "--version", help="Version tag to retire, e.g. v3."),
|
|
904
|
+
api_base: str | None = typer.Option(
|
|
905
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
906
|
+
),
|
|
907
|
+
workspace_id: str | None = typer.Option(
|
|
908
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
909
|
+
),
|
|
910
|
+
force: bool = typer.Option(False, "--force", help="Skip the open-approvals guard and the confirmation prompt."),
|
|
911
|
+
) -> None:
|
|
912
|
+
"""Retire ONE version — terminal (FDP-3439 / US-24). A Live version is unpublished first
|
|
913
|
+
(that traffic stops). The operator tears the version's pods/routes down on the retire
|
|
914
|
+
event. Idempotent: an already-retired version no-ops."""
|
|
915
|
+
base, headers = _hub_auth(api_base, workspace_id)
|
|
916
|
+
try:
|
|
917
|
+
with httpx.Client(timeout=60) as client:
|
|
918
|
+
agent_id = _resolve_agent_id(client, base, headers, agent)
|
|
919
|
+
versions = _list_versions(client, base, headers, agent_id)
|
|
920
|
+
v = next((x for x in versions if x.get("version") == version), None)
|
|
921
|
+
if v is None:
|
|
922
|
+
typer.secho(f"✗ version '{version}' not found for '{agent}'.", fg=typer.colors.RED)
|
|
923
|
+
raise typer.Exit(1)
|
|
924
|
+
status = str(v.get("status", ""))
|
|
925
|
+
if status == "retired":
|
|
926
|
+
typer.secho(f"✓ {agent} {version} is already retired — nothing to do", fg=typer.colors.GREEN)
|
|
927
|
+
return
|
|
928
|
+
if status not in ("staged", "live"):
|
|
929
|
+
typer.secho(
|
|
930
|
+
f"✗ cannot retire a '{status}' version — only staged/live (delete the agent to drop drafts).",
|
|
931
|
+
fg=typer.colors.RED,
|
|
932
|
+
)
|
|
933
|
+
raise typer.Exit(1)
|
|
934
|
+
if not force:
|
|
935
|
+
_guard_open_approvals(client, base, agent, headers, version)
|
|
936
|
+
live_warn = " (LIVE — it will be unpublished first and STOP SERVING)" if status == "live" else ""
|
|
937
|
+
typer.confirm(f"Retire {agent} {version}{live_warn}?", abort=True)
|
|
938
|
+
vid = str(v["id"])
|
|
939
|
+
if status == "live":
|
|
940
|
+
r = client.post(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{vid}/unpublish", headers=headers)
|
|
941
|
+
if r.status_code not in (200, 201):
|
|
942
|
+
typer.secho(f"✗ unpublish rejected ({r.status_code}): {r.text}", fg=typer.colors.RED)
|
|
943
|
+
raise typer.Exit(1)
|
|
944
|
+
typer.echo(f" unpublished {version} (live → staged)")
|
|
945
|
+
r = client.post(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{vid}/retire", headers=headers)
|
|
946
|
+
if r.status_code not in (200, 201):
|
|
947
|
+
typer.secho(f"✗ retire rejected ({r.status_code}): {r.text}", fg=typer.colors.RED)
|
|
948
|
+
raise typer.Exit(1)
|
|
949
|
+
typer.secho(f"✓ retired {agent} {version} — the operator is tearing its pods down", fg=typer.colors.GREEN)
|
|
950
|
+
except httpx.RequestError as exc:
|
|
951
|
+
typer.secho(f"✗ retire failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
952
|
+
raise typer.Exit(1) from exc
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
@agent_app.command()
|
|
956
|
+
def delete(
|
|
957
|
+
agent: str = typer.Argument(..., help="Agent name to delete ENTIRELY, e.g. rag-qa."),
|
|
958
|
+
api_base: str | None = typer.Option(
|
|
959
|
+
None, "--api-base", envvar="KEYSTONE_API_BASE", help="Platform API base (through Kong)."
|
|
960
|
+
),
|
|
961
|
+
workspace_id: str | None = typer.Option(
|
|
962
|
+
None, "--workspace-id", help="Target workspace UUID. Defaults to the one set via `keystone workspace use`."
|
|
963
|
+
),
|
|
964
|
+
force: bool = typer.Option(False, "--force", help="Skip the open-approvals guard and the confirmation prompt."),
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Delete the WHOLE agent (FDP-3439 / US-24): unpublish Live, retire every staged/live
|
|
967
|
+
version, then soft-delete the registry entry. All traffic stops; pods/routes tear down
|
|
968
|
+
via the operator. Idempotent — each step skips what is already done."""
|
|
969
|
+
base, headers = _hub_auth(api_base, workspace_id)
|
|
970
|
+
try:
|
|
971
|
+
with httpx.Client(timeout=120) as client:
|
|
972
|
+
agent_id = _resolve_agent_id(client, base, headers, agent)
|
|
973
|
+
versions = _list_versions(client, base, headers, agent_id)
|
|
974
|
+
lives = [v for v in versions if v.get("status") == "live"]
|
|
975
|
+
active = [v for v in versions if v.get("status") in ("staged", "live")]
|
|
976
|
+
skipped = [v for v in versions if v.get("status") not in ("staged", "live", "retired")]
|
|
977
|
+
if not force:
|
|
978
|
+
_guard_open_approvals(client, base, agent, headers, None)
|
|
979
|
+
typer.confirm(
|
|
980
|
+
f"DELETE agent '{agent}' — {len(versions)} version(s), {len(lives)} live? "
|
|
981
|
+
"All of its traffic stops.",
|
|
982
|
+
abort=True,
|
|
983
|
+
)
|
|
984
|
+
for v in lives:
|
|
985
|
+
r = client.post(
|
|
986
|
+
f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{v['id']}/unpublish", headers=headers
|
|
987
|
+
)
|
|
988
|
+
if r.status_code not in (200, 201):
|
|
989
|
+
typer.secho(
|
|
990
|
+
f"✗ unpublish {v.get('version')} rejected ({r.status_code}): {r.text}", fg=typer.colors.RED
|
|
991
|
+
)
|
|
992
|
+
raise typer.Exit(1)
|
|
993
|
+
typer.echo(f" unpublished {v.get('version')}")
|
|
994
|
+
for v in active:
|
|
995
|
+
r = client.post(f"{base}/api/agent-hub/v1/agents/{agent_id}/versions/{v['id']}/retire", headers=headers)
|
|
996
|
+
if r.status_code not in (200, 201):
|
|
997
|
+
typer.secho(
|
|
998
|
+
f"✗ retire {v.get('version')} rejected ({r.status_code}): {r.text}", fg=typer.colors.RED
|
|
999
|
+
)
|
|
1000
|
+
raise typer.Exit(1)
|
|
1001
|
+
typer.echo(f" retired {v.get('version')}")
|
|
1002
|
+
if skipped:
|
|
1003
|
+
tags = ", ".join(str(v.get("version")) for v in skipped)
|
|
1004
|
+
typer.echo(f" skipped non-retirable version(s): {tags} (soft-deleted with the agent)")
|
|
1005
|
+
r = client.delete(f"{base}/api/agent-hub/v1/agents/{agent_id}", headers=headers)
|
|
1006
|
+
if r.status_code in (200, 204, 404): # 404 = already gone (idempotent re-run)
|
|
1007
|
+
typer.secho(
|
|
1008
|
+
f"✓ deleted {agent} (soft) — pods/routes tear down via the operator; "
|
|
1009
|
+
"the registry row is kept for audit.",
|
|
1010
|
+
fg=typer.colors.GREEN,
|
|
1011
|
+
)
|
|
1012
|
+
return
|
|
1013
|
+
typer.secho(f"✗ delete rejected ({r.status_code}): {r.text}", fg=typer.colors.RED)
|
|
1014
|
+
raise typer.Exit(1)
|
|
1015
|
+
except httpx.RequestError as exc:
|
|
1016
|
+
typer.secho(f"✗ delete failed (is {base} reachable / are you on the network?): {exc}", fg=typer.colors.RED)
|
|
1017
|
+
raise typer.Exit(1) from exc
|