machina-cli 0.4.2__tar.gz → 0.4.4__tar.gz

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 (93) hide show
  1. {machina_cli-0.4.2 → machina_cli-0.4.4}/CHANGELOG.md +12 -0
  2. {machina_cli-0.4.2 → machina_cli-0.4.4}/PKG-INFO +1 -1
  3. {machina_cli-0.4.2 → machina_cli-0.4.4}/pyproject.toml +1 -1
  4. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/org.py +90 -22
  5. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/test_org_usage.py +57 -7
  6. {machina_cli-0.4.2 → machina_cli-0.4.4}/.github/workflows/ci.yml +0 -0
  7. {machina_cli-0.4.2 → machina_cli-0.4.4}/.github/workflows/release.yml +0 -0
  8. {machina_cli-0.4.2 → machina_cli-0.4.4}/.gitignore +0 -0
  9. {machina_cli-0.4.2 → machina_cli-0.4.4}/CLAUDE.md +0 -0
  10. {machina_cli-0.4.2 → machina_cli-0.4.4}/README.md +0 -0
  11. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/agentic-harness-loop.md +0 -0
  12. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/credentials.md +0 -0
  13. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/harness-loop-kit/PLAYBOOK-SCORECARD.md +0 -0
  14. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/harness-loop-kit/README.md +0 -0
  15. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/harness-loop-kit/VALIDATION.md +0 -0
  16. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/harness-loop-kit/context-verify.py +0 -0
  17. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/harness-loop-kit/provision.py +0 -0
  18. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs/loop-tools-connector.py +0 -0
  19. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/.gitignore +0 -0
  20. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/.vitepress/config.mts +0 -0
  21. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/.vitepress/theme/custom.css +0 -0
  22. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/.vitepress/theme/index.ts +0 -0
  23. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/README.md +0 -0
  24. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/agent.md +0 -0
  25. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/config.md +0 -0
  26. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/connector.md +0 -0
  27. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/credentials.md +0 -0
  28. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/deploy.md +0 -0
  29. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/document.md +0 -0
  30. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/execution.md +0 -0
  31. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/factory.md +0 -0
  32. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/login.md +0 -0
  33. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/loop.md +0 -0
  34. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/mapping.md +0 -0
  35. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/org.md +0 -0
  36. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/project.md +0 -0
  37. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/prompt.md +0 -0
  38. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/skills.md +0 -0
  39. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/sports.md +0 -0
  40. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/template.md +0 -0
  41. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/update.md +0 -0
  42. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/commands/workflow.md +0 -0
  43. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/authentication.md +0 -0
  44. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/configuration.md +0 -0
  45. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/installation.md +0 -0
  46. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/quickstart.md +0 -0
  47. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/repl.md +0 -0
  48. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/guide/running.md +0 -0
  49. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/index.md +0 -0
  50. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/package-lock.json +0 -0
  51. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/package.json +0 -0
  52. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/public/favicon.ico +0 -0
  53. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/public/machina-logo-dark.png +0 -0
  54. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/public/machina-logo-light.png +0 -0
  55. {machina_cli-0.4.2 → machina_cli-0.4.4}/docs-site/vercel.json +0 -0
  56. {machina_cli-0.4.2 → machina_cli-0.4.4}/install.ps1 +0 -0
  57. {machina_cli-0.4.2 → machina_cli-0.4.4}/install.sh +0 -0
  58. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/__init__.py +0 -0
  59. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/browser_auth.py +0 -0
  60. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/client.py +0 -0
  61. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/__init__.py +0 -0
  62. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/agent.py +0 -0
  63. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/auth.py +0 -0
  64. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/config_cmd.py +0 -0
  65. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/connect.py +0 -0
  66. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/connector.py +0 -0
  67. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/credentials.py +0 -0
  68. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/deploy.py +0 -0
  69. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/document.py +0 -0
  70. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/execution.py +0 -0
  71. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/factory.py +0 -0
  72. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/loop.py +0 -0
  73. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/mapping.py +0 -0
  74. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/mcp.py +0 -0
  75. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/project.py +0 -0
  76. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/prompt.py +0 -0
  77. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/skills.py +0 -0
  78. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/sports.py +0 -0
  79. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/template.py +0 -0
  80. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/commands/workflow.py +0 -0
  81. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/config.py +0 -0
  82. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/factory_client.py +0 -0
  83. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/loop_client.py +0 -0
  84. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/main.py +0 -0
  85. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/models/__init__.py +0 -0
  86. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/project_client.py +0 -0
  87. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/repl.py +0 -0
  88. {machina_cli-0.4.2 → machina_cli-0.4.4}/src/machina_cli/updater.py +0 -0
  89. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/__init__.py +0 -0
  90. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/test_cli_output.py +0 -0
  91. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/test_connect.py +0 -0
  92. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/test_mcp_url.py +0 -0
  93. {machina_cli-0.4.2 → machina_cli-0.4.4}/tests/test_sports_integration.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to machina-cli are documented here.
