corethink 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.
Files changed (48) hide show
  1. corethink/__init__.py +17 -0
  2. corethink/__main__.py +3 -0
  3. corethink/_version.py +3 -0
  4. corethink/account.py +470 -0
  5. corethink/cli.py +1058 -0
  6. corethink/client.py +253 -0
  7. corethink/commands/__init__.py +0 -0
  8. corethink/commands/admin.py +703 -0
  9. corethink/commands/api.py +59 -0
  10. corethink/commands/archive.py +433 -0
  11. corethink/commands/auth.py +375 -0
  12. corethink/commands/billing.py +314 -0
  13. corethink/commands/capture.py +564 -0
  14. corethink/commands/connection.py +1266 -0
  15. corethink/commands/endpoint.py +1124 -0
  16. corethink/commands/env.py +1966 -0
  17. corethink/commands/hub.py +269 -0
  18. corethink/commands/library.py +347 -0
  19. corethink/commands/models.py +258 -0
  20. corethink/commands/train.py +2655 -0
  21. corethink/commands/workspace.py +622 -0
  22. corethink/config.py +181 -0
  23. corethink/context.py +46 -0
  24. corethink/env_template.py +117 -0
  25. corethink/environment_http_store.py +96 -0
  26. corethink/experiment_cli.py +2187 -0
  27. corethink/hosted_profiles.py +44 -0
  28. corethink/hosted_rl.py +688 -0
  29. corethink/hosted_rl_archive.py +74 -0
  30. corethink/hosted_rl_attribution.py +44 -0
  31. corethink/hosted_rl_billing.py +103 -0
  32. corethink/hosted_rl_state.py +182 -0
  33. corethink/jobs_cli.py +839 -0
  34. corethink/main.py +204 -0
  35. corethink/output.py +201 -0
  36. corethink/py.typed +0 -0
  37. corethink/rl_cli.py +997 -0
  38. corethink/rl_inspect.py +521 -0
  39. corethink/rl_playground.py +129 -0
  40. corethink/rl_reassessment.py +214 -0
  41. corethink/rl_settings.py +43 -0
  42. corethink/sdk.py +331 -0
  43. corethink/settings.py +162 -0
  44. corethink-1.0.0.dist-info/METADATA +120 -0
  45. corethink-1.0.0.dist-info/RECORD +48 -0
  46. corethink-1.0.0.dist-info/WHEEL +5 -0
  47. corethink-1.0.0.dist-info/entry_points.txt +2 -0
  48. corethink-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,703 @@
