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/__init__.py +6 -0
- elan_cli/adk.py +197 -0
- elan_cli/auth.py +391 -0
- elan_cli/client.py +115 -0
- elan_cli/config.py +139 -0
- elan_cli/deploy.py +247 -0
- elan_cli/destroy.py +77 -0
- elan_cli/doctor.py +63 -0
- elan_cli/env.py +96 -0
- elan_cli/files.py +166 -0
- elan_cli/invoke.py +196 -0
- elan_cli/logs.py +149 -0
- elan_cli/main.py +664 -0
- elan_cli/manifests.py +124 -0
- elan_cli/project.py +137 -0
- elan_cli/projects.py +72 -0
- elan_cli/rollback.py +68 -0
- elan_cli/scaffold.py +408 -0
- elan_cli/secret.py +105 -0
- elan_cli/stream.py +162 -0
- elan_cli/tenant.py +59 -0
- elan_cli/validate.py +91 -0
- elan_cli/wallet.py +213 -0
- elan_cli-0.1.0.dist-info/METADATA +96 -0
- elan_cli-0.1.0.dist-info/RECORD +28 -0
- elan_cli-0.1.0.dist-info/WHEEL +4 -0
- elan_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elan_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|