elan-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.
elan_cli/scaffold.py ADDED
@@ -0,0 +1,408 @@
1
+ """`elan init` and `elan new` — the two files created together, always.
2
+
3
+ THE WHOLE POINT IS THAT THEY CANNOT DRIFT. A target in the repo but not in
4
+ `elan.yaml` is refused, and so is the reverse — in both directions, because an
5
+ undeclared target is the one nobody reviewed and a declared one with no code
6
+ would provision a database and a topic for nothing. A scaffolding command that
7
+ wrote the module and left the developer to remember the YAML would be building
8
+ exactly the failure the cross-check exists to catch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from elan_agentkit_contracts import templates
16
+ from elan_agentkit_contracts.identity import (
17
+ AGENT_SLUG_MAX_LEN,
18
+ AGENT_SLUG_MIN_LEN,
19
+ AGENT_SLUG_PATTERN,
20
+ PROJECT_SLUG_MAX_LEN,
21
+ PROJECT_SLUG_MIN_LEN,
22
+ TENANT_SLUG_MAX_LEN,
23
+ TENANT_SLUG_MIN_LEN,
24
+ )
25
+
26
+ from elan_cli.project import SPEC_FILENAME, ProjectError, add_target, find
27
+
28
+ #: `agent` and `workflow` differ by the file the registry scans for, and by
29
+ #: nothing else the scaffolder needs to know.
30
+ KINDS = {"agent": ("agents", "agent.py"), "workflow": ("workflows", "workflow.py")}
31
+
32
+ #: A SHA THAT IS OBVIOUSLY A PLACEHOLDER. `source.ref` must match a 40-character
33
+ #: hex pattern, so it cannot be left blank — and a plausible-looking sha would
34
+ #: be one somebody might not notice was invented. `elan deploy` resolves the
35
+ #: branch and rewrites this before submitting.
36
+ #:
37
+ #: QUOTED IN THE RENDERED FILE, and that is not cosmetic: forty zeroes is a
38
+ #: valid YAML INTEGER, so unquoted it reaches the schema as `0` and the error
39
+ #: says "Input should be a valid string" about a field the developer can see
40
+ #: plainly contains one. Any real sha starting with a digit and containing no
41
+ #: letter would do the same.
42
+ PLACEHOLDER_SHA = "0" * 40
43
+
44
+ #: DELIBERATELY SHORT. A scaffolder that writes somebody's whole ignore file
45
+ #: is guessing at their editor and their operating system; these three are the
46
+ #: ones THIS TOOL causes.
47
+ GITIGNORE = """\
48
+ .env
49
+ .venv/
50
+ .elan/
51
+ __pycache__/
52
+ *.pyc
53
+ """
54
+
55
+ #: THE NAMES ARE THE PLATFORM'S, NOT A LOCAL CONVENTION, which is the whole
56
+ #: value of scaffolding this: a deployed pod reads `GOOGLE_API_KEY` because
57
+ #: `llm.keys: {{google: ...}}` resolves to it through `providers.key_envs`. A
58
+ #: developer who invented their own name here would have something that worked
59
+ #: locally and read nothing in production.
60
+ ENV_TEMPLATE = """\
61
+ # Local development only, and git-ignored because it holds a key.
62
+ #
63
+ # A DEPLOYED POD NEVER READS THIS FILE. The platform injects the real values
64
+ # from Secret Manager under these same names, which is why the names here are
65
+ # worth matching exactly.
66
+
67
+ # YOUR MODEL KEY, and the name is not yours to choose. `GOOGLE_` is a reserved
68
+ # prefix — a tenant setting GOOGLE_CLOUD_PROJECT could point a cloud SDK
69
+ # somewhere unintended — so you declare `llm.keys: {{google: <secret>}}` in
70
+ # elan.yaml and the platform resolves it to this variable.
71
+ GOOGLE_API_KEY=
72
+ # ANTHROPIC_API_KEY=
73
+ # OPENAI_API_KEY=
74
+
75
+ # Lets MODEL_OVERRIDE work, and it is refused anywhere else. Overriding the
76
+ # model substitutes what the manifest declared AFTER the deploy-time
77
+ # `model in llm.models` check has already passed, so the allowlist would stop
78
+ # describing what actually runs.
79
+ AGENTKIT_ENV=local
80
+ # MODEL_OVERRIDE=gemini-flash-latest
81
+
82
+ # DELIBERATELY UNSET, and the runtime says so on every start rather than
83
+ # failing: no ledger means runs are unbilled, and no actions service means
84
+ # autonomy follows your manifest alone. Pointing these at the real services
85
+ # from a laptop is not a local test, it is a remote one.
86
+ # LEDGER_SERVICE_URL=
87
+ # ACTIONS_SERVICE_URL=
88
+
89
+ # DO NOT SET TARGET_SLUG HERE. It names which agent a pod runs, so with it set
90
+ # every target would build as that one. `elan adk` strips it from what it
91
+ # launches for exactly that reason, but anything else reading Settings obeys
92
+ # it.
93
+ """
94
+
95
+ README_TEMPLATE = """\
96
+ # {project}
97
+
98
+ Agents deployed to the Elan platform from this repository.
99
+
100
+ ## Getting set up
101
+
102
+ Put your model key in `.env` (git-ignored), then:
103
+
104
+ ```bash
105
+ elan env # the runtime, into .venv
106
+ elan validate # both files, checked against each other
107
+ elan adk web # drive the agents locally
108
+ ```
109
+
110
+ ## The two files, and why there are two
111
+
112
+ `elan.yaml` holds **deploy-time facts** — what to build, how big, what it may
113
+ reach. Each agent's Python `MANIFEST` holds **runtime behaviour** — its model,
114
+ its tools, what it may do without asking. No field appears in both. Where they
115
+ look like they overlap they are in a validation relationship: the MANIFEST
116
+ states what the agent asks for, `elan.yaml` states what the platform permits,
117
+ and a deploy is refused on a mismatch.
118
+
119
+ `elan new agent <slug>` writes both halves, which is the point of it.
120
+
121
+ ## Custom plugins
122
+
123
+ A target may add ADK plugins of its own, declared in its `MANIFEST`:
124
+
125
+ ```python
126
+ MANIFEST = AgentManifest(
127
+ ...,
128
+ plugins=[".plugins.Redactor"], # plugins.py beside this file
129
+ )
130
+ ```
131
+
132
+ A leading dot is relative to the target's own package, like a Python import;
133
+ without one it is absolute, for a plugin from a library in your
134
+ `pyproject.toml`. The path names a `BasePlugin` subclass (constructed with no
135
+ arguments) or an already-built instance — an instance is how a plugin gets
136
+ configuration.
137
+
138
+ **Keep it inside the target's directory.** The image is built from `agents/`
139
+ and `workflows/` only, so a `lib/` at the repo root is there when your manifest
140
+ is read and gone when the pod runs.
141
+
142
+ Your plugins run **after** the platform's, and that ordering is fixed. ADK
143
+ stops at the first callback that returns a value, so anything earlier would
144
+ skip the platform's gates rather than sit beside them.
145
+
146
+ Two ADK behaviours worth knowing before you write one: a nested `AgentTool` or
147
+ `WorkflowTool` run fires **no** plugin callbacks at all, and `on_event_callback`
148
+ does not fire for a run halted in `before_run_callback`.
149
+
150
+ ## Dependencies
151
+
152
+ There is no `pyproject.toml` here and you may not need one. The platform's
153
+ runtime image already carries ADK and the agentkit packages; add a
154
+ `pyproject.toml` only for libraries **beyond** those, and the build will
155
+ install it. Never put `agentkit-*` in it.
156
+
157
+ ## Deploying
158
+
159
+ ```bash
160
+ git push # elan deploy builds the REMOTE commit, not your disk
161
+ elan deploy
162
+ ```
163
+ """
164
+
165
+
166
+ def _slug_ok(slug: str, *, lo: int, hi: int) -> bool:
167
+ import re
168
+
169
+ return bool(lo <= len(slug) <= hi and re.match(AGENT_SLUG_PATTERN, slug))
170
+
171
+
172
+ def _refuse_slug(slug: str, what: str, lo: int, hi: int) -> None:
173
+ raise ProjectError(
174
+ f"invalid {what} slug {slug!r}\n"
175
+ f" must be {lo}-{hi} characters matching {AGENT_SLUG_PATTERN}\n"
176
+ f" — starting with a letter, not ending in a hyphen, and with no\n"
177
+ f" doubled hyphen. The cap is what keeps every derived DNS label\n"
178
+ f" under 63 characters once a tenant and project are prefixed, and\n"
179
+ f" the doubled-hyphen rule is what makes `--` unambiguous as the\n"
180
+ f" separator between them."
181
+ )
182
+
183
+
184
+ SPEC_TEMPLATE = '''\
185
+ # What the platform provisions for this project.
186
+ #
187
+ # TWO FILES, AND NO FIELD IN BOTH. This one holds DEPLOY-TIME facts — what to
188
+ # build, how big, what it may reach. Each target's Python MANIFEST holds RUNTIME
189
+ # behaviour — its model, its tools, what it may do without asking. They are
190
+ # checked against each other on every deploy, in both directions.
191
+ apiVersion: elan.dev/v1
192
+ kind: Project
193
+
194
+ tenant: {tenant}
195
+ project: {project}
196
+
197
+ source:
198
+ repo: {repo}
199
+ # REWRITTEN BY `elan deploy`, which resolves your branch to a commit and puts
200
+ # the sha here before submitting — so a deployment is reproducible from this
201
+ # file alone. All zeroes until the first deploy.
202
+ ref: "{sha}"
203
+ # Must contain agents/<name>/agent.py and workflows/<name>/workflow.py.
204
+ targetsRoot: .
205
+ runtime: python3.12
206
+
207
+ defaults:
208
+ # Every target inherits these and may override any of them. Autopilot bills a
209
+ # 250m / 512Mi floor whatever you ask for, so asking for less saves nothing.
210
+ resources: {{cpu: "500m", memory: "1Gi"}}
211
+ scaling: {{minReplicas: 0, maxReplicas: 3}}
212
+ llm:
213
+ # Every target's MANIFEST.model must be one of these. The list also decides
214
+ # which provider hosts your agents can reach at all — the egress allowlist
215
+ # is derived from it — so a model missing here fails as a refused
216
+ # connection naming the hostname.
217
+ models: [gemini-flash-latest]
218
+ # provider -> a name within YOUR OWN secrets, set with `elan secret set`.
219
+ # Not a Secret Manager id: the platform prefixes it with your tenant. The
220
+ # variable it arrives under is ours to choose (GOOGLE_API_KEY), because
221
+ # those names sit under reserved prefixes.
222
+ keys:
223
+ google: default
224
+ # THIRD PARTIES YOUR TOOLS CALL. Default-deny: anything not listed here is
225
+ # refused at the proxy by hostname. Your model providers are added
226
+ # automatically from `llm.models` above — you should not have to know which
227
+ # hostname a model lives on.
228
+ egress: []
229
+
230
+ # Every agent and workflow in this repo, by slug. The set here must equal the
231
+ # set the repo actually contains — in both directions, checked on every deploy.
232
+ # `elan new` writes both halves so they cannot drift.
233
+ targets:
234
+ '''
235
+
236
+ def init(directory: Path, *, tenant: str, project: str, repo: str,
237
+ agent: str = "hello-agent") -> int:
238
+ """Lay down `elan.yaml`, the directories the registry scans, and one agent.
239
+
240
+ IT CREATES AN AGENT BECAUSE AN EMPTY PROJECT DOES NOT PARSE. `DeploySpec`
241
+ refuses a spec with no targets — "a project with no agents deploys nothing"
242
+ — which is right for something about to be submitted, and it means there is
243
+ no such thing as a valid empty `elan.yaml`. Scaffolding one anyway would
244
+ hand the developer a file whose very first `elan validate` fails with a
245
+ schema error about a file they have not touched.
246
+
247
+ So `init` leaves a project that is complete rather than a project that is
248
+ started: one agent, declared, scaffolded, and passing validation.
249
+ """
250
+ if not _slug_ok(tenant, lo=TENANT_SLUG_MIN_LEN, hi=TENANT_SLUG_MAX_LEN):
251
+ _refuse_slug(tenant, "tenant", TENANT_SLUG_MIN_LEN, TENANT_SLUG_MAX_LEN)
252
+ if not _slug_ok(project, lo=PROJECT_SLUG_MIN_LEN, hi=PROJECT_SLUG_MAX_LEN):
253
+ _refuse_slug(project, "project", PROJECT_SLUG_MIN_LEN, PROJECT_SLUG_MAX_LEN)
254
+
255
+ spec_path = directory / SPEC_FILENAME
256
+ if spec_path.exists():
257
+ raise ProjectError(
258
+ f"{spec_path} already exists. `elan init` will not overwrite a "
259
+ "deploy file — it is the one thing in the repo that cannot be "
260
+ "regenerated from anything else."
261
+ )
262
+
263
+ directory.mkdir(parents=True, exist_ok=True)
264
+ (directory / "agents").mkdir(exist_ok=True)
265
+ (directory / "workflows").mkdir(exist_ok=True)
266
+ # WRITTEN HERE RATHER THAN LEFT TO THE DEVELOPER because `elan env` puts a
267
+ # virtualenv in this directory, and a committed one is both enormous and
268
+ # full of absolute paths from somebody else's machine. NOT OVERWRITTEN: a
269
+ # repo that already has one has it for reasons this does not know.
270
+ # NONE OF THESE OVERWRITE. A directory that already has one has it for
271
+ # reasons this does not know, and `init` refusing to clobber is the same
272
+ # rule elan.yaml gets a paragraph about above.
273
+ written = []
274
+ for name, body in (
275
+ (".gitignore", GITIGNORE),
276
+ (".env", ENV_TEMPLATE.format(project=project)),
277
+ ("README.md", README_TEMPLATE.format(project=project)),
278
+ ):
279
+ path = directory / name
280
+ if not path.exists():
281
+ path.write_text(body)
282
+ written.append(path)
283
+ spec_path.write_text(SPEC_TEMPLATE.format(
284
+ tenant=tenant, project=project, repo=repo, sha=PLACEHOLDER_SHA))
285
+ print(f"created {spec_path}")
286
+ print(f" {directory / 'agents'}/")
287
+ print(f" {directory / 'workflows'}/")
288
+ for path in written:
289
+ print(f" {path}")
290
+
291
+ # WRITTEN DIRECTLY RATHER THAN THROUGH `new`, because `new` parses the spec
292
+ # to check the slug is free — and at this instant the spec does not parse.
293
+ _write_target("agent", agent, directory)
294
+ add_target(spec_path, agent)
295
+ print(f" {directory / 'agents' / agent.replace('-', '_')}/agent.py")
296
+
297
+ _git_init(directory)
298
+
299
+ # THE ENVIRONMENT, ALWAYS, BECAUSE IT IS DERIVABLE NOW (ADR-0220).
300
+ #
301
+ # This block used to open "`init` CANNOT DERIVE THIS": the runtime lived in
302
+ # a checkout of the platform monorepo and nothing about a new repo says
303
+ # where that is, so it happened only when told — by `--runtime` or by
304
+ # `$ELAN_RUNTIME_SRC` — and was a printed instruction otherwise.
305
+ #
306
+ # PRINTING IT WAS NOT ENOUGH, which is the lesson worth keeping. "Next:
307
+ # elan env" under a heading reads as optional, and the next thing anybody
308
+ # does is `elan validate` or `adk web` — both of which then fail on the
309
+ # missing environment the line was about. That failure mode is now removed
310
+ # rather than mitigated, since there is nothing left to ask the developer
311
+ # for.
312
+ #
313
+ # THE INSTRUCTION SURVIVES FOR THE ONE CASE THAT CAN STILL HIT IT: no
314
+ # network, or no `uv`. Then the install fails, says why in uv's own words,
315
+ # and `Next:` leads with the command to retry.
316
+ from elan_cli import env as env_cmd # noqa: PLC0415
317
+
318
+ print()
319
+ made_environment = env_cmd.run(directory) == 0
320
+
321
+ print()
322
+ print("Next:")
323
+ if not made_environment:
324
+ # STILL FIRST, because `validate` and `adk` both need it.
325
+ print(" elan env # installs the runtime into .venv")
326
+ print(" elan validate # checks both files against each other")
327
+ print(" elan adk web # drive it locally, plugins and all")
328
+ print(" elan new agent <slug> # scaffolds a module AND declares it")
329
+ return 0
330
+
331
+
332
+ def _git_init(directory: Path) -> None:
333
+ """Make this a git repository, if it is not one already.
334
+
335
+ NOT A CONVENIENCE. `elan deploy` resolves a branch to a commit and builds
336
+ THAT COMMIT FROM THE REMOTE — so an agent that is not in a repository
337
+ cannot be deployed at all, and the failure arrives much later, phrased as
338
+ a clone error. A scaffold that produces something undeployable is not
339
+ finished.
340
+
341
+ ALREADY-A-REPO INCLUDES A PARENT. `git rev-parse` walks up, so scaffolding
342
+ into a subdirectory of an existing repository correctly does nothing
343
+ rather than nesting one repo inside another — which git permits and then
344
+ behaves confusingly about.
345
+
346
+ SILENT WHEN GIT IS ABSENT. Nothing else here needs it, and refusing to
347
+ scaffold because a tool is missing would be the wrong trade.
348
+ """
349
+ import shutil # noqa: PLC0415
350
+ import subprocess # noqa: PLC0415
351
+
352
+ if not shutil.which("git"):
353
+ return
354
+ inside = subprocess.run(
355
+ ["git", "rev-parse", "--git-dir"], cwd=directory,
356
+ capture_output=True, text=True)
357
+ if inside.returncode == 0:
358
+ return
359
+ created = subprocess.run(
360
+ ["git", "init", "--quiet"], cwd=directory,
361
+ capture_output=True, text=True)
362
+ if created.returncode == 0:
363
+ print(f" {directory / '.git'}/ (git init)")
364
+
365
+
366
+ def _write_target(kind: str, slug: str, root: Path) -> Path:
367
+ """The module itself. Shared by `init` and `new` so they cannot diverge."""
368
+ package, filename = KINDS[kind]
369
+ module = slug.replace("-", "_")
370
+ directory = root / package / module
371
+ if directory.exists():
372
+ raise ProjectError(f"{directory} already exists")
373
+ directory.mkdir(parents=True)
374
+ path = directory / filename
375
+ path.write_text(templates.read(kind).format(
376
+ slug=slug, module=module, display_name=slug.replace("-", " ").title()))
377
+ return path
378
+
379
+
380
+ def new(kind: str, slug: str, *, start: Path | None = None) -> int:
381
+ """Scaffold a target's module and declare it in the same command."""
382
+ if not _slug_ok(slug, lo=AGENT_SLUG_MIN_LEN, hi=AGENT_SLUG_MAX_LEN):
383
+ _refuse_slug(slug, kind, AGENT_SLUG_MIN_LEN, AGENT_SLUG_MAX_LEN)
384
+
385
+ project = find(start)
386
+
387
+ # ONE NAMESPACE ACROSS BOTH KINDS. An agent and a workflow sharing a slug
388
+ # would resolve to the same workload, database and topics — so the check is
389
+ # against every declared target, not against this kind's directory.
390
+ if slug in project.spec.targets:
391
+ raise ProjectError(
392
+ f"{slug!r} is already declared in {project.spec_path}."
393
+ )
394
+
395
+ # THE YAML SECOND, so a failure to write the module leaves the deploy file
396
+ # untouched rather than declaring a target that does not exist.
397
+ path = _write_target(kind, slug, project.targets_root)
398
+ add_target(project.spec_path, slug)
399
+
400
+ print(f"created {path}")
401
+ print(f"updated {project.spec_path}")
402
+ print()
403
+ print("Next:")
404
+ print(f" 1. Edit {path} — instruction, description, limits.")
405
+ print(f" 2. Set owner.team and owner.contacts for {slug} in "
406
+ f"{project.spec_path.name}.")
407
+ print(" 3. elan validate")
408
+ return 0
elan_cli/secret.py ADDED
@@ -0,0 +1,105 @@
1
+ """`elan secret` — a tenant's own credentials, set and removed by the tenant.
2
+
3
+ WHY THIS EXISTS AT ALL: rotating a provider key was a `gcloud` command run by
4
+ platform staff. That makes the platform a bottleneck for the one operation a
5
+ customer must be able to perform immediately — a leaked key is an emergency,
6
+ and "raise a ticket" is the wrong answer to one.
7
+
8
+ A VALUE GOES IN AND NEVER COMES OUT. There is no `get`, deliberately. A CLI
9
+ that printed a secret would put it in a terminal, a scrollback and a screen
10
+ share, and the pod that needs it reads Secret Manager directly. What can be
11
+ listed is NAMES.
12
+
13
+ THE VALUE IS NEVER AN ARGUMENT. `--value` would put the key in the shell
14
+ history of everyone who uses it, so it is read from a file or from stdin —
15
+ the same reason `gcloud secrets versions add` takes `--data-file`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from elan_cli.client import Api
24
+
25
+ #: The two subtrees a tenant owns. `llm` is a provider credential, resolved by
26
+ #: provider name — `elan secret set llm google` sets what `llm.keys.google`
27
+ #: in elan.yaml points at. `secrets` is whatever the tenant declared.
28
+ KINDS = ("llm", "secrets")
29
+
30
+
31
+ class SecretError(Exception):
32
+ """Phrased for the developer running the command."""
33
+
34
+
35
+ def _read_value(from_file: str) -> str:
36
+ """From a file, or from stdin. NEVER from the command line.
37
+
38
+ STRIPPED OF ONE TRAILING NEWLINE and no more. Pressing Enter after pasting
39
+ a key adds one, and a provider answers a key with `\\n` on the end with a
40
+ 401 that says the key is invalid — which sends somebody to regenerate a
41
+ perfectly good credential. Leading and internal whitespace is left alone:
42
+ some credentials legitimately contain it, and silently trimming those
43
+ would corrupt them.
44
+ """
45
+ if from_file == "-":
46
+ if sys.stdin.isatty():
47
+ print("reading the value from stdin; end with Ctrl-D",
48
+ file=sys.stderr)
49
+ raw = sys.stdin.read()
50
+ else:
51
+ path = Path(from_file)
52
+ if not path.is_file():
53
+ raise SecretError(f"no such file: {from_file}")
54
+ raw = path.read_text()
55
+
56
+ value = raw[:-1] if raw.endswith("\n") else raw
57
+ if not value:
58
+ raise SecretError(
59
+ "the value is empty. A secret with no value is worse than none: "
60
+ "the pod starts and fails at its first call."
61
+ )
62
+ return value
63
+
64
+
65
+ def set_(api: Api, kind: str, name: str, from_file: str) -> int:
66
+ if kind not in KINDS:
67
+ raise SecretError(f"kind must be one of {', '.join(KINDS)}")
68
+ api.request("POST", "/v1/secrets",
69
+ json={"kind": kind, "name": name, "value": _read_value(from_file)})
70
+ print(f"set {kind}/{name}")
71
+ # THE POD READS ITS ENVIRONMENT ONCE, AT STARTUP. External Secrets will
72
+ # refresh the projected Secret on its own schedule, and a pod already
73
+ # running keeps the value it started with — so a rotation is not live
74
+ # until something replaces the pod. Saying so beats a customer believing
75
+ # their compromised key is out of use.
76
+ print(" Running pods keep the value they started with. Redeploy, or wait")
77
+ print(" for them to scale to zero, before the new value is in use.")
78
+ return 0
79
+
80
+
81
+ def ls(api: Api) -> int:
82
+ secrets = api.request("GET", "/v1/secrets").get("secrets", [])
83
+ if not secrets:
84
+ print("no secrets set")
85
+ return 0
86
+ width = max(len(f"{s['kind']}/{s['name']}") for s in secrets)
87
+ for entry in secrets:
88
+ path = f"{entry['kind']}/{entry['name']}"
89
+ print(f"{path:<{width}} updated {entry['updated_at'][:10]}")
90
+ # SET OUT OF BAND IS INVISIBLE HERE, and a developer who put one there
91
+ # with gcloud would otherwise think it had not worked.
92
+ print()
93
+ print("Names only — values are never shown. A secret written directly to")
94
+ print("Secret Manager works and is not listed.")
95
+ return 0
96
+
97
+
98
+ def rm(api: Api, kind: str, name: str) -> int:
99
+ if kind not in KINDS:
100
+ raise SecretError(f"kind must be one of {', '.join(KINDS)}")
101
+ api.request("DELETE", f"/v1/secrets/{kind}/{name}")
102
+ print(f"removed {kind}/{name}")
103
+ print(" Every version is gone. A pod already running is unaffected until")
104
+ print(" it restarts, and then it will not start at all.")
105
+ return 0
elan_cli/stream.py ADDED
@@ -0,0 +1,162 @@
1
+ """Watching a run happen, over rt-service's event socket.
2
+
3
+ ── WHY POLLING A TRANSCRIPT CANNOT WORK ─────────────────────────────────────
4
+
5
+ `elan invoke` used to publish an invocation and then poll
6
+ `GET /v1/sessions/<target>/<session>` until a reply appeared. It never did, for
7
+ a reason no amount of waiting fixes: ADK writes a conversation to the AGENT'S
8
+ OWN DATABASE (ADR-0036), and rt-service holds a connection to the PLATFORM's.
9
+ So that route can read the platform canary's transcript and can never read a
10
+ tenant's — it answers, correctly and forever, that there is nothing there.
11
+
12
+ THE EVENT STREAM IS THE PATH THAT WAS DESIGNED FOR THIS. An agent publishes to
13
+ `agent.events.<tenant>`, rt-service subscribes to a tenant lazily on its first
14
+ socket and fans out to whoever is watching. Nothing in it touches a database.
15
+
16
+ ── THE ORDERING, WHICH IS THE WHOLE TRICK ───────────────────────────────────
17
+
18
+ SUBSCRIBE FIRST, THEN INVOKE. Pub/Sub discards a message published to a topic
19
+ with no subscription, and rt-service creates the tenant's subscription when the
20
+ FIRST socket opens. So a client that invokes and then connects loses everything
21
+ published in between — and on a warm agent that is the entire reply.
22
+
23
+ rt-service's own websocket handler makes the same point in the other direction:
24
+ it watches the tenant BEFORE sending `ready`, because a subscription created
25
+ afterwards can miss the `started` for the message just sent.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from collections.abc import Iterator
32
+ from contextlib import contextmanager
33
+ from typing import Any
34
+
35
+ # ── MOVED TO `elan_sdk`, RE-EXPORTED HERE ────────────────────────────────────
36
+ #
37
+ # `text_of` is the only client code in this repo with a recorded bug and a
38
+ # worked fix — reading the flat `payload["text"]` found nothing on every
39
+ # content event and reported a run that had replied perfectly well as one that
40
+ # "finished without saying anything" — so a second copy of it is exactly the
41
+ # drift `ui/portal/lib/types.ts` already demonstrates. Same for the certifi
42
+ # context, which is now in three places' worth of lessons and one module.
43
+ #
44
+ # RE-EXPORTED RATHER THAN REPLACED AT THE CALL SITES, because
45
+ # `tests/test_elan_cli_invoke.py` imports `_trust` and `text_of` from here and
46
+ # monkeypatches this module's socket — and those tests passing UNCHANGED is
47
+ # what proves the extraction moved nothing.
48
+ from elan_sdk._tls import trust as _trust # noqa: F401
49
+ from elan_sdk.models.events import SHOWN_KINDS as SHOWN # noqa: F401
50
+ from elan_sdk.models.events import TERMINAL_KINDS as TERMINAL # noqa: F401
51
+ from elan_sdk.models.events import author_of, ends_the_run, text_of # noqa: F401
52
+ from elan_sdk.stream import CONTROL
53
+
54
+
55
+ class StreamError(Exception):
56
+ """The socket could not be opened or authenticated."""
57
+
58
+
59
+ @contextmanager
60
+ def watching(api: str, token: str, session_id: str,
61
+ open_socket=None) -> Iterator[Any]:
62
+ """Hold an authenticated, subscribed socket open.
63
+
64
+ A CONTEXT MANAGER BECAUSE THE ORDER IS THE POINT: the caller invokes INSIDE
65
+ the `with`, which is what guarantees the subscription exists before the
66
+ agent can publish anything.
67
+ """
68
+ from elan_agentkit_contracts.tickets import stream_url # noqa: PLC0415
69
+
70
+ base = api.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
71
+ opener = open_socket or _connect
72
+ # THE CREDENTIAL IS ON THE HANDSHAKE (ADR-0163), and `stream_url` is the
73
+ # ONE composer in Python — shared with the SDK.
74
+ #
75
+ # THE TRANSPORTS STAY SEPARATE AND THE PREDICATE DOES NOT. This module is
76
+ # `websockets.sync` and the SDK's is `websockets.asyncio`, which cannot be
77
+ # one implementation; where the credential goes is not transport, and a
78
+ # second copy of it here is exactly the duplication ADR-0116 measured —
79
+ # last time it cost this file a hang on every paused run.
80
+ socket = opener(stream_url(base, token))
81
+ try:
82
+ opening = json.loads(socket.recv())
83
+ if opening.get("type") != "ready":
84
+ raise StreamError(
85
+ f"the event stream refused this token: {opening!r}")
86
+
87
+ socket.send(json.dumps({"type": "subscribe", "session_id": session_id}))
88
+ # WAITED FOR, NOT ASSUMED. `subscribed` is the acknowledgement that
89
+ # this connection is registered for the session; invoking before it
90
+ # arrives reopens the race the whole ordering exists to close.
91
+ while True:
92
+ message = json.loads(socket.recv())
93
+ if message.get("type") == "subscribed":
94
+ break
95
+ if message.get("type") == "error":
96
+ raise StreamError(str(message))
97
+ yield socket
98
+ finally:
99
+ try:
100
+ socket.close()
101
+ except Exception:
102
+ # A socket we could not close cleanly is closed by the server when
103
+ # the process exits. Failing the command over it would be worse.
104
+ pass
105
+
106
+
107
+ def replies(socket, invocation_id: str) -> Iterator[dict]:
108
+ """Events for ONE invocation, until it ends.
109
+
110
+ FILTERED BY INVOCATION because a session can carry several. A socket
111
+ subscribed to the session sees them all, and printing another run's output
112
+ as this one's is worse than printing nothing.
113
+
114
+ THE TWO PREDICATES ARE THE SDK'S AND NOT THIS MODULE'S (ADR-0116), which
115
+ is the whole reason this loop is four lines of filtering and no judgement.
116
+ It owned both once and both had drifted:
117
+
118
+ * it stopped at `kind in TERMINAL`, so A PAUSED RUN NEVER ENDED. A run
119
+ suspended for a human emits two `paused` events and no `finished` at
120
+ all — the pause IS where it stopped — so this blocked in `recv()` for
121
+ ever on a workflow that was sitting there asking a question, and
122
+ `websockets.sync`'s `recv()` has no default timeout to save it.
123
+ `ends_the_run` knows the engine's terminal pause; `TERMINAL` could
124
+ not.
125
+ * its control set was `{pong, subscribed, ready}` against the SDK's
126
+ `{pong, subscribed, ready, error}`. Harmless only by accident — an
127
+ error frame carries no `invocation_id`, so the next line dropped it.
128
+
129
+ A rule each client spells for itself is a rule that drifts, because each
130
+ client's own tests pass against its own copy and nothing compares them.
131
+ """
132
+ while True:
133
+ raw = socket.recv()
134
+ try:
135
+ event = json.loads(raw)
136
+ except ValueError:
137
+ continue
138
+ if not isinstance(event, dict):
139
+ continue
140
+ if event.get("type") in CONTROL:
141
+ continue
142
+ if event.get("invocation_id") != invocation_id:
143
+ continue
144
+ yield event
145
+ if ends_the_run(event):
146
+ return
147
+
148
+
149
+ def _connect(url: str):
150
+ try:
151
+ from websockets.sync.client import connect # noqa: PLC0415
152
+ except ImportError as exc: # pragma: no cover - a packaging fault
153
+ raise StreamError(
154
+ "this CLI was installed without websocket support, so it cannot "
155
+ "watch a run. Reinstall it."
156
+ ) from exc
157
+
158
+ # ONLY FOR `wss://`. Passing a context to a plain ws:// connection is an
159
+ # error rather than a no-op, and local development uses ws://.
160
+ context = _trust() if url.startswith("wss://") else None
161
+ return connect(url, ssl=context, open_timeout=30,
162
+ max_size=4 * 1024 * 1024)