1
+ """`corethink admin`: every account and run on this Workbench, credit, limits and super admins (super admins only).
2
+
3
+ The Workbench checks every request; anyone else is told they cannot use these commands.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import sys
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import typer
15
+ from rich.text import Text
16
+
17
+ from corethink import output as ui
18
+ from corethink.client import ApiError
19
+ from corethink.commands.workspace import entry_amount, entry_type
20
+ from corethink.context import client
21
+
22
+ app = typer.Typer(help="Every account and run on this Workbench (super admins only).", no_args_is_help=True)
23
+ settings_app = typer.Typer(help="Account limits for everyone: show them, or change them with `set`.")
24
+ superadmins_app = typer.Typer(help="Who can use the admin commands and page.")
25
+
26
+ YES_OPTION = typer.Option(False, "--yes", "-y", help="Do not ask.")
27
+ JOB_ID = re.compile(r"^[0-9a-f-]{36}$")
28
+
29
+ LIMIT_LABEL = {
30
+ "signupCreditUsd": "Welcome credit",
31
+ "grantsPerDay": "Welcome credits per day",
32
+ "maxRunningJobs": "Runs at once (personal workspace)",
33
+ "maxRunningJobsTeam": "Runs at once (team workspace)",
34
+ "minBudgetUsd": "Smallest run spending limit",
35
+ "maxBudgetUsd": "Largest run spending limit",
36
+ }
37
+
38
+ RUN_STATE = {
39
+ "RUNNING": ("running", "running"),
40
+ "ADMITTED": ("starting", "running"),
41
+ "QUEUED": ("queued", "queued"),
42
+ "PREEMPTED": ("paused", "queued"),
43
+ "SUCCEEDED": ("succeeded", "succeeded"),
44
+ "FAILED": ("failed", "failed"),
45
+ "CANCELLED": ("cancelled", "failed"),
46
+ }
47
+ RUN_FILTERS = ("running", "queued", "succeeded", "failed")
48
+
49
+
50
+ # ---- helpers ---------------------------------------------------------------------------------------------------------
51
+
52
+
53
+ def _denied(problem: ApiError) -> ApiError:
54
+ if problem.status == 403 and problem.code == "forbidden":
55
+ return ApiError(
56
+ "Only super admins can use `corethink admin`. Ask a super admin to add you.",
57
+ status=403,
58
+ code="forbidden",
59
+ payload=problem.payload,
60
+ )
61
+ return problem
62
+
63
+
64
+ def _call(method: str, path: str, body: Any = None, **params: Any) -> Any:
65
+ try:
66
+ if method == "GET":
67
+ return client().get(path, **params) or {}
68
+ return client().request(method, path, json=body, params=params or None) or {}
69
+ except ApiError as problem:
70
+ raise _denied(problem) from None
71
+
72
+
73
+ def _pick(matches: list[dict[str, Any]], what: str, given: str) -> dict[str, Any]:
74
+ if len(matches) == 1:
75
+ return matches[0]
76
+ if not matches:
77
+ ui.error(f"No {what} matches {given!r}.")
78
+ raise typer.Exit(5)
79
+ ui.error(f"More than one {what} matches {given!r}; be more specific.")
80
+ raise typer.Exit(2)
81
+
82
+
83
+ def _find_account(given: str) -> dict[str, Any]:
84
+ """An account by ID or email (or the start of either)."""
85
+ accounts: list[dict[str, Any]] = _call("GET", "/api/v1/admin/accounts").get("accounts") or []
86
+ folded = given.strip().casefold()
87
+ for test in (
88
+ lambda a: a.get("uid") == given,
89
+ lambda a: str(a.get("email", "")).casefold() == folded,
90
+ lambda a: str(a.get("uid", "")).startswith(given),
91
+ lambda a: str(a.get("email", "")).casefold().startswith(folded),
92
+ ):
93
+ matches = [a for a in accounts if test(a)]
94
+ if matches:
95
+ return _pick(matches, "account", given)
96
+ return _pick([], "account", given)
97
+
98
+
99
+ def _count(value: Any) -> str:
100
+ try:
101
+ return f"{int(value):,}"
102
+ except (TypeError, ValueError):
103
+ return "—"
104
+
105
+
106
+ def _limit_text(unit: Any, value: Any) -> str:
107
+ if value is None:
108
+ return "—"
109
+ if unit == "usd":
110
+ return ui.usd(value, 0 if float(value).is_integer() else 2)
111
+ return _count(value)
112
+
113
+
114
+ def _run_state(run: dict[str, Any]) -> Text:
115
+ label = RUN_STATE.get(str(run.get("state", "")).upper(), (str(run.get("state") or "—").lower(), ""))[0]
116
+ styled = ui.state_text(run.get("state"))
117
+ return Text(label, style=styled.style)
118
+
119
+
120
+ def _limit_keys(known: list[str], pairs: list[str]) -> dict[str, float | int | None]:
121
+ """KEY=VALUE pairs as limit values; a key may be written in any case or kebab-case; `default` clears it."""
122
+ lookup = {re.sub(r"[-_]", "", key).lower(): key for key in known}
123
+ values: dict[str, float | int | None] = {}
124
+ for pair in pairs:
125
+ name, sep, raw = pair.partition("=")
126
+ key = lookup.get(re.sub(r"[-_]", "", name.strip()).lower())
127
+ if not sep or not key:
128
+ ui.error(f"{pair}: use KEY=VALUE with one of {', '.join(known)} (VALUE `default` clears it).")
129
+ raise typer.Exit(2)
130
+ text = raw.strip().lstrip("$").replace(",", "")
131
+ if text.lower() in ("", "default", "none", "null"):
132
+ values[key] = None
133
+ continue
134
+ try:
135
+ number = float(text)
136
+ except ValueError:
137
+ ui.error(f"{pair}: give a number, or `default`.")
138
+ raise typer.Exit(2) from None
139
+ values[key] = int(number) if number.is_integer() else number
140
+ if not values:
141
+ ui.error(f"Give at least one KEY=VALUE: {', '.join(known)}.")
142
+ raise typer.Exit(2)
143
+ return values
144
+
145
+
146
+ def _change_text(values: dict[str, Any], units: dict[str, Any]) -> str:
147
+ return ", ".join(
148
+ f"{LIMIT_LABEL.get(k, k)} → {'default' if v is None else _limit_text(units.get(k), v)}"
149
+ for k, v in values.items()
150
+ )
151
+
152
+
153
+ # ---- overview and accounts -------------------------------------------------------------------------------------------
154
+
155
+
156
+ @app.command("overview")
157
+ def overview() -> None:
158
+ """Accounts, runs, spend and credit across the Workbench."""
159
+ data = _call("GET", "/api/v1/admin/overview")
160
+ accounts, runs = data.get("accounts") or {}, data.get("runs") or {}
161
+ spend, credit = data.get("spend") or {}, data.get("credit") or {}
162
+
163
+ def human() -> None:
164
+ ui.fields(
165
+ [
166
+ ("Total", _count(accounts.get("total"))),
167
+ ("Confirmed", _count(accounts.get("verified"))),
168
+ ("New in the last 7 days", _count(accounts.get("newLast7"))),
169
+ (
170
+ "Test accounts not counted",
171
+ _count(accounts["hiddenTests"]) if accounts.get("hiddenTests") else None,
172
+ ),
173
+ (
174
+ "Sign-ups per day",
175
+ " ".join(f"{d.get('date', '')[5:]}: {d.get('count')}" for d in accounts.get("daily") or [])
176
+ or None,
177
+ ),
178
+ ],
179
+ title="Accounts",
180
+ )
181
+ ui.out.print()
182
+ ui.fields(
183
+ [
184
+ ("Running", _count(runs.get("running"))),
185
+ ("Queued", _count(runs.get("queued"))),
186
+ (
187
+ "Finished in the last 7 days",
188
+ f"{_count(runs.get('finishedLast7'))} ({_count(runs.get('succeededLast7'))} succeeded, "
189
+ f"{_count(runs.get('failedLast7'))} failed)",
190
+ ),
191
+ (
192
+ "Total",
193
+ _count(runs.get("total")) + (" (newest only)" if runs.get("capped") else ""),
194
+ ),
195
+ (
196
+ "Test-account runs not counted",
197
+ _count(runs["hiddenTests"]) if runs.get("hiddenTests") else None,
198
+ ),
199
+ ],
200
+ title="Runs",
201
+ )
202
+ ui.out.print()
203
+ ui.fields(
204
+ [
205
+ ("Spent on runs", ui.usd(spend.get("totalUsd"))),
206
+ ("In the last 7 days", ui.usd(spend.get("last7Usd"))),
207
+ (
208
+ "Credit given or bought",
209
+ ui.usd(float(credit.get("grantedUsd") or 0) + float(credit.get("boughtUsd") or 0)),
210
+ ),
211
+ ("Welcome credit", ui.usd(credit.get("welcomeUsd"))),
212
+ ("Added by admins", ui.usd(credit.get("addedUsd"))),
213
+ ("Bought", ui.usd(credit.get("boughtUsd"))),
214
+ ("Used", ui.usd(credit.get("usedUsd"))),
215
+ ("Left", ui.usd(credit.get("leftUsd"))),
216
+ ("Held by running jobs", ui.usd(credit.get("heldUsd"))),
217
+ ],
218
+ title="Spend and credit",
219
+ )
220
+ if runs.get("problem"):
221
+ ui.warn(f"Runs could not all be read: {runs['problem']}")
222
+
223
+ ui.emit(data, human)
224
+
225
+
226
+ @app.command("accounts")
227
+ def accounts(
228
+ search: str = typer.Option(None, "--search", "-s", help="Only accounts whose email contains this."),
229
+ limit: int = typer.Option(100, "--limit", "-n", min=1, help="Show at most this many."),
230
+ ) -> None:
231
+ """Every account, newest first.
232
+
233
+ Example: corethink admin accounts --search acme.com
234
+ """
235
+ data = _call("GET", "/api/v1/admin/accounts")
236
+ every = data.get("accounts") or []
237
+ matched = [a for a in every if not search or search.casefold() in str(a.get("email", "")).casefold()]
238
+ shown = matched[:limit]
239
+
240
+ def human() -> None:
241
+ ui.table(
242
+ shown,
243
+ [
244
+ ("Email", "email"),
245
+ ("Created", lambda a: ui.when(a.get("createdAt"))),
246
+ ("Confirmed", lambda a: "yes" if a.get("verified") else Text("no", style="yellow")),
247
+ ("Workspaces", lambda a: _count(a.get("workspaces"))),
248
+ ("Balance", lambda a: ui.usd(a.get("balanceUsd"))),
249
+ ("Spent", lambda a: ui.usd(a.get("spentUsd"))),
250
+ ("Runs", lambda a: _count(a.get("runs"))),
251
+ ("ID", lambda a: ui.short_id(a.get("uid"), 12)),
252
+ ],
253
+ empty="No accounts match.",
254
+ )
255
+ hidden = data.get("hiddenTestAccounts")
256
+ ui.info(
257
+ f"{len(shown)} of {len(every)} accounts"
258
+ + (f" ({len(matched)} match)" if search else "")
259
+ + (f" · {hidden} test accounts hidden" if hidden else "")
260
+ )
261
+
262
+ ui.emit(
263
+ {
264
+ "accounts": shown,
265
+ "total": len(every),
266
+ "hiddenTestAccounts": data.get("hiddenTestAccounts"),
267
+ },
268
+ human,
269
+ )
270
+
271
+
272
+ @app.command("account")
273
+ def account(
274
+ who: str = typer.Argument(..., metavar="ACCOUNT", help="The account's email or ID (or the start of either)."),
275
+ ) -> None:
276
+ """One account: its workspaces, credit, limits and runs."""
277
+ found = _find_account(who)
278
+ detail = _call("GET", f"/api/v1/admin/accounts/{found['uid']}")
279
+ shown = detail.get("account") or found
280
+
281
+ def human() -> None:
282
+ held = float(shown.get("heldUsd") or 0)
283
+ ui.fields(
284
+ [
285
+ ("Email", shown.get("email")),
286
+ ("ID", shown.get("uid")),
287
+ ("Created", ui.when(shown.get("createdAt"))),
288
+ ("Confirmed", "yes" if shown.get("verified") else "no"),
289
+ (
290
+ "Balance",
291
+ ui.usd(shown.get("balanceUsd")) + (f" ({ui.usd(held)} held)" if held > 0 else ""),
292
+ ),
293
+ (
294
+ "Spent on runs",
295
+ f"{ui.usd(shown.get('spentUsd'))} · {_count(shown.get('runs'))} runs",
296
+ ),
297
+ ]
298
+ )
299
+ ui.out.print()
300
+ ui.table(
301
+ detail.get("workspaces") or [],
302
+ [("Workspace", "name"), ("Kind", "kind"), ("Role", "role"), ("ID", "id")],
303
+ title="Workspaces",
304
+ empty="No workspaces.",
305
+ )
306
+ limits = (detail.get("limits") or {}).get("limits") or []
307
+ if limits:
308
+ ui.out.print()
309
+ _account_limits_table(limits)
310
+ ui.out.print()
311
+ ui.table(
312
+ (detail.get("ledger") or [])[:20],
313
+ [
314
+ ("When", lambda e: ui.when(e.get("at"))),
315
+ ("Type", entry_type),
316
+ ("Note", lambda e: e.get("note") or e.get("kind")),
317
+ ("Amount", entry_amount),
318
+ ],
319
+ title="Credit",
320
+ empty="No credit yet.",
321
+ )
322
+ ui.out.print()
323
+ ui.table(
324
+ (detail.get("runs") or [])[:20],
325
+ [
326
+ ("Job", lambda r: ui.short_id(r.get("jobId"))),
327
+ ("Environment", "environment"),
328
+ ("Status", _run_state),
329
+ ("Spent", lambda r: ui.usd(r.get("spentUsd"))),
330
+ ("Updated", lambda r: ui.ago(r.get("updatedAt"))),
331
+ ],
332
+ title="Runs",
333
+ empty="No runs.",
334
+ )
335
+
336
+ ui.emit(detail, human)
337
+
338
+
339
+ def _account_limits_table(limits: list[dict[str, Any]]) -> None:
340
+ ui.table(
341
+ limits,
342
+ [
343
+ ("Limit", lambda x: LIMIT_LABEL.get(x.get("key", ""), x.get("key"))),
344
+ ("Key", "key"),
345
+ ("In use", lambda x: _limit_text(x.get("unit"), x.get("value"))),
346
+ (
347
+ "Its own",
348
+ lambda x: _limit_text(x.get("unit"), x.get("own")) if x.get("own") is not None else "—",
349
+ ),
350
+ ("For everyone", lambda x: _limit_text(x.get("unit"), x.get("global"))),
351
+ ],
352
+ title="Limits for this account",
353
+ )
354
+
355
+
356
+ @app.command("add-credit")
357
+ def add_credit(
358
+ who: str = typer.Argument(..., metavar="ACCOUNT", help="The account's email or ID."),
359
+ usd: float = typer.Argument(..., metavar="USD", help="US dollars to add (up to 1,000)."),
360
+ note: str = typer.Option(..., "--note", help="Why, in a few words (the account's ledger shows it)."),
361
+ key: str = typer.Option(
362
+ None,
363
+ "--key",
364
+ help="A unique key for this credit (8-64 letters, digits, dashes); repeating a request with the same key adds "
365
+ "nothing. Default: a new one.",
366
+ ),
367
+ yes: bool = YES_OPTION,
368
+ ) -> None:
369
+ """Add promotional credit to an account.
370
+
371
+ Example: corethink admin add-credit ana@acme.com 50 --note "Pilot credit"
372
+ """
373
+ if not 0 < usd <= 1000:
374
+ ui.error("Give an amount from $0.01 to $1,000.")
375
+ raise typer.Exit(2)
376
+ if not note.strip():
377
+ ui.error("Add a short note.")
378
+ raise typer.Exit(2)
379
+ if key is not None and not re.fullmatch(r"[A-Za-z0-9-]{8,64}", key):
380
+ ui.error("--key: use 8 to 64 letters, digits or dashes.")
381
+ raise typer.Exit(2)
382
+ found = _find_account(who)
383
+ ui.confirm(f"Add {ui.usd(usd)} to {found.get('email')} ({note.strip()})?", yes=yes)
384
+ body = {"usd": round(usd, 2), "note": note.strip(), "key": key or str(uuid.uuid4())}
385
+ done = _call("POST", f"/api/v1/admin/accounts/{found['uid']}/credit", body)
386
+
387
+ def human() -> None:
388
+ if done.get("repeated"):
389
+ ui.warn(
390
+ f"That credit was already added (same key): nothing more was added. Balance {ui.usd(done.get('balanceUsd'))}."
391
+ )
392
+ else:
393
+ ui.success(f"Added {ui.usd(usd)} to {found.get('email')}. Balance {ui.usd(done.get('balanceUsd'))}.")
394
+
395
+ ui.emit({"uid": found["uid"], "email": found.get("email"), "key": body["key"], **done}, human)
396
+
397
+
398
+ @app.command("limits")
399
+ def limits(
400
+ who: str = typer.Argument(..., metavar="ACCOUNT", help="The account's email or ID."),
401
+ pairs: list[str] = typer.Argument( # noqa: B008
402
+ None,
403
+ metavar="[KEY=VALUE]...",
404
+ help="Limits to set; VALUE `default` uses the value for everyone.",
405
+ ),
406
+ yes: bool = YES_OPTION,
407
+ ) -> None:
408
+ """Show an account's own limits, or set them.
409
+
410
+ Example: corethink admin limits ana@acme.com maxRunningJobs=5 maxBudgetUsd=default
411
+ """
412
+ found = _find_account(who)
413
+ current = _call("GET", f"/api/v1/admin/accounts/{found['uid']}").get("limits") or {}
414
+ if not pairs:
415
+ ui.emit(current, lambda: _account_limits_table(current.get("limits") or []))
416
+ return
417
+ rows = current.get("limits") or []
418
+ known = [row["key"] for row in rows] or list(LIMIT_LABEL)
419
+ values = _limit_keys(known, pairs)
420
+ ui.confirm(
421
+ f"Set for {found.get('email')}: {_change_text(values, {row['key']: row.get('unit') for row in rows})}?",
422
+ yes=yes,
423
+ )
424
+ saved = _call("POST", f"/api/v1/admin/accounts/{found['uid']}/limits", {"values": values})
425
+
426
+ def human() -> None:
427
+ ui.success(f"Saved {found.get('email')}'s limits.")
428
+ _account_limits_table(saved.get("limits") or [])
429
+
430
+ ui.emit(saved, human)
431
+
432
+
433
+ # ---- settings ----------------------------------------------------------------------------------------------------------
434
+
435
+
436
+ def _settings_table(data: dict[str, Any]) -> None:
437
+ ui.table(
438
+ data.get("limits") or [],
439
+ [
440
+ ("Limit", lambda x: LIMIT_LABEL.get(x.get("key", ""), x.get("key"))),
441
+ ("Key", "key"),
442
+ (
443
+ "Value",
444
+ lambda x: _limit_text(x.get("unit"), x.get("value")) + ("" if x.get("saved") else " (default)"),
445
+ ),
446
+ ("Default", lambda x: _limit_text(x.get("unit"), x.get("default"))),
447
+ (
448
+ "Allowed",
449
+ lambda x: f"{_limit_text(x.get('unit'), x.get('min'))} to {_limit_text(x.get('unit'), x.get('max'))}",
450
+ ),
451
+ ("Per account", lambda x: "yes" if x.get("perAccount") else "no"),
452
+ ],
453
+ empty="No limits.",
454
+ )
455
+ if data.get("updatedBy"):
456
+ ui.info(f"Last changed by {data['updatedBy']} {ui.ago(data.get('updatedAt'))}.")
457
+
458
+
459
+ @settings_app.callback(invoke_without_command=True)
460
+ def settings_show(ctx: typer.Context) -> None:
461
+ """Account limits for everyone: welcome credit, runs at once, run spending limits.
462
+
463
+ Change them with `corethink admin settings set KEY=VALUE…`.
464
+ """
465
+ if ctx.invoked_subcommand is not None:
466
+ return
467
+ data = _call("GET", "/api/v1/admin/settings")
468
+ ui.emit(data, lambda: _settings_table(data))
469
+
470
+
471
+ @settings_app.command("set")
472
+ def settings_set(
473
+ pairs: list[str] = typer.Argument( # noqa: B008
474
+ ..., metavar="KEY=VALUE...", help="VALUE `default` goes back to the default."
475
+ ),
476
+ yes: bool = YES_OPTION,
477
+ ) -> None:
478
+ """Change limits for every account; limits left out stay as they are.
479
+
480
+ Example: corethink admin settings set signupCreditUsd=10 maxRunningJobs=3
481
+ """
482
+ current = _call("GET", "/api/v1/admin/settings")
483
+ rows = current.get("limits") or []
484
+ known = [row["key"] for row in rows] or list(LIMIT_LABEL)
485
+ values = _limit_keys(known, pairs)
486
+ ui.confirm(
487
+ f"Change for every account: {_change_text(values, {row['key']: row.get('unit') for row in rows})}?",
488
+ yes=yes,
489
+ )
490
+ saved = _call("POST", "/api/v1/admin/settings", {"values": values})
491
+
492
+ def human() -> None:
493
+ ui.success("Saved.")
494
+ _settings_table(saved)
495
+
496
+ ui.emit(saved, human)
497
+
498
+
499
+ # ---- runs ----------------------------------------------------------------------------------------------------------------
500
+
501
+
502
+ @app.command("runs")
503
+ def runs(
504
+ qa: bool = typer.Option(False, "--qa", help="Include test accounts' runs (marked QA)."),
505
+ status: str = typer.Option(None, "--status", help="Only running, queued, succeeded or failed runs."),
506
+ search: str = typer.Option(
507
+ None,
508
+ "--search",
509
+ "-s",
510
+ help="Only runs whose person, workspace or environment contains this.",
511
+ ),
512
+ limit: int = typer.Option(50, "--limit", "-n", min=1, help="Show at most this many."),
513
+ ) -> None:
514
+ """Every workspace's runs, newest first.
515
+
516
+ Example: corethink admin runs --status failed
517
+ """
518
+ if status is not None and status.lower() not in RUN_FILTERS:
519
+ ui.error(f"--status: use one of {', '.join(RUN_FILTERS)}.")
520
+ raise typer.Exit(2)
521
+ data = _call("GET", "/api/v1/admin/runs", qa=1 if qa else None)
522
+ every = data.get("runs") or []
523
+ needle = (search or "").casefold()
524
+
525
+ def keep(run: dict[str, Any]) -> bool:
526
+ if status and RUN_STATE.get(str(run.get("state", "")).upper(), ("", ""))[1] != status.lower():
527
+ return False
528
+ return not needle or any(
529
+ needle in str(run.get(k) or "").casefold()
530
+ for k in ("by", "workspaceName", "workspace", "environment", "jobId")
531
+ )
532
+
533
+ matched = [r for r in every if keep(r)]
534
+ shown = matched[:limit]
535
+
536
+ def human() -> None:
537
+ ui.table(
538
+ shown,
539
+ [
540
+ ("Job", lambda r: ui.short_id(r.get("jobId"))),
541
+ ("Who", lambda r: ("QA · " if r.get("qa") else "") + str(r.get("by") or "—")),
542
+ ("Workspace", lambda r: r.get("workspaceName") or r.get("workspace")),
543
+ ("Environment", "environment"),
544
+ ("Kind", "kind"),
545
+ ("Status", _run_state),
546
+ ("Stage", "stage"),
547
+ (
548
+ "Spent",
549
+ lambda r: f"{ui.usd(r.get('spentUsd'))} / {ui.usd(r.get('budgetUsd'), 0)}",
550
+ ),
551
+ ("Started", lambda r: ui.when(r.get("startedAt") or r.get("submittedAt"))),
552
+ ],
553
+ empty="No runs match.",
554
+ )
555
+ notes = [f"{len(shown)} of {len(matched)} runs"]
556
+ if data.get("hiddenTestRuns") and not qa:
557
+ notes.append(f"{data['hiddenTestRuns']} test-account runs hidden (--qa shows them)")
558
+ if data.get("capped"):
559
+ notes.append("only the newest runs were read")
560
+ ui.info(" · ".join(notes))
561
+ if data.get("problem"):
562
+ ui.warn(f"Runs could not all be read: {data['problem']}")
563
+
564
+ ui.emit({**data, "runs": shown}, human)
565
+
566
+
567
+ @app.command("logs")
568
+ def logs(
569
+ job_id: str = typer.Argument(..., help="The run's job ID (or its start)."),
570
+ output: Path = typer.Option( # noqa: B008
571
+ None,
572
+ "--output",
573
+ "-o",
574
+ help="Where to save the .tar.gz (default run-<job>-logs.tar.gz here); - for stdout.",
575
+ ),
576
+ ) -> None:
577
+ """Download a run's logs (its record, hold and log files) as one .tar.gz, from any workspace."""
578
+ job = job_id.strip().lower()
579
+ if not JOB_ID.fullmatch(job):
580
+ every = _call("GET", "/api/v1/admin/runs", qa=1).get("runs") or []
581
+ job = _pick([r for r in every if str(r.get("jobId", "")).startswith(job)], "run", job_id)["jobId"]
582
+ path = f"/api/v1/admin/runs/{job}/logs"
583
+ try:
584
+ if output is not None and str(output) == "-":
585
+ with client().stream("GET", path) as response:
586
+ for chunk in response.iter_bytes():
587
+ sys.stdout.buffer.write(chunk)
588
+ sys.stdout.buffer.flush()
589
+ return
590
+ target = output or Path(f"run-{job}-logs.tar.gz")
591
+ saved = client().download(path, target)
592
+ except ApiError as problem:
593
+ raise _denied(problem) from None
594
+ size = saved.stat().st_size
595
+ ui.emit(
596
+ {"jobId": job, "path": str(saved), "bytes": size},
597
+ lambda: ui.success(f"Saved the logs of {ui.short_id(job)} to {saved} ({size:,} bytes)."),
598
+ )
599
+
600
+
601
+ # ---- super admins ---------------------------------------------------------------------------------------------------------
602
+
603
+
604
+ @superadmins_app.callback(invoke_without_command=True)
605
+ def superadmins_default(ctx: typer.Context) -> None:
606
+ """Who can use the admin commands and page (team owners always can)."""
607
+ if ctx.invoked_subcommand is None:
608
+ superadmins_list()
609
+
610
+
611
+ @superadmins_app.command("list")
612
+ def superadmins_list() -> None:
613
+ """The super admins."""
614
+ data = _call("GET", "/api/v1/admin/superadmins")
615
+ you = data.get("you")
616
+
617
+ def human() -> None:
618
+ ui.table(
619
+ data.get("admins") or [],
620
+ [
621
+ (
622
+ "Email",
623
+ lambda a: str(a.get("email")) + (" (you)" if a.get("email") == you else ""),
624
+ ),
625
+ (
626
+ "Status",
627
+ lambda a: "team owner" if a.get("owner") else "joined" if a.get("joined") else "not signed up yet",
628
+ ),
629
+ ("Added by", "addedBy"),
630
+ ("Added", lambda a: ui.when(a.get("addedAt"))),
631
+ ],
632
+ empty="No super admins.",
633
+ )
634
+
635
+ ui.emit(data, human)
636
+
637
+
638
+ @superadmins_app.command("add")
639
+ def superadmins_add(
640
+ email: str = typer.Argument(..., help="Their email address."),
641
+ notify: bool = typer.Option(False, "--notify", help="Also email them that they were added (off by default)."),
642
+ ) -> None:
643
+ """Make someone a super admin. They get access once they sign up with this email."""
644
+ done = _call("POST", "/api/v1/admin/superadmins", {"email": email.strip(), "notify": notify})
645
+ ui.emit(
646
+ done,
647
+ lambda: ui.success(
648
+ f"{email.strip()} is a super admin. {'Email sent.' if done.get('emailed') else 'No email sent.'}"
649
+ ),
650
+ )
651
+
652
+
653
+ @superadmins_app.command("remove")
654
+ def superadmins_remove(
655
+ email: str = typer.Argument(..., help="Their email address."),
656
+ yes: bool = YES_OPTION,
657
+ ) -> None:
658
+ """Take away someone's super admin access."""
659
+ ui.confirm(f"Remove {email.strip()} as a super admin?", yes=yes)
660
+ done = _call("DELETE", "/api/v1/admin/superadmins", {"email": email.strip()})
661
+ ui.emit(
662
+ {"email": email.strip(), **done},
663
+ lambda: ui.success(f"{email.strip()} is no longer a super admin."),
664
+ )
665
+
666
+
667
+ # ---- waitlist (read only) ---------------------------------------------------------------------------------------------------
668
+
669
+
670
+ @app.command("waitlist")
671
+ def waitlist(
672
+ pending: bool = typer.Option(False, "--pending", help="Only people not sent the beta email yet."),
673
+ ) -> None:
674
+ """The waitlist: who asked to join and who was sent the beta email (read only)."""
675
+ data = _call("GET", "/api/v1/waitlist")
676
+ entries = [e for e in data.get("entries") or [] if not pending or not e.get("invitedAt")]
677
+
678
+ def human() -> None:
679
+ ui.fields([("Sign-up", "open to everyone" if data.get("open") else "invite only")])
680
+ ui.out.print()
681
+ ui.table(
682
+ entries,
683
+ [
684
+ ("Email", lambda e: str(e.get("email")) + (" (test)" if e.get("test") else "")),
685
+ ("From", "source"),
686
+ ("Joined", lambda e: ui.when(e.get("at"))),
687
+ (
688
+ "Sent the beta email",
689
+ lambda e: ui.when(e.get("invitedAt")) if e.get("invitedAt") else "not yet",
690
+ ),
691
+ ],
692
+ empty="No one is waiting." if pending else "The waitlist is empty.",
693
+ )
694
+
695
+ ui.emit({**data, "entries": entries}, human)
696
+
697
+
698
+ app.add_typer(settings_app, name="settings")
699
+ app.add_typer(superadmins_app, name="superadmins")
700
+
701
+
702
+ def register(root: typer.Typer) -> None:
703
+ root.add_typer(app, name="admin", rich_help_panel="Admin")