sagex-cli 1.0.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.
- sagex/__init__.py +3 -0
- sagex/__main__.py +2907 -0
- sagex/_env.py +7 -0
- sagex/api/__init__.py +3 -0
- sagex/api/client.py +195 -0
- sagex/api/resources.py +763 -0
- sagex/api/store.py +52 -0
- sagex/app.py +378 -0
- sagex/app.tcss +63 -0
- sagex/config.py +88 -0
- sagex/copier.py +277 -0
- sagex/formatting.py +126 -0
- sagex/pusher.py +350 -0
- sagex/render.py +1046 -0
- sagex/runner.py +198 -0
- sagex/runview.py +247 -0
- sagex/shell.py +231 -0
- sagex/widgets/__init__.py +1 -0
- sagex/widgets/activity.py +56 -0
- sagex/widgets/message.py +78 -0
- sagex/widgets/prompt_input.py +54 -0
- sagex/widgets/run_graph.py +58 -0
- sagex_cli-1.0.0.dist-info/METADATA +210 -0
- sagex_cli-1.0.0.dist-info/RECORD +28 -0
- sagex_cli-1.0.0.dist-info/WHEEL +5 -0
- sagex_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sagex_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- sagex_cli-1.0.0.dist-info/top_level.txt +1 -0
sagex/api/resources.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Fetch and normalize Autosage resources into display-ready shapes.
|
|
2
|
+
|
|
3
|
+
All backend-shape knowledge (envelope quirks, field names, pagination) lives here,
|
|
4
|
+
so the UI code just asks for "the list of workflow names" and gets a clean list.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from sagex.api.client import ApiClient, ApiError
|
|
10
|
+
from sagex.api.client import waiting as client_waiting
|
|
11
|
+
from sagex.formatting import relative_time, truncate_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _as_list(data) -> list:
|
|
15
|
+
"""List endpoints may return a plain list OR a paginated {results: [...]} dict."""
|
|
16
|
+
if isinstance(data, dict):
|
|
17
|
+
return data.get("results", [])
|
|
18
|
+
return data or []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_workflows(client: ApiClient) -> list[str]:
|
|
22
|
+
"""Return workflow display names (trimmed)."""
|
|
23
|
+
data = client.get("/api/workflows/")
|
|
24
|
+
return [truncate_name(w.get("name") or "(unnamed)") for w in _as_list(data)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_scripts(client: ApiClient) -> list[str]:
|
|
28
|
+
"""Return script file names (trimmed)."""
|
|
29
|
+
data = client.get("/api/scripts/")
|
|
30
|
+
return [truncate_name(s.get("name") or "(unnamed)") for s in _as_list(data)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_vault_resources(client: ApiClient) -> list[str]:
|
|
34
|
+
"""Return vault items — servers and credentials — labeled by kind.
|
|
35
|
+
|
|
36
|
+
Only the name is trimmed; the '(server)'/'(credential)' kind is kept intact.
|
|
37
|
+
"""
|
|
38
|
+
servers = _as_list(client.get("/api/vault/servers/"))
|
|
39
|
+
creds = _as_list(client.get("/api/vault/credentials/"))
|
|
40
|
+
labels = [f"{truncate_name(s.get('name') or '(unnamed)')} (server)" for s in servers]
|
|
41
|
+
labels += [f"{truncate_name(c.get('name') or '(unnamed)')} (credential)" for c in creds]
|
|
42
|
+
return labels
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_triggers(client: ApiClient) -> list[tuple[str, str, bool]]:
|
|
46
|
+
"""Return configured triggers as (workflow_name, detail, is_active).
|
|
47
|
+
|
|
48
|
+
From the aggregate /api/triggers/ endpoint (one call); data is
|
|
49
|
+
{"http_triggers": [...], "schedule_triggers": [...]}. `detail` is just the
|
|
50
|
+
trigger TYPE — "scheduler" or "http" — kept short on purpose; the cron/URL and
|
|
51
|
+
full details will come from a command later. The endpoint only returns
|
|
52
|
+
*configured* triggers, which is what we want.
|
|
53
|
+
"""
|
|
54
|
+
data = client.get("/api/triggers/") or {}
|
|
55
|
+
out: list[tuple[str, str, bool]] = []
|
|
56
|
+
for t in data.get("schedule_triggers", []):
|
|
57
|
+
name = t.get("workflow_name") or "(workflow)"
|
|
58
|
+
out.append((name, "scheduler", bool(t.get("is_active"))))
|
|
59
|
+
for t in data.get("http_triggers", []):
|
|
60
|
+
name = t.get("workflow_name") or "(workflow)"
|
|
61
|
+
out.append((name, "http", bool(t.get("is_active"))))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_recent_runs(client: ApiClient, limit: int = 5) -> list[tuple[str, str, str]]:
|
|
66
|
+
"""Return the most recent workflow runs as (status, workflow_name, relative_time)."""
|
|
67
|
+
runs = _as_list(client.get("/api/execution-engine/workflows/runs/"))
|
|
68
|
+
runs.sort(key=lambda r: r.get("created_at") or "", reverse=True) # newest first
|
|
69
|
+
return [
|
|
70
|
+
(
|
|
71
|
+
r.get("status") or "unknown",
|
|
72
|
+
r.get("workflow_name") or "(workflow)",
|
|
73
|
+
relative_time(r.get("created_at")),
|
|
74
|
+
)
|
|
75
|
+
for r in runs[:limit]
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Single-resource lookup ("show" / "copy"): resolve a name-or-id reference to
|
|
81
|
+
# one record, then fetch its full detail. Shared by the CLI and (later) the TUI.
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ResourceNotFound(Exception):
|
|
86
|
+
"""No resource matched the given reference."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, kind: str, ref: str) -> None:
|
|
89
|
+
super().__init__(f"No {kind} matching '{ref}'.")
|
|
90
|
+
self.kind = kind
|
|
91
|
+
self.ref = ref
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AmbiguousResource(Exception):
|
|
95
|
+
"""A name matched more than one resource — the caller must pick by id.
|
|
96
|
+
|
|
97
|
+
`matches` is a list of {id, name, hint} dicts for display.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, kind: str, ref: str, matches: list[dict]) -> None:
|
|
101
|
+
super().__init__(f"Multiple {kind}s match '{ref}'.")
|
|
102
|
+
self.kind = kind
|
|
103
|
+
self.ref = ref
|
|
104
|
+
self.matches = matches
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _match(items: list[dict], ref: str, *, id_key: str = "id", name_key: str = "name") -> list[dict]:
|
|
108
|
+
"""Find items by EXACT id first (fast, unambiguous), else case-insensitive name.
|
|
109
|
+
|
|
110
|
+
Returns every name match so the caller can detect ambiguity (len > 1).
|
|
111
|
+
"""
|
|
112
|
+
for it in items: # exact id wins outright
|
|
113
|
+
if str(it.get(id_key)) == ref:
|
|
114
|
+
return [it]
|
|
115
|
+
ref_l = ref.strip().lower()
|
|
116
|
+
return [it for it in items if str(it.get(name_key) or "").lower() == ref_l]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_workflow(client: ApiClient, ref: str) -> dict:
|
|
120
|
+
"""Resolve a workflow name-or-id and return its FULL detail (incl. nodes/edges).
|
|
121
|
+
|
|
122
|
+
The list endpoint strips the graph; only the detail endpoint returns it.
|
|
123
|
+
"""
|
|
124
|
+
items = _as_list(client.get("/api/workflows/"))
|
|
125
|
+
hits = _match(items, ref)
|
|
126
|
+
if not hits:
|
|
127
|
+
raise ResourceNotFound("workflow", ref)
|
|
128
|
+
if len(hits) > 1:
|
|
129
|
+
raise AmbiguousResource("workflow", ref, [
|
|
130
|
+
{"id": w.get("id"), "name": w.get("name") or "(unnamed)",
|
|
131
|
+
"hint": f"modified {relative_time(w.get('modified_at'))}"}
|
|
132
|
+
for w in hits
|
|
133
|
+
])
|
|
134
|
+
return client.get(f"/api/workflows/{hits[0]['id']}/")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_workflow_detail(client: ApiClient, workflow_id) -> dict:
|
|
138
|
+
"""Fetch a workflow's full detail (nodes + edges) by id, skipping name resolution."""
|
|
139
|
+
return client.get(f"/api/workflows/{workflow_id}/")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_workflow(client: ApiClient, payload: dict) -> dict:
|
|
143
|
+
"""POST a new workflow; returns the created record (with its new id)."""
|
|
144
|
+
return client.post("/api/workflows/", json=payload)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def update_workflow(client: ApiClient, workflow_id, payload: dict) -> dict:
|
|
148
|
+
"""PUT a full workflow update (own workflows only); returns the updated record."""
|
|
149
|
+
return client.put(f"/api/workflows/{workflow_id}/", json=payload)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def rename_workflow(client: ApiClient, workflow_id, new_name: str) -> dict:
|
|
153
|
+
"""Rename a workflow via a partial PATCH (leaves nodes/edges untouched)."""
|
|
154
|
+
return client.patch(f"/api/workflows/{workflow_id}/", json={"name": new_name})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def pick_script(items: list[dict], ref: str) -> dict:
|
|
158
|
+
"""Match a script by id or name in an already-fetched list (no API call)."""
|
|
159
|
+
hits = _match(items, ref)
|
|
160
|
+
if not hits:
|
|
161
|
+
raise ResourceNotFound("script", ref)
|
|
162
|
+
if len(hits) > 1:
|
|
163
|
+
raise AmbiguousResource("script", ref, [
|
|
164
|
+
{"id": s.get("id"), "name": s.get("name") or "(unnamed)",
|
|
165
|
+
"hint": f"v{s.get('version')} · updated {relative_time(s.get('updated_at'))}"}
|
|
166
|
+
for s in hits
|
|
167
|
+
])
|
|
168
|
+
return hits[0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_script(client: ApiClient, ref: str) -> dict:
|
|
172
|
+
"""Resolve a script name-or-id and return its metadata record (no code body).
|
|
173
|
+
|
|
174
|
+
Script ids are ints (unlike workflows' UUIDs); the code lives in a separate
|
|
175
|
+
content endpoint — see `get_script_content`.
|
|
176
|
+
"""
|
|
177
|
+
hit = pick_script(_as_list(client.get("/api/scripts/")), ref)
|
|
178
|
+
return client.get(f"/api/scripts/{hit['id']}/")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_script_content(client: ApiClient, script_id) -> dict:
|
|
182
|
+
"""Fetch a script's raw code: {id, name, content, content_type, version}."""
|
|
183
|
+
return client.get(f"/api/scripts/{script_id}/content/")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def rename_script(client: ApiClient, script_id, new_name: str) -> dict:
|
|
187
|
+
"""Rename a script via its dedicated endpoint. `new_name` is the bare stem (no
|
|
188
|
+
extension — the server keeps the original one). Returns the updated record."""
|
|
189
|
+
return client.post(f"/api/scripts/{script_id}/rename/", json={"new_name": new_name})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def find_script_by_name(client: ApiClient, name: str) -> dict | None:
|
|
193
|
+
"""Return the script whose `name` (filename incl. extension) matches, else None.
|
|
194
|
+
|
|
195
|
+
Scripts are unique per (owner, pathname) and an API key scopes to one owner, so
|
|
196
|
+
at most one script can match a given filename. Used by `push script` to decide
|
|
197
|
+
create-vs-update without an upsert endpoint.
|
|
198
|
+
"""
|
|
199
|
+
name_l = name.strip().lower()
|
|
200
|
+
for it in _as_list(client.get("/api/scripts/")):
|
|
201
|
+
if str(it.get("name") or "").lower() == name_l:
|
|
202
|
+
return it
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def create_script(client: ApiClient, payload: dict) -> dict:
|
|
207
|
+
"""POST a new script ({name, language, content}); returns the created record."""
|
|
208
|
+
return client.post("/api/scripts/", json=payload)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def update_script(client: ApiClient, script_id, content: str) -> dict:
|
|
212
|
+
"""POST new content to an existing script (bumps its version); returns the record."""
|
|
213
|
+
return client.post(f"/api/scripts/{script_id}/update/", json={"content": content})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def run_script_stream(client: ApiClient, payload: dict):
|
|
217
|
+
"""Start a one-shot script run; yields its live (event, data) SSE frames.
|
|
218
|
+
|
|
219
|
+
Payload: {script_details: {script_id, script_name, pathname},
|
|
220
|
+
vault_details: {vault_id, server_id, credential_id}, inputs: {NAME: value}}.
|
|
221
|
+
The server applies no stored defaults, so `inputs` must carry every variable.
|
|
222
|
+
"""
|
|
223
|
+
return client.stream("POST", "/api/execution-engine/run/", json=payload)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stop_script_execution(client: ApiClient, execution_id) -> None:
|
|
227
|
+
"""Signal a script execution to terminate (only valid while it is 'running')."""
|
|
228
|
+
client.post(f"/api/execution-engine/{execution_id}/stop/")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def resolve_run(client: ApiClient, ref: str) -> dict:
|
|
232
|
+
"""Resolve a run by full id, or by an id PREFIX (runs have no names).
|
|
233
|
+
|
|
234
|
+
A full UUID is fetched directly; a shorter prefix is matched against the
|
|
235
|
+
recent-runs list (handy for pasting the first few characters).
|
|
236
|
+
"""
|
|
237
|
+
ref = ref.strip()
|
|
238
|
+
if len(ref) == 36 and ref.count("-") == 4: # looks like a full UUID
|
|
239
|
+
try:
|
|
240
|
+
return client.get(f"/api/execution-engine/workflows/runs/{ref}/")
|
|
241
|
+
except ApiError as exc:
|
|
242
|
+
if exc.status == 404:
|
|
243
|
+
raise ResourceNotFound("run", ref)
|
|
244
|
+
raise
|
|
245
|
+
runs = _as_list(client.get("/api/execution-engine/workflows/runs/"))
|
|
246
|
+
hits = [r for r in runs if str(r.get("id", "")).startswith(ref)]
|
|
247
|
+
if not hits:
|
|
248
|
+
raise ResourceNotFound("run", ref)
|
|
249
|
+
if len(hits) > 1:
|
|
250
|
+
raise AmbiguousResource("run", ref, [
|
|
251
|
+
{"id": r.get("id"), "name": r.get("workflow_name") or "(workflow)",
|
|
252
|
+
"hint": f"{r.get('status')} · {relative_time(r.get('created_at'))}"}
|
|
253
|
+
for r in hits
|
|
254
|
+
])
|
|
255
|
+
return client.get(f"/api/execution-engine/workflows/runs/{hits[0]['id']}/")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_run_nodes(client: ApiClient, run_id) -> list[dict]:
|
|
259
|
+
"""Per-node results for a run, each with signed log URLs + a `logs_expired` flag."""
|
|
260
|
+
return _as_list(client.get(f"/api/execution-engine/workflows/runs/{run_id}/nodes/"))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_workflow_run(client: ApiClient, run_id) -> dict:
|
|
264
|
+
"""A run's record: status, error_message, started_at/finished_at, inputs (masked)."""
|
|
265
|
+
return client.get(f"/api/execution-engine/workflows/runs/{run_id}/")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def trigger_workflow_run(client: ApiClient, workflow_id, payload: dict) -> dict:
|
|
269
|
+
"""Queue an on-demand (manual) run; returns {workflow_run_id, status}.
|
|
270
|
+
|
|
271
|
+
Payload: {inputs: {param_id: value}, send_email, user_email}. Parameters left out
|
|
272
|
+
of `inputs` keep the value saved in the workflow. The server validates the graph
|
|
273
|
+
and bindings first (400 with a message if they're incomplete).
|
|
274
|
+
"""
|
|
275
|
+
return client.post(f"/api/execution-engine/workflows/{workflow_id}/run/", json=payload)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def stream_workflow_run(client: ApiClient, run_id):
|
|
279
|
+
"""Yield a run's live (event, data) SSE frames.
|
|
280
|
+
|
|
281
|
+
Live only: events published before this connects are not replayed, and an
|
|
282
|
+
already-finished run yields just its final status + done.
|
|
283
|
+
"""
|
|
284
|
+
return client.stream("GET", f"/api/execution-engine/workflows/runs/{run_id}/stream/")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cancel_workflow_run(client: ApiClient, run_id) -> None:
|
|
288
|
+
"""Cancel a queued/running run. The server publishes no 'done' event for it."""
|
|
289
|
+
client.post(f"/api/execution-engine/workflows/runs/{run_id}/cancel/")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def fetch_log_text(url: str) -> str:
|
|
293
|
+
"""Download a signed GCS log URL directly (no API key / envelope) and return text.
|
|
294
|
+
|
|
295
|
+
The `*_signed_url` fields point straight at Google Cloud Storage, so this is a
|
|
296
|
+
plain GET — not routed through ApiClient.
|
|
297
|
+
"""
|
|
298
|
+
if not url:
|
|
299
|
+
return ""
|
|
300
|
+
# Never put `exc` in the message: httpx embeds the full signed URL in it, and that
|
|
301
|
+
# URL is a working credential for the log until it expires.
|
|
302
|
+
try:
|
|
303
|
+
with client_waiting("GET"):
|
|
304
|
+
resp = httpx.get(url, timeout=30.0)
|
|
305
|
+
except httpx.HTTPError:
|
|
306
|
+
raise ApiError("couldn't download log (network error)")
|
|
307
|
+
if resp.status_code >= 400:
|
|
308
|
+
raise ApiError(f"couldn't download log (HTTP {resp.status_code})")
|
|
309
|
+
return resp.text
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def resolve_trigger(client: ApiClient, ref: str) -> tuple[str, dict]:
|
|
313
|
+
"""Resolve a trigger by id or workflow name; return (kind, detail).
|
|
314
|
+
|
|
315
|
+
`kind` is "http" or "schedule". Detail comes from the richer per-node endpoint
|
|
316
|
+
(adds rotated_at / last_run_id), merged with the aggregate item so we keep
|
|
317
|
+
workflow_name. Only ever exposes `secret_last4`, never a full secret.
|
|
318
|
+
"""
|
|
319
|
+
data = client.get("/api/triggers/") or {}
|
|
320
|
+
items = [{**t, "_kind": "http"} for t in data.get("http_triggers", [])]
|
|
321
|
+
items += [{**t, "_kind": "schedule"} for t in data.get("schedule_triggers", [])]
|
|
322
|
+
|
|
323
|
+
hits = _match(items, ref, name_key="workflow_name")
|
|
324
|
+
if not hits:
|
|
325
|
+
raise ResourceNotFound("trigger", ref)
|
|
326
|
+
if len(hits) > 1:
|
|
327
|
+
raise AmbiguousResource("trigger", ref, [
|
|
328
|
+
{"id": t.get("id"), "name": f"{t.get('workflow_name')} ({t['_kind']})",
|
|
329
|
+
"hint": f"node {t.get('node_id')}"}
|
|
330
|
+
for t in hits
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
t = hits[0]
|
|
334
|
+
kind = t["_kind"]
|
|
335
|
+
path = f"/api/workflows/{t.get('workflow_id')}/triggers/{kind}/{t.get('node_id')}/"
|
|
336
|
+
try:
|
|
337
|
+
detail = client.get(path) or {}
|
|
338
|
+
except ApiError:
|
|
339
|
+
detail = {} # fall back to the aggregate item
|
|
340
|
+
return kind, {**t, **detail} # detail wins; keeps workflow_name
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# Trigger writes. Triggers bind to a workflow's trigger NODE by node id; HTTP
|
|
345
|
+
# create/regenerate return the plaintext secret ONCE. Enable/disable is a global
|
|
346
|
+
# PATCH keyed by the trigger's UUID (not by node).
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def find_trigger_nodes(workflow: dict, dtype: str) -> list[dict]:
|
|
351
|
+
"""Trigger nodes (outer type 'trigger') whose data.type == dtype ('http'/'schedule')."""
|
|
352
|
+
out = []
|
|
353
|
+
for n in workflow.get("nodes") or []:
|
|
354
|
+
if n.get("type") == "trigger":
|
|
355
|
+
data = n.get("data") if isinstance(n.get("data"), dict) else {}
|
|
356
|
+
if data.get("type") == dtype:
|
|
357
|
+
out.append(n)
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def create_http_trigger(client: ApiClient, workflow_id, node_id: str) -> dict:
|
|
362
|
+
"""Create-or-ROTATE an HTTP trigger for a node. Returns data incl. the plaintext
|
|
363
|
+
`secret` (shown once) and `trigger_url`. Re-running invalidates the old secret."""
|
|
364
|
+
return client.post(f"/api/workflows/{workflow_id}/triggers/http/", json={"node_id": node_id})
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def get_http_trigger(client: ApiClient, workflow_id, node_id: str) -> dict:
|
|
368
|
+
"""Fetch an HTTP trigger's detail (no secret, only secret_last4). 404 if none."""
|
|
369
|
+
return client.get(f"/api/workflows/{workflow_id}/triggers/http/{node_id}/")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def regenerate_http_trigger(client: ApiClient, workflow_id, node_id: str) -> dict:
|
|
373
|
+
"""Rotate an HTTP trigger's secret (same URL). Returns the new plaintext `secret` once."""
|
|
374
|
+
return client.post(f"/api/workflows/{workflow_id}/triggers/http/{node_id}/regenerate/")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def upsert_schedule_trigger(client: ApiClient, workflow_id, node_id: str, cron: str) -> dict:
|
|
378
|
+
"""Create-or-update a schedule trigger (5-field cron, UTC). Returns its record."""
|
|
379
|
+
return client.post(
|
|
380
|
+
f"/api/workflows/{workflow_id}/triggers/schedule/",
|
|
381
|
+
json={"node_id": node_id, "cron_expression": cron},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def http_trigger_path(trigger: dict) -> str | None:
|
|
386
|
+
"""The webhook's path (/api/execution-engine/triggers/http/<token>/) from a
|
|
387
|
+
trigger record's `trigger_url`. The URL's host is whatever the server saw, so
|
|
388
|
+
callers join this path onto their own base URL instead."""
|
|
389
|
+
url = str(trigger.get("trigger_url") or "")
|
|
390
|
+
marker = "/api/execution-engine/triggers/http/"
|
|
391
|
+
if marker not in url:
|
|
392
|
+
return None
|
|
393
|
+
token = url.split(marker, 1)[1].strip("/").split("/")[0]
|
|
394
|
+
return f"{marker}{token}/" if token else None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def fire_http_trigger(client: ApiClient, path: str, secret: str, idempotency_key: str,
|
|
398
|
+
inputs: dict) -> dict:
|
|
399
|
+
"""Call an HTTP trigger exactly as an outside caller would: no API key, just the
|
|
400
|
+
trigger secret + an Idempotency-Key. Returns {workflow_run_id, status,
|
|
401
|
+
idempotent, polling_url}; a reused key returns the original run (idempotent=True).
|
|
402
|
+
"""
|
|
403
|
+
caller = ApiClient(client.base_url, None)
|
|
404
|
+
return caller.post(path, json={"inputs": inputs}, headers={
|
|
405
|
+
"X-Trigger-Secret": secret,
|
|
406
|
+
"Idempotency-Key": idempotency_key,
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def http_trigger_poll_path(url: str) -> tuple[str, str] | None:
|
|
411
|
+
"""(path, run_id) from a polling URL (…/triggers/http/<token>/runs/<run_id>/), or
|
|
412
|
+
None. As with firing, only the path is used — the host is whatever Django saw."""
|
|
413
|
+
marker = "/api/execution-engine/triggers/http/"
|
|
414
|
+
if marker not in url:
|
|
415
|
+
return None
|
|
416
|
+
parts = url.split(marker, 1)[1].split("?")[0].strip("/").split("/")
|
|
417
|
+
if len(parts) < 3 or parts[1] != "runs" or not parts[0] or not parts[2]:
|
|
418
|
+
return None
|
|
419
|
+
return f"{marker}{parts[0]}/runs/{parts[2]}/", parts[2]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def resolve_http_trigger_run(client: ApiClient, ref: str, run: str | None = None) -> dict:
|
|
423
|
+
"""One owner-only call: a workflow name, trigger id or run id (or the start of one)
|
|
424
|
+
→ the HTTP trigger run to poll. `ref` alone means: a run → the trigger that started
|
|
425
|
+
it; a trigger/workflow → its latest webhook run. With `run`, ref names the trigger.
|
|
426
|
+
|
|
427
|
+
Returns {trigger, run, polling_path, workflow (graph shape only)}. Raises
|
|
428
|
+
AmbiguousResource when a name or prefix matches several; other failures raise
|
|
429
|
+
ApiError whose `errors["code"]` says why (not_webhook_run, no_runs_yet, …).
|
|
430
|
+
"""
|
|
431
|
+
params = {"ref": ref, **({"run": run} if run else {})}
|
|
432
|
+
try:
|
|
433
|
+
return client.get("/api/triggers/http/resolve/", params=params)
|
|
434
|
+
except ApiError as exc:
|
|
435
|
+
errors = exc.errors if isinstance(exc.errors, dict) else {}
|
|
436
|
+
if exc.status == 409 and errors.get("matches"):
|
|
437
|
+
raise AmbiguousResource(errors.get("kind") or "match", run or ref, errors["matches"])
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def poll_http_trigger_run(client: ApiClient, path: str, secret: str) -> dict:
|
|
442
|
+
"""Read a run through the trigger's public polling endpoint, as a webhook caller
|
|
443
|
+
would (trigger secret only, no API key). Returns {workflow_run_id, status,
|
|
444
|
+
error_message, started_at, finished_at, nodes: [...]} — no logs."""
|
|
445
|
+
return ApiClient(client.base_url, None).get(path, headers={"X-Trigger-Secret": secret})
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def update_workflow_nodes(client: ApiClient, workflow_id, nodes: list) -> dict:
|
|
449
|
+
"""Replace a workflow's nodes via a partial PATCH (edges and name untouched)."""
|
|
450
|
+
return client.patch(f"/api/workflows/{workflow_id}/", json={"nodes": nodes})
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def get_schedule_trigger(client: ApiClient, workflow_id, node_id: str) -> dict:
|
|
454
|
+
"""A schedule's detail: cron, next_runs (the scheduler's own next fire times),
|
|
455
|
+
last_run_id / last_run_status, last_error."""
|
|
456
|
+
return client.get(f"/api/workflows/{workflow_id}/triggers/schedule/{node_id}/")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def fire_schedule_now(client: ApiClient, workflow_id, node_id: str) -> dict:
|
|
460
|
+
"""Fire a schedule now through the scheduler's own path (overlap rule, no inputs,
|
|
461
|
+
recorded as a scheduled run). Returns {workflow_run_id, status}; 409 if skipped."""
|
|
462
|
+
return client.post(f"/api/workflows/{workflow_id}/triggers/schedule/{node_id}/fire/")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def set_trigger_active(client: ApiClient, kind: str, trigger_id, is_active: bool) -> dict:
|
|
466
|
+
"""Enable/disable a trigger via the global endpoint. `kind` is 'http' or 'schedule'."""
|
|
467
|
+
return client.patch(f"/api/triggers/{kind}/{trigger_id}/", json={"is_active": is_active})
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def delete_trigger(client: ApiClient, workflow_id, kind: str, node_id: str) -> None:
|
|
471
|
+
"""Delete a trigger by its workflow + node. `kind` is 'http' or 'schedule'."""
|
|
472
|
+
client.delete(f"/api/workflows/{workflow_id}/triggers/{kind}/{node_id}/")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _vault_name(client: ApiClient, vault_id) -> str | None:
|
|
476
|
+
"""Look up a vault's display name from its id (best-effort; None on failure).
|
|
477
|
+
|
|
478
|
+
Credentials/servers only carry the vault's UUID; we resolve the friendly name
|
|
479
|
+
with one targeted call so callers can show it instead of the raw id.
|
|
480
|
+
"""
|
|
481
|
+
if not vault_id:
|
|
482
|
+
return None
|
|
483
|
+
try:
|
|
484
|
+
return (client.get(f"/api/vault/vaults/{vault_id}/") or {}).get("name")
|
|
485
|
+
except ApiError:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _vault_name_map(client: ApiClient) -> dict:
|
|
490
|
+
"""Map every vault id -> name in one call. Used to label ambiguous matches by vault."""
|
|
491
|
+
return {str(v.get("id")): v.get("name") for v in _as_list(client.get("/api/vault/vaults/"))}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _dotted(*parts) -> str:
|
|
495
|
+
"""Join non-empty hint parts with ' · ' (e.g. 'ssh · Prod')."""
|
|
496
|
+
return " · ".join(str(p) for p in parts if p)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def resolve_credential(client: ApiClient, ref: str) -> dict:
|
|
500
|
+
"""Resolve a vault credential (key) by id or name; returns the MASKED record.
|
|
501
|
+
|
|
502
|
+
Secret fields are write-only server-side, so this never contains plaintext.
|
|
503
|
+
Use `reveal_credential` (explicitly) to fetch the actual secret values.
|
|
504
|
+
Enriched with `vault_name` for display.
|
|
505
|
+
"""
|
|
506
|
+
items = _as_list(client.get("/api/vault/credentials/"))
|
|
507
|
+
hits = _match(items, ref)
|
|
508
|
+
if not hits:
|
|
509
|
+
raise ResourceNotFound("key", ref)
|
|
510
|
+
if len(hits) > 1:
|
|
511
|
+
vmap = _vault_name_map(client)
|
|
512
|
+
raise AmbiguousResource("key", ref, [
|
|
513
|
+
{"id": c.get("id"), "name": c.get("name") or "(unnamed)",
|
|
514
|
+
"hint": _dotted(c.get("credential_type"), vmap.get(str(c.get("vault"))))}
|
|
515
|
+
for c in hits
|
|
516
|
+
])
|
|
517
|
+
cred = client.get(f"/api/vault/credentials/{hits[0]['id']}/")
|
|
518
|
+
cred["vault_name"] = _vault_name(client, cred.get("vault"))
|
|
519
|
+
return cred
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def find_credential_in_vault(vault: dict, ref: str) -> dict | None:
|
|
523
|
+
"""Match a credential by id or name within a vault's nested `credentials` list.
|
|
524
|
+
|
|
525
|
+
A vault detail record carries its credentials inline, so this needs no API call.
|
|
526
|
+
Used to enforce (client-side, with a clear error) that a server's attached key
|
|
527
|
+
lives in the same vault. Credential names are unique per vault, so no ambiguity.
|
|
528
|
+
"""
|
|
529
|
+
creds = vault.get("credentials") or []
|
|
530
|
+
for c in creds: # exact id first
|
|
531
|
+
if str(c.get("id")) == ref:
|
|
532
|
+
return c
|
|
533
|
+
ref_l = ref.strip().lower()
|
|
534
|
+
for c in creds:
|
|
535
|
+
if str(c.get("name") or "").lower() == ref_l:
|
|
536
|
+
return c
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def find_server_in_vault(vault: dict, ref: str) -> dict | None:
|
|
541
|
+
"""Match a server by id or name within a vault's nested `servers` list.
|
|
542
|
+
|
|
543
|
+
Server names are unique per vault, so scoping a lookup to one vault is always
|
|
544
|
+
unambiguous — used by `update server --vault` to disambiguate a shared name.
|
|
545
|
+
"""
|
|
546
|
+
servers = vault.get("servers") or []
|
|
547
|
+
for s in servers: # exact id first
|
|
548
|
+
if str(s.get("id")) == ref:
|
|
549
|
+
return s
|
|
550
|
+
ref_l = ref.strip().lower()
|
|
551
|
+
for s in servers:
|
|
552
|
+
if str(s.get("name") or "").lower() == ref_l:
|
|
553
|
+
return s
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def reveal_credential(client: ApiClient, credential_id) -> dict:
|
|
558
|
+
"""Fetch a credential's PLAINTEXT secrets. Only call on explicit user request.
|
|
559
|
+
|
|
560
|
+
This hits the single endpoint that decrypts secrets; the caller must confirm
|
|
561
|
+
with the user first.
|
|
562
|
+
"""
|
|
563
|
+
return client.get(f"/api/vault/credentials/{credential_id}/reveal/")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def create_credential(client: ApiClient, payload: dict) -> dict:
|
|
567
|
+
"""POST a new credential; returns the MASKED record (secrets are write-only).
|
|
568
|
+
|
|
569
|
+
Payload: {vault, name, credential_type} plus the secret fields for that type.
|
|
570
|
+
Secrets travel as plaintext in the body and are encrypted server-side (TLS on the
|
|
571
|
+
wire). `vault` is a UUID and must be owned by the caller.
|
|
572
|
+
"""
|
|
573
|
+
return client.post("/api/vault/credentials/", json=payload)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def update_credential(client: ApiClient, credential_id, payload: dict) -> dict:
|
|
577
|
+
"""Update a credential (the server treats PUT as partial); returns the masked record.
|
|
578
|
+
|
|
579
|
+
Send only the fields being changed; omitted secrets are left untouched. Secrets
|
|
580
|
+
travel as plaintext in the body (encrypted server-side).
|
|
581
|
+
"""
|
|
582
|
+
return client.put(f"/api/vault/credentials/{credential_id}/", json=payload)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def resolve_server(client: ApiClient, ref: str) -> dict:
|
|
586
|
+
"""Resolve a vault server by id or name; returns its detail (enriched).
|
|
587
|
+
|
|
588
|
+
Adds `vault_name`; the linked credential's name is already in
|
|
589
|
+
`credential_details` (masked — no secrets).
|
|
590
|
+
"""
|
|
591
|
+
items = _as_list(client.get("/api/vault/servers/"))
|
|
592
|
+
try:
|
|
593
|
+
hit = pick_server(items, ref, {})
|
|
594
|
+
except AmbiguousResource:
|
|
595
|
+
# Only fetch vault names when they're needed to tell the matches apart.
|
|
596
|
+
hit = pick_server(items, ref, _vault_name_map(client))
|
|
597
|
+
server = client.get(f"/api/vault/servers/{hit['id']}/")
|
|
598
|
+
server["vault_name"] = _vault_name(client, server.get("vault"))
|
|
599
|
+
return server
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def pick_server(items: list[dict], ref: str, vault_names: dict) -> dict:
|
|
603
|
+
"""Match a server by id or name in an already-fetched list (no API call).
|
|
604
|
+
|
|
605
|
+
`vault_names` maps vault id -> name, used to label matches that share a name.
|
|
606
|
+
"""
|
|
607
|
+
hits = _match(items, ref)
|
|
608
|
+
if not hits:
|
|
609
|
+
raise ResourceNotFound("server", ref)
|
|
610
|
+
if len(hits) > 1:
|
|
611
|
+
raise AmbiguousResource("server", ref, [
|
|
612
|
+
{"id": s.get("id"), "name": s.get("name") or "(unnamed)",
|
|
613
|
+
"hint": _dotted(s.get("host"), vault_names.get(str(s.get("vault"))))}
|
|
614
|
+
for s in hits
|
|
615
|
+
])
|
|
616
|
+
return hits[0]
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def create_server(client: ApiClient, payload: dict) -> dict:
|
|
620
|
+
"""POST a new vault server; returns the created record.
|
|
621
|
+
|
|
622
|
+
Payload: {vault, name, host, connection_method, port} plus optional {credential}.
|
|
623
|
+
`vault` and `credential` are UUID ids; the credential must belong to that vault.
|
|
624
|
+
"""
|
|
625
|
+
return client.post("/api/vault/servers/", json=payload)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def update_server(client: ApiClient, server_id, payload: dict) -> dict:
|
|
629
|
+
"""Update a server (the server treats PUT as partial, so send only changed fields).
|
|
630
|
+
|
|
631
|
+
Any of name/host/port/connection_method/credential. Set credential to None to
|
|
632
|
+
unlink the current key; a credential id must belong to the server's vault.
|
|
633
|
+
"""
|
|
634
|
+
return client.put(f"/api/vault/servers/{server_id}/", json=payload)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def resolve_vault(client: ApiClient, ref: str) -> dict:
|
|
638
|
+
"""Resolve a vault by id or name; returns its detail (nested creds + servers)."""
|
|
639
|
+
hit = pick_vault(_as_list(client.get("/api/vault/vaults/")), ref)
|
|
640
|
+
return client.get(f"/api/vault/vaults/{hit['id']}/")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def pick_vault(items: list[dict], ref: str) -> dict:
|
|
644
|
+
"""Match a vault by id or name in an already-fetched list (no API call).
|
|
645
|
+
|
|
646
|
+
List items already carry the nested servers and credentials.
|
|
647
|
+
"""
|
|
648
|
+
hits = _match(items, ref)
|
|
649
|
+
if not hits:
|
|
650
|
+
raise ResourceNotFound("vault", ref)
|
|
651
|
+
if len(hits) > 1:
|
|
652
|
+
raise AmbiguousResource("vault", ref, [
|
|
653
|
+
{"id": v.get("id"), "name": v.get("name") or "(unnamed)",
|
|
654
|
+
"hint": f"modified {relative_time(v.get('modified_at'))}"}
|
|
655
|
+
for v in hits
|
|
656
|
+
])
|
|
657
|
+
return hits[0]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def find_vault_by_name(client: ApiClient, name: str) -> dict | None:
|
|
661
|
+
"""Return the vault whose name matches (case-insensitive), or None.
|
|
662
|
+
|
|
663
|
+
Vault names are unique per owner and the API key scopes to one owner, so at most
|
|
664
|
+
one matches. Used by `create/update vault` to check for a name clash first, since
|
|
665
|
+
the server's duplicate-name error shape isn't guaranteed.
|
|
666
|
+
"""
|
|
667
|
+
name_l = name.strip().lower()
|
|
668
|
+
for it in _as_list(client.get("/api/vault/vaults/")):
|
|
669
|
+
if str(it.get("name") or "").lower() == name_l:
|
|
670
|
+
return it
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def create_vault(client: ApiClient, payload: dict) -> dict:
|
|
675
|
+
"""POST a new vault ({name, description}); returns the created record."""
|
|
676
|
+
return client.post("/api/vault/vaults/", json=payload)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def update_vault(client: ApiClient, vault_id, payload: dict) -> dict:
|
|
680
|
+
"""Update a vault (the server treats PUT as partial, so send only changed fields)."""
|
|
681
|
+
return client.put(f"/api/vault/vaults/{vault_id}/", json=payload)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ---------------------------------------------------------------------------
|
|
685
|
+
# Delete helpers. All are owner-scoped server-side (404 if not yours). Deleting a
|
|
686
|
+
# vault CASCADES to its servers and credentials.
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def delete_workflow(client: ApiClient, workflow_id) -> None:
|
|
691
|
+
client.delete(f"/api/workflows/{workflow_id}/")
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def delete_script(client: ApiClient, script_id) -> None:
|
|
695
|
+
client.delete(f"/api/scripts/{script_id}/")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def delete_vault(client: ApiClient, vault_id) -> None:
|
|
699
|
+
"""Delete a vault — CASCADES to every server and credential inside it."""
|
|
700
|
+
client.delete(f"/api/vault/vaults/{vault_id}/")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def delete_server(client: ApiClient, server_id) -> None:
|
|
704
|
+
client.delete(f"/api/vault/servers/{server_id}/")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def delete_credential(client: ApiClient, credential_id) -> None:
|
|
708
|
+
client.delete(f"/api/vault/credentials/{credential_id}/")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
# Full-list helpers for the `sagex list` commands.
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def list_workflows_full(client: ApiClient) -> list[dict]:
|
|
717
|
+
"""All workflows — id, name, modified_at."""
|
|
718
|
+
return _as_list(client.get("/api/workflows/"))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def list_scripts_full(client: ApiClient) -> list[dict]:
|
|
722
|
+
"""All scripts — id, name, content_type, version."""
|
|
723
|
+
return _as_list(client.get("/api/scripts/"))
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def list_runs_full(client: ApiClient, limit: int | None = 20) -> list[dict]:
|
|
727
|
+
"""Recent runs sorted newest-first. `limit=None` returns the full history."""
|
|
728
|
+
runs = _as_list(client.get("/api/execution-engine/workflows/runs/"))
|
|
729
|
+
runs.sort(key=lambda r: r.get("created_at") or "", reverse=True)
|
|
730
|
+
return runs if limit is None else runs[:limit]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def list_triggers_full(client: ApiClient) -> list[dict]:
|
|
734
|
+
"""All triggers as a flat list; each dict has a 'kind' key ('http' or 'schedule')."""
|
|
735
|
+
data = client.get("/api/triggers/") or {}
|
|
736
|
+
out: list[dict] = []
|
|
737
|
+
for t in data.get("schedule_triggers", []):
|
|
738
|
+
out.append({**t, "kind": "schedule"})
|
|
739
|
+
for t in data.get("http_triggers", []):
|
|
740
|
+
out.append({**t, "kind": "http"})
|
|
741
|
+
return out
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def list_credentials_full(client: ApiClient) -> list[dict]:
|
|
745
|
+
"""All credentials enriched with vault_name (2 API calls: creds + vaults list)."""
|
|
746
|
+
creds = _as_list(client.get("/api/vault/credentials/"))
|
|
747
|
+
vault_names = {
|
|
748
|
+
str(v.get("id")): v.get("name")
|
|
749
|
+
for v in _as_list(client.get("/api/vault/vaults/"))
|
|
750
|
+
}
|
|
751
|
+
for c in creds:
|
|
752
|
+
c["vault_name"] = vault_names.get(str(c.get("vault") or ""))
|
|
753
|
+
return creds
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def list_servers_full(client: ApiClient) -> list[dict]:
|
|
757
|
+
"""All servers — name, host, port, connection_method."""
|
|
758
|
+
return _as_list(client.get("/api/vault/servers/"))
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def list_vaults_full(client: ApiClient) -> list[dict]:
|
|
762
|
+
"""All vaults (nested credentials + servers included by the serializer)."""
|
|
763
|
+
return _as_list(client.get("/api/vault/vaults/"))
|