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.
Files changed (54) hide show
  1. keystone_cli/__init__.py +4 -0
  2. keystone_cli/__main__.py +24 -0
  3. keystone_cli/auth/__init__.py +5 -0
  4. keystone_cli/auth/device_flow.py +197 -0
  5. keystone_cli/auth/token_store.py +71 -0
  6. keystone_cli/commands/__init__.py +1 -0
  7. keystone_cli/commands/agent.py +1017 -0
  8. keystone_cli/commands/dev.py +57 -0
  9. keystone_cli/commands/login.py +207 -0
  10. keystone_cli/commands/workspace.py +87 -0
  11. keystone_cli/devloop.py +183 -0
  12. keystone_cli/platform_client.py +75 -0
  13. keystone_cli/runner.py +87 -0
  14. keystone_cli/scaffold.py +81 -0
  15. keystone_cli/templates/blank/README.md.tmpl +25 -0
  16. keystone_cli/templates/blank/agent.yaml.tmpl +20 -0
  17. keystone_cli/templates/blank/env.tmpl +10 -0
  18. keystone_cli/templates/blank/gitignore.tmpl +7 -0
  19. keystone_cli/templates/blank/pkg/__init__.py.tmpl +0 -0
  20. keystone_cli/templates/blank/pkg/graph.py.tmpl +33 -0
  21. keystone_cli/templates/blank/pyproject.toml.tmpl +12 -0
  22. keystone_cli/templates/hitl/README.md.tmpl +33 -0
  23. keystone_cli/templates/hitl/agent.yaml.tmpl +33 -0
  24. keystone_cli/templates/hitl/env.tmpl +10 -0
  25. keystone_cli/templates/hitl/gitignore.tmpl +7 -0
  26. keystone_cli/templates/hitl/pkg/__init__.py.tmpl +0 -0
  27. keystone_cli/templates/hitl/pkg/graph.py.tmpl +115 -0
  28. keystone_cli/templates/hitl/pyproject.toml.tmpl +12 -0
  29. keystone_cli/templates/llm/README.md.tmpl +29 -0
  30. keystone_cli/templates/llm/agent.yaml.tmpl +29 -0
  31. keystone_cli/templates/llm/env.tmpl +10 -0
  32. keystone_cli/templates/llm/gitignore.tmpl +7 -0
  33. keystone_cli/templates/llm/pkg/__init__.py.tmpl +0 -0
  34. keystone_cli/templates/llm/pkg/graph.py.tmpl +53 -0
  35. keystone_cli/templates/llm/pyproject.toml.tmpl +12 -0
  36. keystone_cli/templates/rag-qa/README.md.tmpl +23 -0
  37. keystone_cli/templates/rag-qa/agent.yaml.tmpl +34 -0
  38. keystone_cli/templates/rag-qa/env.tmpl +10 -0
  39. keystone_cli/templates/rag-qa/gitignore.tmpl +7 -0
  40. keystone_cli/templates/rag-qa/pkg/__init__.py.tmpl +0 -0
  41. keystone_cli/templates/rag-qa/pkg/graph.py.tmpl +69 -0
  42. keystone_cli/templates/rag-qa/pyproject.toml.tmpl +12 -0
  43. keystone_cli/templates/tool-agent/README.md.tmpl +30 -0
  44. keystone_cli/templates/tool-agent/agent.yaml.tmpl +25 -0
  45. keystone_cli/templates/tool-agent/env.tmpl +10 -0
  46. keystone_cli/templates/tool-agent/gitignore.tmpl +7 -0
  47. keystone_cli/templates/tool-agent/pkg/__init__.py.tmpl +0 -0
  48. keystone_cli/templates/tool-agent/pkg/graph.py.tmpl +89 -0
  49. keystone_cli/templates/tool-agent/pyproject.toml.tmpl +15 -0
  50. keystone_cli-0.1.0.dist-info/METADATA +13 -0
  51. keystone_cli-0.1.0.dist-info/RECORD +54 -0
  52. keystone_cli-0.1.0.dist-info/WHEEL +5 -0
  53. keystone_cli-0.1.0.dist-info/entry_points.txt +2 -0
  54. 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