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/env.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`elan env` — the local environment `elan validate` needs.
|
|
2
|
+
|
|
3
|
+
WHY THIS COMMAND EXISTS AT ALL.
|
|
4
|
+
|
|
5
|
+
Checking `elan.yaml` against the Python MANIFESTs means IMPORTING the repo's
|
|
6
|
+
agents, which needs the platform runtime — `agentkit_runtime`, `agentkit_core`
|
|
7
|
+
and whatever ADK pulls in. The CLI deliberately does not carry those: it would
|
|
8
|
+
put a web server and a database driver on the machine for a command that reads
|
|
9
|
+
files. So the runtime goes in a `.venv` beside `elan.yaml`, and this is what
|
|
10
|
+
puts it there.
|
|
11
|
+
|
|
12
|
+
IT USED TO COME FROM A CHECKOUT OF THE PLATFORM MONOREPO, through `--runtime`
|
|
13
|
+
and `$ELAN_RUNTIME_SRC`, because the agentkit packages went from that repo
|
|
14
|
+
straight into the base image and were published to no index — so there was no
|
|
15
|
+
`pip install` that would work. There is now (ADR-0220): one name,
|
|
16
|
+
`elan-agentkit-runtime`, with core and plugins as its dependencies, which is
|
|
17
|
+
the same single `--package` the runtime image syncs.
|
|
18
|
+
|
|
19
|
+
THIS MODULE'S OWN DOCSTRING USED TO PREDICT ITS DELETION — "when they are
|
|
20
|
+
published, this command goes away and the scaffolded repo grows an ordinary
|
|
21
|
+
dependency instead" — and that was wrong about the destination. A scaffolded
|
|
22
|
+
repo deliberately has NO `pyproject.toml`; the one it may grow is for libraries
|
|
23
|
+
BEYOND the runtime, and its own README says "Never put `agentkit-*` in it". That
|
|
24
|
+
file is what the BUILD installs, and the build's image already carries the
|
|
25
|
+
runtime, so declaring it there would install it twice with no guarantee the two
|
|
26
|
+
versions agree. The command stays; only where it resolves from changed.
|
|
27
|
+
|
|
28
|
+
UNPINNED, AND THAT IS A DECISION. Every package here carries its own version, so
|
|
29
|
+
pinning the runtime to the CLI's would couple two numbers meant to move apart.
|
|
30
|
+
The real answer is version negotiation, which this CLI has none of — no
|
|
31
|
+
`--version`, no `User-Agent`, nothing in the handshake — and which is its own
|
|
32
|
+
change.
|
|
33
|
+
|
|
34
|
+
IT STILL DOES NOT TOUCH THE DEPLOY FILE, which remains the part worth being
|
|
35
|
+
careful about: the environment lives in `.venv` and is described nowhere the
|
|
36
|
+
builder reads.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from elan_cli.project import ProjectError, find
|
|
46
|
+
|
|
47
|
+
#: What `elan validate` and `elan adk` import the repo's agents through.
|
|
48
|
+
#:
|
|
49
|
+
#: ONE NAME, NOT THREE. `elan-agentkit-core` and `elan-agentkit-plugins` are its
|
|
50
|
+
#: dependencies, so naming all three here would be three chances to disagree
|
|
51
|
+
#: with the manifest that already relates them.
|
|
52
|
+
RUNTIME_PACKAGE = "elan-agentkit-runtime"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run(start: Path | None = None) -> int:
|
|
56
|
+
try:
|
|
57
|
+
project = find(start)
|
|
58
|
+
except ProjectError as exc:
|
|
59
|
+
print(f"error: {exc}")
|
|
60
|
+
return 2
|
|
61
|
+
|
|
62
|
+
if not shutil.which("uv"):
|
|
63
|
+
print("error: `uv` is not on PATH, and this uses it to build the\n"
|
|
64
|
+
"project's environment. https://docs.astral.sh/uv/")
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
venv = project.root / ".venv"
|
|
68
|
+
python = venv / "bin" / "python"
|
|
69
|
+
print(f"environment {venv}")
|
|
70
|
+
# FLUSHED BECAUSE A SUBPROCESS IS ABOUT TO WRITE TO THE SAME TERMINAL.
|
|
71
|
+
# Python buffers when its output is a pipe and uv writes straight to the
|
|
72
|
+
# descriptor, so without this the heading appears BELOW the wall of
|
|
73
|
+
# package names it is meant to introduce.
|
|
74
|
+
print(f"runtime {RUNTIME_PACKAGE}", flush=True)
|
|
75
|
+
|
|
76
|
+
if not python.exists():
|
|
77
|
+
if _uv(["venv", str(venv)], project.root) != 0:
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
if _uv(["pip", "install", "--python", str(python), RUNTIME_PACKAGE],
|
|
81
|
+
project.root) != 0:
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
print()
|
|
85
|
+
print("ok — `elan validate` will find this automatically.")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _uv(args: list[str], cwd: Path) -> int:
|
|
90
|
+
"""Run uv, letting its own output through.
|
|
91
|
+
|
|
92
|
+
NOT CAPTURED. A resolution failure here is about the developer's machine —
|
|
93
|
+
a Python version, a compiler, a network — and uv says all of that far
|
|
94
|
+
better than a wrapper repeating "install failed" would.
|
|
95
|
+
"""
|
|
96
|
+
return subprocess.run(["uv", *args], cwd=cwd).returncode
|
elan_cli/files.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""`elan files` — the artifacts an agent reads and writes.
|
|
2
|
+
|
|
3
|
+
WHY A CLI SURFACE AT ALL. Three things people actually do from a terminal:
|
|
4
|
+
upload the project's policy document once so every agent can read it, pull a
|
|
5
|
+
file an agent produced while debugging why it looks wrong, and list what a
|
|
6
|
+
conversation has accumulated. The alternative was `curl` with a hand-built
|
|
7
|
+
bearer and a hand-assembled signed PUT — four exact headers, one of which
|
|
8
|
+
pins the byte length.
|
|
9
|
+
|
|
10
|
+
THE BYTES GO STRAIGHT TO STORAGE, NEVER THROUGH THE PLATFORM. `upload` asks the
|
|
11
|
+
platform to authorise one transfer and then PUTs to the URL it gets back; the
|
|
12
|
+
PUT carries NO platform token, because that URL is Google's host and a bearer
|
|
13
|
+
there would be a credential sent to a third party in a request that succeeds.
|
|
14
|
+
|
|
15
|
+
THE SCOPE COMES FROM THE FILENAME, not from a flag, because that is how the
|
|
16
|
+
agent's own `load_artifact` addresses it — so a name that works here works
|
|
17
|
+
unchanged in a tool. `elan_agentkit_contracts.artifacts` is the one place the
|
|
18
|
+
table lives (ADR-0116); nothing here restates it.
|
|
19
|
+
|
|
20
|
+
NO `--tenant`, for the same reason as `elan wallet`: every route derives it from
|
|
21
|
+
the verified token, so a flag would be a value the service ignores.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
from elan_agentkit_contracts.artifacts import (
|
|
30
|
+
MAX_ARTIFACT_BYTES,
|
|
31
|
+
SHARED_SCOPES,
|
|
32
|
+
ArtifactNameRefused,
|
|
33
|
+
scope_of,
|
|
34
|
+
validate_artifact_filename,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from elan_cli.client import Api
|
|
38
|
+
from elan_cli.project import ProjectError, find
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FilesError(Exception):
|
|
42
|
+
"""Phrased for the developer running the command."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _project(api: Api, given: str) -> str:
|
|
46
|
+
"""The project, from the flag or from the elan.yaml you are standing in."""
|
|
47
|
+
if given:
|
|
48
|
+
return given
|
|
49
|
+
try:
|
|
50
|
+
return find().project
|
|
51
|
+
except ProjectError as exc:
|
|
52
|
+
raise FilesError(
|
|
53
|
+
"which project? Run this inside a project directory, or pass "
|
|
54
|
+
"--project."
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _checked(filename: str) -> str:
|
|
59
|
+
try:
|
|
60
|
+
validate_artifact_filename(filename)
|
|
61
|
+
except ArtifactNameRefused as exc:
|
|
62
|
+
raise FilesError(str(exc)) from exc
|
|
63
|
+
return filename
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _common(args, api: Api) -> dict:
|
|
67
|
+
return {"target_slug": args.target, "project": _project(api, args.project),
|
|
68
|
+
"user": getattr(args, "user", "") or ""}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ls(args, api: Api) -> int:
|
|
72
|
+
"""What this conversation can see, prefixes intact."""
|
|
73
|
+
answer = api.request("GET", "/v1/artifacts", params={
|
|
74
|
+
**_common(args, api), "session_id": args.session})
|
|
75
|
+
names = answer.get("artifacts", [])
|
|
76
|
+
if not names:
|
|
77
|
+
print("no files")
|
|
78
|
+
return 0
|
|
79
|
+
# GROUPED BY SCOPE, because the prefix is the only thing distinguishing a
|
|
80
|
+
# project document from a file in this one conversation, and a flat list
|
|
81
|
+
# buries that in punctuation.
|
|
82
|
+
for name in names:
|
|
83
|
+
scope, bare = scope_of(name)
|
|
84
|
+
shared = " (read-only)" if scope in SHARED_SCOPES else ""
|
|
85
|
+
print(f" {scope:<8} {bare}{shared}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get(args, api: Api) -> int:
|
|
90
|
+
"""Download one file.
|
|
91
|
+
|
|
92
|
+
THE PLATFORM ANSWERS WITH A URL AND NOT WITH BYTES, so this follows it —
|
|
93
|
+
and deliberately with a plain client carrying no token, because the URL is
|
|
94
|
+
storage's and the signature is the whole authorisation.
|
|
95
|
+
"""
|
|
96
|
+
_checked(args.filename)
|
|
97
|
+
answer = api.request("GET", "/v1/artifacts/download", params={
|
|
98
|
+
**_common(args, api), "session_id": args.session,
|
|
99
|
+
"filename": args.filename,
|
|
100
|
+
**({"version": args.version} if args.version is not None else {})})
|
|
101
|
+
|
|
102
|
+
destination = Path(args.out) if args.out else Path(
|
|
103
|
+
scope_of(args.filename)[1].rsplit("/", 1)[-1])
|
|
104
|
+
try:
|
|
105
|
+
with httpx.Client(timeout=300.0, follow_redirects=True) as client:
|
|
106
|
+
response = client.get(answer["url"])
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
except httpx.HTTPError as exc:
|
|
109
|
+
raise FilesError(f"could not download {args.filename}: {exc}") from exc
|
|
110
|
+
destination.write_bytes(response.content)
|
|
111
|
+
print(f"{destination} ({len(response.content)} bytes)")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def put(args, api: Api) -> int:
|
|
116
|
+
"""Upload one file to a scope.
|
|
117
|
+
|
|
118
|
+
THE SIZE IS THE AUTHORISATION. It is signed into the URL as an exact range,
|
|
119
|
+
so this reads the file first and declares its real length — a declared
|
|
120
|
+
guess would be refused by storage rather than truncated.
|
|
121
|
+
"""
|
|
122
|
+
source = Path(args.path)
|
|
123
|
+
if not source.is_file():
|
|
124
|
+
raise FilesError(f"{source} is not a file")
|
|
125
|
+
payload = source.read_bytes()
|
|
126
|
+
if not payload:
|
|
127
|
+
raise FilesError(f"{source} is empty; there is nothing to store")
|
|
128
|
+
if len(payload) > MAX_ARTIFACT_BYTES:
|
|
129
|
+
raise FilesError(
|
|
130
|
+
f"{source} is {len(payload)} bytes; the limit is "
|
|
131
|
+
f"{MAX_ARTIFACT_BYTES} ({MAX_ARTIFACT_BYTES // (1024 * 1024)} MiB)")
|
|
132
|
+
|
|
133
|
+
name = _checked(args.name or source.name)
|
|
134
|
+
scope, _ = scope_of(name)
|
|
135
|
+
answer = api.request("POST", "/v1/artifacts/uploads", json={
|
|
136
|
+
**_common(args, api), "session_id": args.session, "filename": name,
|
|
137
|
+
"content_type": args.content_type, "size": len(payload)})
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with httpx.Client(timeout=300.0) as client:
|
|
141
|
+
# EXACTLY THE HEADERS WE WERE GIVEN, and no Authorization. Each one
|
|
142
|
+
# is part of what was signed; adding ours would break the signature
|
|
143
|
+
# and the error would name the signature rather than the header.
|
|
144
|
+
response = client.request(
|
|
145
|
+
answer["method"], answer["url"], content=payload,
|
|
146
|
+
headers=answer["headers"])
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
except httpx.HTTPError as exc:
|
|
149
|
+
raise FilesError(
|
|
150
|
+
f"the bytes did not reach storage: {exc}. The signed URL pins the "
|
|
151
|
+
f"exact size ({len(payload)}) and content type "
|
|
152
|
+
f"({args.content_type}) that were declared."
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
print(f"{name} version {answer['version']} {scope} scope")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def rm(args, api: Api) -> int:
|
|
160
|
+
"""Remove one file and every version of it."""
|
|
161
|
+
_checked(args.filename)
|
|
162
|
+
api.request("DELETE", "/v1/artifacts", params={
|
|
163
|
+
**_common(args, api), "session_id": args.session,
|
|
164
|
+
"filename": args.filename})
|
|
165
|
+
print(f"removed {args.filename}")
|
|
166
|
+
return 0
|
elan_cli/invoke.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""`elan invoke` — send an agent a message and read what it says back.
|
|
2
|
+
|
|
3
|
+
THE FIRST THING SOMEBODY WANTS AFTER A DEPLOY, and until now the only way to
|
|
4
|
+
do it was a Python script run by whoever had a shell on the platform. A deploy
|
|
5
|
+
that succeeds and cannot be exercised is a deploy nobody can believe.
|
|
6
|
+
|
|
7
|
+
IT HOLDS A SOCKET RATHER THAN POLLING THE TRANSCRIPT, and this docstring used
|
|
8
|
+
to say the opposite with a reasoned argument whose premise was false: ADK
|
|
9
|
+
persists a conversation to the AGENT'S OWN database (ADR-0036) while rt-service
|
|
10
|
+
holds the PLATFORM's, so that route answers "nothing here" correctly and for
|
|
11
|
+
ever for every tenant.
|
|
12
|
+
|
|
13
|
+
EVERY RULE ABOUT THE STREAM COMES FROM `elan_sdk` (ADR-0116) — what ends a run,
|
|
14
|
+
what is a control frame, where the text is, who spoke. This file owns the
|
|
15
|
+
PRINTING and nothing else, because the one rule it owned for itself had drifted
|
|
16
|
+
into a hang: it waited for `finished`, which a paused run never sends.
|
|
17
|
+
|
|
18
|
+
THE FIRST REPLY CAN TAKE MINUTES AND THAT IS NOT A BUG. An agent scaled to
|
|
19
|
+
zero needs a KEDA poll, a node, a sandboxed container start and an ADK import
|
|
20
|
+
before it reads its queue. The wait message says so, because silence for two
|
|
21
|
+
minutes reads as a failure.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
|
|
30
|
+
from elan_cli.client import Api, ApiError
|
|
31
|
+
from elan_cli.stream import (
|
|
32
|
+
SHOWN,
|
|
33
|
+
StreamError,
|
|
34
|
+
author_of,
|
|
35
|
+
ends_the_run,
|
|
36
|
+
replies,
|
|
37
|
+
text_of,
|
|
38
|
+
watching,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: Long enough for a cold start with a scale-from-zero and a model call.
|
|
42
|
+
DEFAULT_TIMEOUT = 300.0
|
|
43
|
+
|
|
44
|
+
#: What answers each kind of pause, because THIS COMMAND CANNOT. A pause can
|
|
45
|
+
#: outlast any command — the push handler acks at the pause and resumption
|
|
46
|
+
#: arrives as a new message — which is why resuming is an API call and not a
|
|
47
|
+
#: flag here. There is no `elan resume` subcommand, so every one of these names
|
|
48
|
+
#: something that exists elsewhere, checked against the subparsers and the
|
|
49
|
+
#: routes rather than written from memory.
|
|
50
|
+
ANSWERED_BY = {
|
|
51
|
+
"input": ("a node is waiting for a VALUE — `elan.runtime.resume(id, "
|
|
52
|
+
"approved=True, payload={...})`, or POST /v1/invocations/{id}"
|
|
53
|
+
"/resume"),
|
|
54
|
+
"confirmation": ("a tool is waiting for APPROVAL — the console's approvals "
|
|
55
|
+
"page, or `elan.actions.decide_batch(...)`"),
|
|
56
|
+
"tool": ("a long-running tool is waiting for YOUR backend's RESULT — "
|
|
57
|
+
"`elan.tools.submit(call_id, result={...})`"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(api: Api, target: str, text: str, *, session: str = "",
|
|
62
|
+
project: str = "", timeout: float = DEFAULT_TIMEOUT,
|
|
63
|
+
now=time.monotonic, open_socket=None) -> int:
|
|
64
|
+
"""Send one message and print what comes back. Returns the exit code.
|
|
65
|
+
|
|
66
|
+
THE CLOCK IS INJECTED so a timeout can be reached without waiting for one.
|
|
67
|
+
The `sleep` that used to sit beside it is gone with the polling loop it
|
|
68
|
+
belonged to: nothing here sleeps any more, it blocks on a socket, and a
|
|
69
|
+
parameter no code path reads is a seam that lies about how this works.
|
|
70
|
+
"""
|
|
71
|
+
session_id = session or f"cli-{uuid.uuid4().hex[:12]}"
|
|
72
|
+
|
|
73
|
+
# ── SUBSCRIBE FIRST, THEN INVOKE ────────────────────────────────────
|
|
74
|
+
#
|
|
75
|
+
# Pub/Sub discards a message published to a topic with no subscription,
|
|
76
|
+
# and rt-service creates a tenant's subscription when its FIRST socket
|
|
77
|
+
# opens. Invoking before connecting therefore loses everything published
|
|
78
|
+
# in between — on a warm agent, that is the entire reply.
|
|
79
|
+
#
|
|
80
|
+
# THE TRANSCRIPT CANNOT BE POLLED INSTEAD, which is what this replaced.
|
|
81
|
+
# ADK writes a conversation to the AGENT'S OWN database (ADR-0036) and
|
|
82
|
+
# rt-service holds the PLATFORM's, so that read answers "nothing here"
|
|
83
|
+
# correctly and forever for every tenant.
|
|
84
|
+
try:
|
|
85
|
+
with watching(api.config.api, api.token(), session_id,
|
|
86
|
+
open_socket=open_socket) as socket:
|
|
87
|
+
try:
|
|
88
|
+
started = api.request("POST", "/v1/invocations", json={
|
|
89
|
+
"target_slug": target, "text": text,
|
|
90
|
+
"session_id": session_id, "project": project})
|
|
91
|
+
except ApiError as exc:
|
|
92
|
+
print(f"{exc}", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
invocation = started.get("invocation_id", "")
|
|
96
|
+
print(f"invocation {invocation}")
|
|
97
|
+
print(f"session {session_id}")
|
|
98
|
+
print()
|
|
99
|
+
return _follow(socket, invocation, target, session_id,
|
|
100
|
+
timeout, now)
|
|
101
|
+
except StreamError as exc:
|
|
102
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _follow(socket, invocation: str, target: str, session_id: str,
|
|
107
|
+
timeout: float, now) -> int:
|
|
108
|
+
"""Print what happens until the run ends. Returns the exit code."""
|
|
109
|
+
deadline = now() + timeout
|
|
110
|
+
said = False
|
|
111
|
+
#: True when the run reached a terminal event rather than running out of
|
|
112
|
+
#: time. The difference is the whole message below.
|
|
113
|
+
ended = False
|
|
114
|
+
#: The interrupt kind, when the run STOPPED TO ASK rather than finishing.
|
|
115
|
+
#: A third outcome, and it used to be unreachable: this waited for
|
|
116
|
+
#: `finished`, which a paused run never sends, so it blocked in `recv()`
|
|
117
|
+
#: for ever on a run that was sitting there asking a question (ADR-0116).
|
|
118
|
+
waiting_on = ""
|
|
119
|
+
for event in replies(socket, invocation):
|
|
120
|
+
if ends_the_run(event):
|
|
121
|
+
# ASKED OF THE SDK, which is the only place the rule is spelled —
|
|
122
|
+
# `finished`, `error`, OR the engine's terminal pause.
|
|
123
|
+
ended = True
|
|
124
|
+
if event.get("kind") == "paused":
|
|
125
|
+
waiting_on = str(
|
|
126
|
+
(event.get("payload") or {}).get("interrupt_kind") or "?")
|
|
127
|
+
kind = event.get("kind")
|
|
128
|
+
if kind == "queued":
|
|
129
|
+
# THE ONE EVENT rt-service SENDS ITSELF, because a scaled-to-zero
|
|
130
|
+
# agent has no pod to send it. Saying so is what stops the wait
|
|
131
|
+
# reading as a hang.
|
|
132
|
+
print(" queued — a sleeping agent is scheduled and started "
|
|
133
|
+
"before it reads its queue; the first reply after a quiet "
|
|
134
|
+
"period is the slow one.")
|
|
135
|
+
continue
|
|
136
|
+
if kind not in SHOWN:
|
|
137
|
+
continue
|
|
138
|
+
body = text_of(event)
|
|
139
|
+
if not body:
|
|
140
|
+
continue
|
|
141
|
+
if kind == "error":
|
|
142
|
+
print(f"\nerror: {body}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
# WHO SPOKE, when a run has more than one agent in it. Falls back to
|
|
145
|
+
# the target, which is the only name a single-agent run has.
|
|
146
|
+
who = author_of(event) or target
|
|
147
|
+
print(f"{who}: {body}" if not said else body)
|
|
148
|
+
said = True
|
|
149
|
+
if now() > deadline:
|
|
150
|
+
break
|
|
151
|
+
# ── PAUSED IS NOT FINISHED, AND IT IS NOT SILENCE EITHER ───────────────
|
|
152
|
+
#
|
|
153
|
+
# It is the third outcome, and the one this command cannot act on. Said
|
|
154
|
+
# before the exit code is decided because a run can both answer and then
|
|
155
|
+
# pause — asking a follow-up question is the commonest shape of all — and
|
|
156
|
+
# a note is the right weight for that, where a failure would not be.
|
|
157
|
+
if waiting_on:
|
|
158
|
+
# A NEWER ENGINE MAY PAUSE FOR A REASON THIS CLI HAS NOT HEARD OF, and
|
|
159
|
+
# the honest answer then is the kind's own name plus the read path —
|
|
160
|
+
# not a bare token the reader has to guess at.
|
|
161
|
+
answer = ANSWERED_BY.get(
|
|
162
|
+
waiting_on, f"{waiting_on} — a pause kind this CLI does not know")
|
|
163
|
+
print(f"\n{target} is PAUSED, not finished.\n"
|
|
164
|
+
f" invocation {invocation}\n"
|
|
165
|
+
f" waiting on {answer}\n"
|
|
166
|
+
f"`elan invoke` cannot answer a pause: a pause can outlast any "
|
|
167
|
+
f"command, which is why resuming is an API call. `elan.runs."
|
|
168
|
+
f"get({invocation!r})` reads what it has done so far.",
|
|
169
|
+
file=sys.stderr)
|
|
170
|
+
|
|
171
|
+
if said:
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
# WHICH SILENCE THIS WAS, because they need different reactions and the
|
|
175
|
+
# message used to claim the wrong one. `No reply within 300s` was printed
|
|
176
|
+
# the instant a run ENDED having said nothing — so a command that returned
|
|
177
|
+
# in two seconds reported a five-minute timeout, and the reader went
|
|
178
|
+
# looking for something slow.
|
|
179
|
+
if waiting_on:
|
|
180
|
+
print("It paused without saying anything first, so the question is in "
|
|
181
|
+
"the run rather than above.", file=sys.stderr)
|
|
182
|
+
elif ended:
|
|
183
|
+
print(f"\n{target} finished without saying anything. That is the "
|
|
184
|
+
"agent's own behaviour rather than a timeout — check its logs, "
|
|
185
|
+
"and check it returns content from `build()`.", file=sys.stderr)
|
|
186
|
+
else:
|
|
187
|
+
# NOT `--session` HERE, WHICH IS WHAT THIS USED TO SAY: `--text` is
|
|
188
|
+
# REQUIRED, so "rejoins the same conversation without sending anything
|
|
189
|
+
# new" named a command argparse refuses. Nothing buffers for a closed
|
|
190
|
+
# socket either, so reconnecting would show only what happens NEXT —
|
|
191
|
+
# the read path is the one P2 built for exactly this.
|
|
192
|
+
print(f"\nNo reply within {timeout:.0f}s. The invocation was accepted "
|
|
193
|
+
f"and may still be running — `elan.runs.get({invocation!r})`, or "
|
|
194
|
+
f"GET /v1/runs/{invocation}, reads what it has done so far.",
|
|
195
|
+
file=sys.stderr)
|
|
196
|
+
return 1
|
elan_cli/logs.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""`elan logs` — what a build said, and what a running workload is saying.
|
|
2
|
+
|
|
3
|
+
TWO KINDS OF LOG, AND THE COMMAND USED TO MEAN ONLY ONE. A failed build's
|
|
4
|
+
`logUrl` points at Cloud Build's console in the PLATFORM's project, which an
|
|
5
|
+
external customer cannot open — so the only actionable part of a build failure
|
|
6
|
+
was a link that answered "permission denied". That is `elan logs <deployment>`.
|
|
7
|
+
|
|
8
|
+
Once an agent is RUNNING, the question changes and the old command could not
|
|
9
|
+
answer it: a developer whose agent is misbehaving wants its stdout, and got a
|
|
10
|
+
build transcript. `elan logs --workload <name>` is that, and it reads the same
|
|
11
|
+
route the console does.
|
|
12
|
+
|
|
13
|
+
THE FLAG RATHER THAN A SECOND COMMAND, because "logs" is the word a person
|
|
14
|
+
reaches for either way. A separate `elan workload logs` would mean guessing
|
|
15
|
+
which one they meant every time they typed the shorter thing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from elan_cli.client import Api, ApiError
|
|
23
|
+
|
|
24
|
+
#: Printed when `--tail` is given without a number.
|
|
25
|
+
DEFAULT_TAIL = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(api: Api, deployment_id: str, *, tail: int = 0) -> int:
|
|
29
|
+
try:
|
|
30
|
+
payload = api.request("GET", f"/v1/deployments/{deployment_id}/logs")
|
|
31
|
+
except ApiError as exc:
|
|
32
|
+
print(f"{exc}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
note = payload.get("note")
|
|
36
|
+
if note:
|
|
37
|
+
# NOT AN ERROR. A deploy refused at validate never started a build, and
|
|
38
|
+
# saying so beats printing an empty log that reads as a fetch failure.
|
|
39
|
+
print(note)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
log = payload.get("log") or ""
|
|
43
|
+
if not log.strip():
|
|
44
|
+
print("The build produced no output, which usually means it was "
|
|
45
|
+
"cancelled before it started.")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
lines = log.splitlines()
|
|
49
|
+
if tail and len(lines) > tail:
|
|
50
|
+
print(f"[showing the last {tail} of {len(lines)} lines]")
|
|
51
|
+
lines = lines[-tail:]
|
|
52
|
+
print("\n".join(lines))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def workload(api: Api, name: str, *, project: str, minutes: int = 60,
|
|
57
|
+
probes: bool = False, tail: int = 0) -> int:
|
|
58
|
+
"""What a running workload has said.
|
|
59
|
+
|
|
60
|
+
THE PROJECT IS REQUIRED and the service refuses without it. An agent slug is
|
|
61
|
+
unique within a PROJECT, not within a tenant, so two projects may both run
|
|
62
|
+
`worker` — and guessing which would be a coin toss over whose log to print.
|
|
63
|
+
"""
|
|
64
|
+
if not project:
|
|
65
|
+
print("Name the project this workload belongs to: `--project <slug>`.\n"
|
|
66
|
+
"A workload name is unique within a project, not within a tenant.",
|
|
67
|
+
file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
query = f"?project={project}&since_minutes={minutes}"
|
|
71
|
+
if probes:
|
|
72
|
+
query += "&probes=true"
|
|
73
|
+
try:
|
|
74
|
+
payload = api.request("GET", f"/v1/workloads/{name}/logs{query}")
|
|
75
|
+
except ApiError as exc:
|
|
76
|
+
print(f"{exc}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
log = payload.get("log") or ""
|
|
80
|
+
if not log.strip():
|
|
81
|
+
# SILENCE AND A WRONG NAME LOOK IDENTICAL FROM THE SERVICE, so the name
|
|
82
|
+
# is checked HERE before silence is reported as normal.
|
|
83
|
+
#
|
|
84
|
+
# The route deliberately performs no lookup: a namespace composed from
|
|
85
|
+
# the caller's own token IS the ownership check, and reading the config
|
|
86
|
+
# repo to confirm a name would take `deployed()`'s lock and serialise
|
|
87
|
+
# every deploy behind an open browser tab. That reasoning is sound and
|
|
88
|
+
# is why this belongs in the client.
|
|
89
|
+
#
|
|
90
|
+
# A DEVELOPER LOST TIME TO EXACTLY THIS: they passed the POD name,
|
|
91
|
+
# `ag-fin-modules`, where the WORKLOAD name `fin-modules` was wanted,
|
|
92
|
+
# and got "Nothing in the last 60 minutes" — which reads as a quiet
|
|
93
|
+
# agent, not a bad argument. `DeploySpec.workloads()` is the one place
|
|
94
|
+
# the shape of the fleet is decided, and the CLI already has the spec.
|
|
95
|
+
if problem := _unknown_workload(name, project):
|
|
96
|
+
print(problem, file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
# NOT AN ERROR, AND THIS IS THE COMMON CASE. A workload with no queue
|
|
99
|
+
# scales to zero and says nothing, so silence is the normal state rather
|
|
100
|
+
# than a failure to fetch — the service says which window it searched.
|
|
101
|
+
print(payload.get("note")
|
|
102
|
+
or f"Nothing in the last {minutes} minutes.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
lines = log.splitlines()
|
|
106
|
+
if tail and len(lines) > tail:
|
|
107
|
+
print(f"[showing the last {tail} of {len(lines)} lines]")
|
|
108
|
+
lines = lines[-tail:]
|
|
109
|
+
print("\n".join(lines))
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _unknown_workload(name: str, project: str) -> str:
|
|
114
|
+
"""A message naming the real workloads, or "" when nothing can be said.
|
|
115
|
+
|
|
116
|
+
SILENT WHEN THERE IS NO PROJECT TO READ. `elan logs` is legitimately run
|
|
117
|
+
from outside a checkout — against an environment somebody else deployed —
|
|
118
|
+
and refusing there would turn a working command into an error over a check
|
|
119
|
+
that is only ever a convenience.
|
|
120
|
+
|
|
121
|
+
AND SILENT WHEN THE CHECKOUT IS A DIFFERENT PROJECT, which is the trap this
|
|
122
|
+
walked into on its first run: `project.find()` searches UPWARD, so asking
|
|
123
|
+
about `--project other` from inside any checkout validated the name against
|
|
124
|
+
whatever `elan.yaml` happened to be above the working directory. The
|
|
125
|
+
platform's own suite caught it, because the repo has an `elan.yaml` of its
|
|
126
|
+
own and every existing test runs inside it.
|
|
127
|
+
"""
|
|
128
|
+
from elan_cli import project as project_mod # noqa: PLC0415
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
spec = project_mod.find().spec
|
|
132
|
+
except Exception:
|
|
133
|
+
return ""
|
|
134
|
+
if spec.project != project:
|
|
135
|
+
return ""
|
|
136
|
+
known = spec.workloads()
|
|
137
|
+
if name in known:
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
lines = [f"No workload called {name!r} in this project."]
|
|
141
|
+
# THE POD PREFIX IS THE MISTAKE THAT HAPPENS, so it is named rather than
|
|
142
|
+
# left to the list below to imply.
|
|
143
|
+
if name.startswith("ag-") and name[3:] in known:
|
|
144
|
+
lines.append(f"\n `ag-` is the POD's name in the cluster; the workload "
|
|
145
|
+
f"is {name[3:]!r}.")
|
|
146
|
+
lines.append("\n this project's workloads:")
|
|
147
|
+
for workload, members in sorted(known.items()):
|
|
148
|
+
lines.append(f" {workload} ({', '.join(sorted(members))})")
|
|
149
|
+
return "\n".join(lines)
|