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/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)