4
4
 
5
+ ## [0.4.4] - 2026-06-30
6
+
7
+ ### Fixed
8
+ - **`machina org usage` shows a readable label for nameless project stubs.** Some `user/projects/search` records are bare membership stubs (`project_id` only, no `project_name` — e.g. a deleted project's leftover association); they were rendered as a raw 24-char ObjectId in the "Skipped" line. They now show `(unnamed:<id8>)`. The project is still scanned (never silently excluded over a missing name), so a real project can't be dropped this way.
9
+
10
+ ## [0.4.3] - 2026-06-30
11
+
12
+ ### Fixed
13
+ - **`machina org usage` no longer silently drops a project on a transient error.** The big collections (e.g. `sbot-prd`) return intermittent `500`s under load; the previous build caught that as "unreachable" and skipped the project, producing a complete-looking but wildly undercounted total (e.g. 73M instead of ~440M when SBOT Prd dropped). Now:
14
+ - Each project session + execution page is **retried** on transient failures (`500`/timeout) with backoff, so the heavy projects scan through.
15
+ - A reachable project whose scan still fails is reported as **errored** and the run is flagged **INCOMPLETE / PARTIAL** in red (with the affected projects listed) — distinct from a benign **skip** of an undeployed/no-access project. A partial total is never presented as if it were whole. `--json` gains `incomplete`, `projects_errored`, `projects_skipped`.
16
+
5
17
  ## [0.4.2] - 2026-06-29
6
18
 
7
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: machina-cli
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: CLI for the Machina AI Agent platform
5
5
  Project-URL: Homepage, https://machina.gg
6
6
  Project-URL: Repository, https://github.com/machina-sports/machina-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "machina-cli"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "CLI for the Machina AI Agent platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -143,6 +143,14 @@ def use(
143
143
  # we paginate and sum client-side. A frozen upper date bound keeps pagination stable
144
144
  # as new executions arrive mid-scan.
145
145
  _TOKEN_FILTER_KEY = "execution_tokens.total_tokens"
146
+ _SCAN_RETRIES = 3 # big collections (e.g. sbot-prd) 500 intermittently under load; retry recovers
147
+ _RETRY_BACKOFF = 0.8
148
+
149
+
150
+ class _ScanIncomplete(Exception):
151
+ """A reachable project's scan kept failing after retries. Its tokens are missing,
152
+ so the org total must be flagged PARTIAL — never silently dropped (that would turn
153
+ a transient 500 on the biggest project into a plausible-looking but wrong total)."""
146
154
 
147
155
 
148
156
  def _resolve_org_projects(org_id: str) -> list[tuple[str, str]]:
@@ -161,8 +169,12 @@ def _resolve_org_projects(org_id: str) -> list[tuple[str, str]]:
161
169
  for proj in rows:
162
170
  if proj.get("organization_id") != org_id:
163
171
  continue
164
- pid = proj.get("project_id", proj.get("_id", ""))
165
- pname = proj.get("project_name", proj.get("name", pid))
172
+ pid = proj.get("project_id") or proj.get("_id") or ""
173
+ # Some membership records are bare stubs (project_id only, no name — e.g. a
174
+ # deleted project's leftover association). Show a readable label instead of a
175
+ # raw 24-char ObjectId, but still scan them so a real project is never
176
+ # silently dropped just because its name didn't resolve.
177
+ pname = proj.get("project_name") or proj.get("name") or f"(unnamed:{pid[:8]})"
166
178
  if pid and proj.get("status") != "archived":
167
179
  targets.append((pid, pname))
168
180
  pagination = res.get("pagination", {})
@@ -180,10 +192,30 @@ def _sum_project_tokens(project_id: str, token_filter: dict, page_size: int, on_
180
192
  ProjectClient) on auth/connection failure — callers should catch and skip.
181
193
  `on_page(count, total_tokens)` is invoked after each page for progress display.
182
194
  """
195
+ import time
183
196
  from collections import defaultdict
184
197
  from email.utils import parsedate_to_datetime
185
198
 
186
- client = ProjectClient(project_id)
199
+ def _retry(fn, *, tries, incomplete_on_fail):
200
+ # Retry transient failures: ProjectClient raises SystemExit on a 500, httpx
201
+ # raises on a timeout. On give-up, either re-raise (caller skips an undeployed
202
+ # project) or raise _ScanIncomplete (caller flags a reachable project's partial
203
+ # scan loudly instead of silently dropping it).
204
+ last = None
205
+ for attempt in range(tries):
206
+ try:
207
+ return fn()
208
+ except (SystemExit, httpx.HTTPError) as exc:
209
+ last = exc
210
+ if attempt == tries - 1:
211
+ if incomplete_on_fail:
212
+ raise _ScanIncomplete(project_id) from last
213
+ raise
214
+ time.sleep(_RETRY_BACKOFF * (attempt + 1))
215
+
216
+ # Project session. A persistent failure means undeployed/no-access — let it
217
+ # propagate so the caller records a benign skip.
218
+ client = _retry(lambda: ProjectClient(project_id), tries=2, incomplete_on_fail=False)
187
219
  proj = {"prompt": 0, "completion": 0, "total": 0, "count": 0}
188
220
  by_agent: dict = defaultdict(lambda: {"prompt": 0, "completion": 0, "total": 0, "count": 0})
189
221
  by_day: dict = defaultdict(lambda: {"total": 0, "count": 0})
@@ -192,9 +224,20 @@ def _sum_project_tokens(project_id: str, token_filter: dict, page_size: int, on_
192
224
  max_pages = 400 # safety net; the `total` check below normally breaks first
193
225
  truncated = False
194
226
  while True:
195
- res = client.post(
196
- "execution/agent-search",
197
- {"filters": token_filter, "page": page, "page_size": page_size, "sorters": ["_id", -1]},
227
+ # The search on a reachable project: a persistent failure (e.g. sbot-prd's
228
+ # heavy collection 500ing) means the scan is INCOMPLETE, not skippable.
229
+ res = _retry(
230
+ lambda: client.post(
231
+ "execution/agent-search",
232
+ {
233
+ "filters": token_filter,
234
+ "page": page,
235
+ "page_size": page_size,
236
+ "sorters": ["_id", -1],
237
+ },
238
+ ),
239
+ tries=_SCAN_RETRIES,
240
+ incomplete_on_fail=True,
198
241
  )
199
242
  rows = res.get("data", [])
200
243
  if not rows:
@@ -297,7 +340,8 @@ def usage(
297
340
  by_project: dict = {}
298
341
  by_agent: dict = defaultdict(lambda: {"prompt": 0, "completion": 0, "total": 0, "count": 0})
299
342
  by_day: dict = defaultdict(lambda: {"total": 0, "count": 0})
300
- unreachable: list[str] = []
343
+ skipped: list[str] = [] # undeployed / no access — expected, excluded
344
+ errored: list[str] = [] # reachable but scan failed after retries — total is PARTIAL
301
345
  truncated_projects: list[str] = []
302
346
 
303
347
  status_cm = (
@@ -308,7 +352,7 @@ def usage(
308
352
  n = len(targets)
309
353
  # _quiet_clients silences the core/project HTTP clients: undeployed or
310
354
  # inaccessible projects print a red error and raise SystemExit, which we catch
311
- # and record as unreachable — the leaked error would just be noise mid-scan.
355
+ # and record as skipped/errored — the leaked error would just be noise mid-scan.
312
356
  with status_cm as status, _quiet_clients():
313
357
  for i, (pid, pname) in enumerate(targets, 1):
314
358
 
@@ -322,11 +366,14 @@ def usage(
322
366
  proj, p_agents, p_days, truncated = _sum_project_tokens(
323
367
  pid, token_filter, page_size, on_page=_progress
324
368
  )
325
- # SystemExit: auth/lookup failure (undeployed/inaccessible project).
326
- # httpx.HTTPError: the client-api is slow, so a page can time out — skip
327
- # that project rather than aborting the whole multi-project scan.
369
+ except _ScanIncomplete:
370
+ # reachable project whose search kept failing — its tokens are MISSING,
371
+ # so the org total is partial (flagged loudly below), not silently dropped.
372
+ errored.append(pname)
373
+ continue
328
374
  except (SystemExit, httpx.HTTPError):
329
- unreachable.append(pname)
375
+ # login/lookup failed after retries -> undeployed or no access; expected.
376
+ skipped.append(pname)
330
377
  continue
331
378
  if proj["count"] == 0:
332
379
  continue
@@ -351,19 +398,27 @@ def usage(
351
398
  "by_project": by_project,
352
399
  "by_agent": dict(sorted(by_agent.items(), key=lambda kv: -kv[1]["total"])),
353
400
  "by_day": dict(sorted(by_day.items())),
354
- "projects_unreachable": unreachable,
401
+ "incomplete": bool(errored),
402
+ "projects_errored": errored,
403
+ "projects_skipped": skipped,
355
404
  "projects_truncated": truncated_projects,
356
405
  }
357
406
  console.print_json(_json.dumps(payload, default=str))
358
407
  return
359
408
 
360
409
  if grand["count"] == 0:
361
- console.print(
362
- f"[yellow]No token-bearing agent executions in the last {days} day(s) "
363
- f"for organization {org_id}.[/yellow]"
364
- )
365
- if unreachable:
366
- console.print(f"[dim]Unreachable projects: {', '.join(unreachable)}[/dim]")
410
+ if errored:
411
+ console.print(
412
+ f"[bold red]⚠ {len(errored)} project(s) failed to scan: "
413
+ f"{', '.join(errored)} — re-run to retry (results would be partial).[/bold red]"
414
+ )
415
+ else:
416
+ console.print(
417
+ f"[yellow]No token-bearing agent executions in the last {days} day(s) "
418
+ f"for organization {org_id}.[/yellow]"
419
+ )
420
+ if skipped:
421
+ console.print(f"[dim]Skipped (undeployed/no access): {', '.join(skipped)}[/dim]")
367
422
  return
368
423
 
369
424
  total = grand["total"]
@@ -373,11 +428,19 @@ def usage(
373
428
 
374
429
  from rich.panel import Panel
375
430
 
431
+ if errored:
432
+ console.print(
433
+ f"[bold red]⚠ INCOMPLETE — {len(errored)} reachable project(s) failed to scan "
434
+ f"and are NOT in the totals below:[/bold red] {', '.join(errored)}\n"
435
+ f"[red]The numbers below are PARTIAL. Re-run to retry those projects.[/red]\n"
436
+ )
437
+ total_label = "Total tokens (PARTIAL):" if errored else "Total tokens:"
438
+
376
439
  console.print(
377
440
  Panel.fit(
378
441
  f"[bold]Organization:[/bold] {org_id}\n"
379
442
  f"[bold]Window:[/bold] last {days}d ({date_from[:10]} → {date_to[:10]})\n"
380
- f"[bold]Total tokens:[/bold] {total:,}\n"
443
+ f"[bold]{total_label}[/bold] {total:,}\n"
381
444
  f"[bold] prompt:[/bold] {grand['prompt']:,} ({prompt_pct:.1f}%) "
382
445
  f"[bold]completion:[/bold] {grand['completion']:,} ({completion_pct:.1f}%)\n"
383
446
  f"[bold]Executions:[/bold] {grand['count']:,} "
@@ -427,8 +490,13 @@ def usage(
427
490
  dt.add_row(day, f"{agg['count']:,}", f"{agg['total']:,}")
428
491
  console.print(dt)
429
492
 
430
- if unreachable:
431
- console.print(f"\n[dim]Skipped (unreachable/undeployed): {', '.join(unreachable)}[/dim]")
493
+ if errored:
494
+ console.print(
495
+ f"\n[bold red]⚠ Excluded (scan failed): {', '.join(errored)} — totals are PARTIAL; "
496
+ "re-run to retry.[/bold red]"
497
+ )
498
+ if skipped:
499
+ console.print(f"[dim]Skipped (undeployed/no access): {', '.join(skipped)}[/dim]")
432
500
  if truncated_projects:
433
501
  console.print(
434
502
  f"[yellow]Note:[/yellow] hit the page cap for {', '.join(truncated_projects)} — "
@@ -155,33 +155,83 @@ def test_usage_resolves_projects_for_org():
155
155
  assert payload["totals"]["total"] == _TOTAL
156
156
 
157
157
 
158
- def test_usage_skips_unreachable_project_without_failing():
159
- bad = MagicMock(side_effect=SystemExit(1)) # ProjectClient() raises -> project skipped
158
+ def test_resolve_org_projects_labels_unnamed_stub():
159
+ # a bare membership stub (project_id only, no name) gets a readable label, not a
160
+ # raw ObjectId — and is still returned as a target (never silently excluded)
161
+ from machina_cli.commands import org as org_mod
162
+
163
+ projects = [
164
+ {"project_id": "p1", "project_name": "Real One", "organization_id": "org_1"},
165
+ {"project_id": "69ffa178815a5305f540dcac", "organization_id": "org_1"}, # bare stub
166
+ {"project_id": "p3", "project_name": "Other Org", "organization_id": "org_2"},
167
+ ]
168
+ with patch("machina_cli.commands.org.MachinaClient", _projects_client(projects)):
169
+ targets = dict(org_mod._resolve_org_projects("org_1"))
170
+ assert targets["p1"] == "Real One"
171
+ assert targets["69ffa178815a5305f540dcac"] == "(unnamed:69ffa178)"
172
+ assert "p3" not in targets # other org excluded
173
+
174
+
175
+ def test_usage_skips_undeployed_project_as_benign():
176
+ # ProjectClient() (session login) fails persistently -> undeployed/no access ->
177
+ # benign skip, NOT marked incomplete.
178
+ bad = MagicMock(side_effect=SystemExit(1))
160
179
  with (
161
180
  patch("machina_cli.commands.org.get_config", return_value=None),
181
+ patch("time.sleep", lambda *a: None), # don't wait on retry backoff
162
182
  patch("machina_cli.commands.org.ProjectClient", bad),
163
183
  ):
164
184
  result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
165
185
  assert result.exit_code == 0, result.output
166
186
  payload = json.loads(result.output)
167
187
  assert payload["totals"]["count"] == 0
168
- assert payload["projects_unreachable"] == ["p1"]
188
+ assert payload["projects_skipped"] == ["p1"]
189
+ assert payload["projects_errored"] == []
190
+ assert payload["incomplete"] is False
169
191
 
170
192
 
171
- def test_usage_skips_project_on_network_error():
172
- # the client-api is slow; a page can time out (httpx.HTTPError) — skip the
173
- # project rather than aborting the whole multi-project scan
193
+ def test_usage_flags_incomplete_when_reachable_project_scan_fails():
194
+ # session opens fine (reachable) but the search keeps 500ing/timing out -> the
195
+ # project is ERRORED and the total must be flagged PARTIAL, never silently dropped.
174
196
  client = MagicMock()
175
197
  client.return_value.post.side_effect = httpx.ReadTimeout("slow")
176
198
  with (
177
199
  patch("machina_cli.commands.org.get_config", return_value=None),
200
+ patch("time.sleep", lambda *a: None),
178
201
  patch("machina_cli.commands.org.ProjectClient", client),
179
202
  ):
180
203
  result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
181
204
  assert result.exit_code == 0, result.output
182
205
  payload = json.loads(result.output)
183
206
  assert payload["totals"]["count"] == 0
184
- assert payload["projects_unreachable"] == ["p1"]
207
+ assert payload["projects_errored"] == ["p1"]
208
+ assert payload["projects_skipped"] == []
209
+ assert payload["incomplete"] is True
210
+
211
+
212
+ def test_usage_retries_then_succeeds():
213
+ # transient failures (sbot-prd-style 500s) recover on retry -> project IS counted
214
+ client = MagicMock()
215
+ calls = {"n": 0}
216
+
217
+ def _post(path, payload=None):
218
+ calls["n"] += 1
219
+ if calls["n"] <= 2: # fail the first two attempts, succeed on the third
220
+ raise httpx.ReadTimeout("transient 500")
221
+ return {"data": _ROWS, "total_documents": len(_ROWS), "status": True}
222
+
223
+ client.return_value.post.side_effect = _post
224
+ with (
225
+ patch("machina_cli.commands.org.get_config", return_value=None),
226
+ patch("time.sleep", lambda *a: None),
227
+ patch("machina_cli.commands.org.ProjectClient", client),
228
+ ):
229
+ result = runner.invoke(app, ["org", "usage", "--project", "p1", "--json"])
230
+ assert result.exit_code == 0, result.output
231
+ payload = json.loads(result.output)
232
+ assert payload["totals"]["total"] == _TOTAL # recovered, fully counted
233
+ assert payload["incomplete"] is False
234
+ assert payload["projects_errored"] == []
185
235
 
186
236
 
187
237
  def test_usage_no_org_no_project_errors():
File without changes
File without changes
File without changes
File without changes
File without changes