buildai-cli 0.3.149__tar.gz → 0.3.151__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 (82) hide show
  1. buildai_cli-0.3.151/AGENTS.md +24 -0
  2. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/Dockerfile.rescaling-migration-jobs +1 -0
  3. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/Dockerfile.rescaling-migration-jobs.dockerignore +3 -0
  4. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/PKG-INFO +1 -1
  5. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/egoexo.py +2 -0
  6. buildai_cli-0.3.151/cli/commands/egoexo_config.py +557 -0
  7. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/ingest.py +30 -0
  8. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/pyproject.toml +1 -1
  9. buildai_cli-0.3.149/AGENTS.md +0 -50
  10. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/.gitignore +0 -0
  11. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/CLAUDE.md +0 -0
  12. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/buildai_bootstrap.py +0 -0
  13. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/__init__.py +0 -0
  14. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/_has_core.py +0 -0
  15. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/auth_local.py +0 -0
  16. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/__init__.py +0 -0
  17. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/api_proxy.py +0 -0
  18. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/auth.py +0 -0
  19. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/__init__.py +0 -0
  20. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/broker.py +0 -0
  21. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/common.py +0 -0
  22. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/migrate.py +0 -0
  23. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/query.py +0 -0
  24. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/rescaling.py +0 -0
  25. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/schema.py +0 -0
  26. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/status.py +0 -0
  27. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/db/tunnel.py +0 -0
  28. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/dev.py +0 -0
  29. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/doctor.py +0 -0
  30. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/ego_frame_search.py +0 -0
  31. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/gigcamera.py +0 -0
  32. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/ingest_docs.py +0 -0
  33. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/intake.py +0 -0
  34. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/label_replay_recovery.py +0 -0
  35. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/labeling.py +0 -0
  36. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/processing.py +0 -0
  37. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/rescaling.py +0 -0
  38. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/spec.py +0 -0
  39. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/commands/spec_pr.py +0 -0
  40. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/config.py +0 -0
  41. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/console.py +0 -0
  42. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/context.py +0 -0
  43. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/db_broker.py +0 -0
  44. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/guard.py +0 -0
  45. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/internal_api.py +0 -0
  46. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/labeling_onboarding.py +0 -0
  47. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/main.py +0 -0
  48. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/nl_query/__init__.py +0 -0
  49. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/nl_query/dataset_tools.py +0 -0
  50. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/ops_init.py +0 -0
  51. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/output.py +0 -0
  52. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/pagination.py +0 -0
  53. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/__init__.py +0 -0
  54. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/canonical.py +0 -0
  55. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/export.py +0 -0
  56. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/feed_proofs.py +0 -0
  57. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/fence.py +0 -0
  58. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/importer.py +0 -0
  59. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/jobs.py +0 -0
  60. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/management.py +0 -0
  61. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/orchestrator.py +0 -0
  62. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/probes.py +0 -0
  63. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/reconcile.py +0 -0
  64. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/run_record.py +0 -0
  65. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/runtime.py +0 -0
  66. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_export_closeout.sql +0 -0
  67. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_export_prepare.sql +0 -0
  68. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_fence.sql +0 -0
  69. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_unfreeze.sql +0 -0
  70. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_export_closeout.sql +0 -0
  71. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_export_prepare.sql +0 -0
  72. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_fence.sql +0 -0
  73. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_unfreeze.sql +0 -0
  74. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/storage.py +0 -0
  75. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rescaling_migration/transform.py +0 -0
  76. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/cli/rls_guard.py +0 -0
  77. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_logical_backup/__init__.py +0 -0
  78. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_logical_backup/__main__.py +0 -0
  79. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_migration_job/__init__.py +0 -0
  80. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_migration_job/__main__.py +0 -0
  81. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_recovery_extractor/__init__.py +0 -0
  82. {buildai_cli-0.3.149 → buildai_cli-0.3.151}/rescaling_recovery_extractor/__main__.py +0 -0
@@ -0,0 +1,24 @@
1
+ # Build AI CLI
2
+
3
+ The installed CLI calls the API. The workspace CLI can also inspect the database.
4
+ Keep those modes separate: installed clients must work without `core/` or database credentials.
5
+
6
+ ## Database commands
7
+
8
+ - Database commands use a profile-specific `alloydb-auth-proxy` broker. Its state in
9
+ `~/.buildai/db-brokers` is shared across worktrees. Use `db broker status`,
10
+ `ensure`, or `stop` to manage it.
11
+ - Select the database with `buildai db --env production|staging|dev`.
12
+ App targeting comes from environment variables and the Makefile, not a saved CLI context.
13
+ - Staging needs an IAP, VPN, or SOCKS route. For the CLI tunnel, run
14
+ `buildai db --env staging tunnel`, then query with `--all-proxy socks5://127.0.0.1:1080`.
15
+ - Before counting frame labels by status or mode, read the
16
+ [labeling query rule](../../../core/dal/labeling/AGENTS.md).
17
+ A pushed-down filter can produce incorrect totals.
18
+
19
+ ## Check your change
20
+
21
+ Run the relevant tests in `tests/cli/` from the repo root. For broker changes,
22
+ include `tests/cli/test_db_broker.py`; for query handling, include
23
+ `tests/cli/test_db_query_analysis.py`. Live queries are needed only when the
24
+ change depends on connection behavior.
@@ -20,6 +20,7 @@ WORKDIR /app
20
20
  # uv must see every workspace member declaration to honor the checked-in lockfile.
21
21
  COPY pyproject.toml uv.lock ./
22
22
  COPY core/pyproject.toml core/pyproject.toml
23
+ COPY packages/egoexo/pyproject.toml packages/egoexo/pyproject.toml
23
24
  COPY services/pyproject.toml services/pyproject.toml
24
25
  COPY apps/buildai/buildai-api/pyproject.toml apps/buildai/buildai-api/pyproject.toml
25
26
  COPY apps/buildai/buildai-cli/pyproject.toml apps/buildai/buildai-cli/pyproject.toml
@@ -3,6 +3,9 @@
3
3
  !uv.lock
4
4
  !core/
5
5
  !core/pyproject.toml
6
+ !packages/
7
+ !packages/egoexo/
8
+ !packages/egoexo/pyproject.toml
6
9
  !services/
7
10
  !services/pyproject.toml
8
11
  !apps/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: buildai-cli
3
- Version: 0.3.149
3
+ Version: 0.3.151
4
4
  Summary: Build AI CLI (Typer)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: google-crc32c>=1.7.1
@@ -10,10 +10,12 @@ import httpx
10
10
  import typer
11
11
 
12
12
  from cli.commands.api_proxy import _build_url
13
+ from cli.commands.egoexo_config import app as config_app
13
14
  from cli.config import resolve_api_url, resolve_credential
14
15
  from cli.console import error
15
16
 
16
17
  app = typer.Typer(help="Inspect EgoExo verdict-plane outcomes.", no_args_is_help=True)
18
+ app.add_typer(config_app, name="config")
17
19
 
18
20
 
19
21
  def _request(path: str, *, query_params: list[str] | None = None) -> None:
@@ -0,0 +1,557 @@
1
+ """Operator commands for the EgoExo runtime configuration store.
2
+
3
+ The store is one JSON object in the capture bucket. The processing API reads it
4
+ every thirty seconds and lets each stored field override the matching environment
5
+ value. These commands read the object, show the effective configuration, and
6
+ write new revisions. Every write needs ``--write --reason``. Reads never change
7
+ anything.
8
+
9
+ The commands need the workspace ``egoexo`` package and the Google Cloud Storage
10
+ client, so run them from the repo with ``uv run buildai egoexo config ...``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ import tempfile
18
+ from dataclasses import replace
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import typer
23
+
24
+ from cli.console import error, info
25
+
26
+ app = typer.Typer(
27
+ help="Show and change the EgoExo runtime configuration store.", no_args_is_help=True
28
+ )
29
+
30
+ DEPLOYMENT_RELATIVE = Path("infra/production/services/processing-api")
31
+ LOCAL_JSON_MAX_BYTES = 1024 * 1024
32
+ ASSET_PREFIX = "derived/runtime-assets/"
33
+
34
+ URI_OPTION = typer.Option(
35
+ None,
36
+ "--uri",
37
+ envvar="EGOEXO_RUNTIME_CONFIG_URI",
38
+ help="Runtime config object. Default: the production object in gs://buildai-ego-exo.",
39
+ )
40
+ DEPLOYMENT_OPTION = typer.Option(
41
+ None,
42
+ "--deployment",
43
+ help=(
44
+ "Checked-in deployment directory whose egoexo-recipe.json and "
45
+ "egoexo-trusted-assets.json are the environment fallback shown for env fields. "
46
+ "Default: infra/production/services/processing-api when run inside the repo."
47
+ ),
48
+ )
49
+ WRITE_OPTION = typer.Option(False, "--write", help="Write the revision. Omit for a dry run.")
50
+ REASON_OPTION = typer.Option(
51
+ None, "--reason", help="Why this revision exists. Required with --write."
52
+ )
53
+
54
+
55
+ def _runtime_config():
56
+ """Import the store module lazily so installed clients without it get a clear message."""
57
+ try:
58
+ from egoexo.runtime.pipeline import runtime_config
59
+ except ImportError as exc:
60
+ error(
61
+ "'buildai egoexo config' needs the workspace egoexo package and the GCS client.\n"
62
+ " Run it from the repo: uv run buildai egoexo config ..."
63
+ )
64
+ raise typer.Exit(1) from exc
65
+ return runtime_config
66
+
67
+
68
+ def _store(uri: str | None):
69
+ """Build a store on the real GCS adapter. Tests replace this function."""
70
+ rc = _runtime_config()
71
+ from egoexo.adapters.gcs.publication import GCSStorage
72
+
73
+ return rc.RuntimeConfigStore(uri or rc.DEFAULT_RUNTIME_CONFIG_URI, storage=GCSStorage())
74
+
75
+
76
+ def _author() -> str:
77
+ """Return the active gcloud account. It is recorded as the revision author."""
78
+ from cli.auth_local import _gcloud_value
79
+
80
+ account = _gcloud_value("account")
81
+ if not account:
82
+ error(
83
+ "No active gcloud account. Run `gcloud auth login` first; the account is "
84
+ "recorded as the revision author."
85
+ )
86
+ raise typer.Exit(1)
87
+ return account
88
+
89
+
90
+ def _deployment_dir(explicit: Path | None) -> Path | None:
91
+ """Return the checked-in deployment directory, or None when it is not available."""
92
+ if explicit is not None:
93
+ if not explicit.is_dir():
94
+ error(f"Deployment directory not found: {explicit}")
95
+ raise typer.Exit(1)
96
+ return explicit
97
+ for root in (Path.cwd(), *Path.cwd().parents):
98
+ candidate = root / DEPLOYMENT_RELATIVE
99
+ if (candidate / "egoexo-recipe.json").is_file():
100
+ return candidate
101
+ return None
102
+
103
+
104
+ def _load_json_file(path: Path) -> Any:
105
+ """Read a bounded local JSON file."""
106
+ if not path.is_file():
107
+ error(f"File not found: {path}")
108
+ raise typer.Exit(1)
109
+ data = path.read_bytes()
110
+ if len(data) > LOCAL_JSON_MAX_BYTES:
111
+ error(f"{path} exceeds {LOCAL_JSON_MAX_BYTES} bytes")
112
+ raise typer.Exit(1)
113
+ try:
114
+ return json.loads(data)
115
+ except ValueError as exc:
116
+ error(f"{path} is not valid JSON: {exc}")
117
+ raise typer.Exit(1) from exc
118
+
119
+
120
+ def _deployment_env(deployment: Path | None):
121
+ """Build the environment fallback from checked-in files. Flags stay unknown."""
122
+ rc = _runtime_config()
123
+ if deployment is None:
124
+ return rc.EnvironmentConfig(), None
125
+ recipe = deployment / "egoexo-recipe.json"
126
+ assets = deployment / "egoexo-trusted-assets.json"
127
+ return (
128
+ rc.EnvironmentConfig(
129
+ recipe_json=recipe.read_text() if recipe.is_file() else None,
130
+ trusted_assets_json=assets.read_text() if assets.is_file() else "[]",
131
+ ),
132
+ deployment,
133
+ )
134
+
135
+
136
+ def _read_base(store):
137
+ """Read the current document once. Returns (document, generation, problem)."""
138
+ try:
139
+ document, generation = store.read()
140
+ return document, generation, None
141
+ except (ValueError, TypeError, KeyError) as exc:
142
+ generation = str(store.storage.metadata(store.uri)["generation"])
143
+ return None, generation, f"{type(exc).__name__}: {exc}"
144
+
145
+
146
+ def _emit(payload: dict[str, Any]) -> None:
147
+ sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
148
+
149
+
150
+ def _recipe_summary(recipe: dict[str, Any]) -> dict[str, Any]:
151
+ stages = {}
152
+ for stage, spec in sorted(recipe.get("preset", {}).items()):
153
+ entry: dict[str, Any] = {"method": spec.get("method")}
154
+ if "config" in spec:
155
+ entry["config_uri"] = spec["config"].get("uri")
156
+ for key in ("frames_per_job", "selection", "selections"):
157
+ if key in spec:
158
+ entry[key] = spec[key]
159
+ stages[stage] = entry
160
+ return {
161
+ key: recipe.get(key)
162
+ for key in ("enabled", "gpu_enabled", "bucket", "max_pages", "max_plans")
163
+ if key in recipe
164
+ } | {"stages": stages}
165
+
166
+
167
+ def _task_class_summary(classes: list[dict[str, Any]]) -> list[dict[str, Any]]:
168
+ keys = (
169
+ "name",
170
+ "machine_type",
171
+ "provisioning_model",
172
+ "max_active_tasks",
173
+ "max_tasks_per_capture",
174
+ "runtime_seconds",
175
+ "image",
176
+ )
177
+ return [{key: item.get(key) for key in keys if key in item} for item in classes]
178
+
179
+
180
+ def _asset_summary(assets: list[dict[str, Any]]) -> list[dict[str, Any]]:
181
+ return [{"role": a.get("role"), "uri": a.get("uri"), "sha256": a.get("sha256")} for a in assets]
182
+
183
+
184
+ ENV_NOTE = "environment value lives in the processing API deployment; not readable locally"
185
+
186
+
187
+ @app.command("show")
188
+ def show(
189
+ uri: str | None = URI_OPTION,
190
+ deployment: Path | None = DEPLOYMENT_OPTION,
191
+ full: bool = typer.Option(False, "--full", help="Print full values instead of summaries."),
192
+ ) -> None:
193
+ """Show the effective configuration, the source of each field, and the store generation."""
194
+ rc = _runtime_config()
195
+ store = _store(uri)
196
+ document, generation, problem = _read_base(store)
197
+ env, deployment_dir = _deployment_env(_deployment_dir(deployment))
198
+ loaded = rc.LoadedDocument(document, generation) if document is not None else None
199
+ effective = rc.merge_effective(env, loaded)
200
+ fields: dict[str, Any] = {}
201
+
202
+ def value_or_none(name: str, raw: str | None, summarize):
203
+ if raw is None:
204
+ return None
205
+ parsed = json.loads(raw)
206
+ return parsed if full else summarize(parsed)
207
+
208
+ for name, raw, summarize, filename in (
209
+ ("recipe", effective.recipe_json, _recipe_summary, "egoexo-recipe.json"),
210
+ (
211
+ "trusted_assets",
212
+ effective.trusted_assets_json,
213
+ _asset_summary,
214
+ "egoexo-trusted-assets.json",
215
+ ),
216
+ ):
217
+ source = effective.source(name)
218
+ entry: dict[str, Any] = {"source": source}
219
+ if source == "store" or deployment_dir is not None:
220
+ entry["value"] = value_or_none(name, raw, summarize)
221
+ if source == "env":
222
+ entry["note"] = (
223
+ f"environment value read from {deployment_dir / filename}"
224
+ if deployment_dir is not None
225
+ else ENV_NOTE
226
+ )
227
+ fields[name] = entry
228
+ source = effective.source("task_classes")
229
+ fields["task_classes"] = {"source": source}
230
+ if source == "store":
231
+ fields["task_classes"]["value"] = value_or_none(
232
+ "task_classes", effective.task_classes_json, _task_class_summary
233
+ )
234
+ else:
235
+ fields["task_classes"]["note"] = ENV_NOTE + " (rendered by terraform task_classes.tf)"
236
+ fields["flags"] = {
237
+ name: (
238
+ {"source": "store", "value": getattr(effective, name)}
239
+ if effective.source(name) == "store"
240
+ else {"source": "env", "note": ENV_NOTE}
241
+ )
242
+ for name in rc.FLAG_NAMES
243
+ }
244
+ _emit(
245
+ {
246
+ "uri": store.uri,
247
+ "generation": None if generation == "0" else generation,
248
+ "revision": (
249
+ {
250
+ "author": document.author,
251
+ "reason": document.reason,
252
+ "created_at": document.created_at,
253
+ "previous_generation": document.previous_generation,
254
+ }
255
+ if document is not None
256
+ else None
257
+ ),
258
+ "problem": problem,
259
+ "fields": fields,
260
+ }
261
+ )
262
+ if problem:
263
+ raise typer.Exit(1)
264
+
265
+
266
+ def _revise(store, *, write: bool, reason: str | None, change: dict[str, Any], mutate) -> None:
267
+ """Validate a new revision, print the plan, and write it only with --write --reason."""
268
+ rc = _runtime_config()
269
+ document, generation, problem = _read_base(store)
270
+ base = document if document is not None else rc.RuntimeConfigDocument()
271
+ revised = mutate(base)
272
+ try:
273
+ revised.validate()
274
+ except (ValueError, TypeError, KeyError) as exc:
275
+ error(f"Rejected by the production parser: {type(exc).__name__}: {exc}")
276
+ raise typer.Exit(1) from exc
277
+ plan = {
278
+ "uri": store.uri,
279
+ "expected_generation": None if generation == "0" else generation,
280
+ "current_problem": problem,
281
+ "change": change,
282
+ "overrides_after": {
283
+ "recipe": revised.recipe is not None,
284
+ "trusted_assets": revised.trusted_assets is not None,
285
+ "task_classes": revised.task_classes is not None,
286
+ "flags": dict(revised.flags),
287
+ },
288
+ }
289
+ if not write:
290
+ _emit(plan | {"written": False})
291
+ info('Dry run. Nothing was written. Re-run with --write --reason "<text>".')
292
+ return
293
+ if not reason or not reason.strip():
294
+ error("--reason is required with --write.")
295
+ raise typer.Exit(1)
296
+ author = _author()
297
+ try:
298
+ new_generation = store.write(
299
+ revised, author=author, reason=reason, expected_generation=generation
300
+ )
301
+ except rc.GenerationMismatch as exc:
302
+ error("The object changed since it was read. Re-run the command to read it again.")
303
+ raise typer.Exit(1) from exc
304
+ _emit(
305
+ plan
306
+ | {
307
+ "written": True,
308
+ "generation": new_generation,
309
+ "history_uri": store.history_uri(new_generation),
310
+ "author": author,
311
+ "reason": reason,
312
+ }
313
+ )
314
+
315
+
316
+ @app.command("set-recipe")
317
+ def set_recipe(
318
+ file: Path = typer.Argument(..., help="Recipe JSON, the same document the API loads."),
319
+ uri: str | None = URI_OPTION,
320
+ write: bool = WRITE_OPTION,
321
+ reason: str | None = REASON_OPTION,
322
+ ) -> None:
323
+ """Store a recipe that overrides EGOEXO_CAPTURE_RECIPE_JSON."""
324
+ recipe = _load_json_file(file)
325
+ _revise(
326
+ _store(uri),
327
+ write=write,
328
+ reason=reason,
329
+ change={"field": "recipe", "file": str(file)},
330
+ mutate=lambda base: replace(base, recipe=recipe),
331
+ )
332
+
333
+
334
+ @app.command("set-task-classes")
335
+ def set_task_classes(
336
+ file: Path = typer.Argument(..., help="Task class list JSON, one to three classes."),
337
+ uri: str | None = URI_OPTION,
338
+ write: bool = WRITE_OPTION,
339
+ reason: str | None = REASON_OPTION,
340
+ ) -> None:
341
+ """Store task classes that override BUILDAI_EGOEXO_ASSIGNED_TASK_CLASSES_JSON."""
342
+ classes = _load_json_file(file)
343
+ _revise(
344
+ _store(uri),
345
+ write=write,
346
+ reason=reason,
347
+ change={"field": "task_classes", "file": str(file)},
348
+ mutate=lambda base: replace(base, task_classes=classes),
349
+ )
350
+
351
+
352
+ @app.command("set-trusted-assets")
353
+ def set_trusted_assets(
354
+ file: Path = typer.Argument(..., help="Trusted asset list JSON with pinned identities."),
355
+ uri: str | None = URI_OPTION,
356
+ write: bool = WRITE_OPTION,
357
+ reason: str | None = REASON_OPTION,
358
+ ) -> None:
359
+ """Store trusted assets that override BUILDAI_EGOEXO_CAPTURE_TRUSTED_ASSETS_JSON."""
360
+ assets = _load_json_file(file)
361
+ _revise(
362
+ _store(uri),
363
+ write=write,
364
+ reason=reason,
365
+ change={"field": "trusted_assets", "file": str(file)},
366
+ mutate=lambda base: replace(base, trusted_assets=assets),
367
+ )
368
+
369
+
370
+ @app.command("set-flag")
371
+ def set_flag(
372
+ name: str = typer.Argument(
373
+ ...,
374
+ help="assigned_dispatch_enabled, auto_advance_enabled or native_discovery_enabled.",
375
+ ),
376
+ value: str = typer.Argument(..., help="true or false."),
377
+ uri: str | None = URI_OPTION,
378
+ write: bool = WRITE_OPTION,
379
+ reason: str | None = REASON_OPTION,
380
+ ) -> None:
381
+ """Store one lane flag that overrides its environment variable."""
382
+ rc = _runtime_config()
383
+ if name not in rc.FLAG_NAMES:
384
+ error(f"Unknown flag {name!r}. Choose one of: {', '.join(rc.FLAG_NAMES)}")
385
+ raise typer.Exit(1)
386
+ if value.lower() not in {"true", "false"}:
387
+ error("Flag value must be true or false.")
388
+ raise typer.Exit(1)
389
+ enabled = value.lower() == "true"
390
+ _revise(
391
+ _store(uri),
392
+ write=write,
393
+ reason=reason,
394
+ change={"field": f"flags.{name}", "value": enabled},
395
+ mutate=lambda base: replace(base, flags=dict(base.flags) | {name: enabled}),
396
+ )
397
+
398
+
399
+ @app.command("clear")
400
+ def clear(
401
+ field: str = typer.Argument(
402
+ ..., help="recipe, trusted_assets, task_classes, or a flag name to hand back to env."
403
+ ),
404
+ uri: str | None = URI_OPTION,
405
+ write: bool = WRITE_OPTION,
406
+ reason: str | None = REASON_OPTION,
407
+ ) -> None:
408
+ """Remove one stored override so the environment value applies again."""
409
+ rc = _runtime_config()
410
+ if field in rc.VALUE_FIELDS:
411
+ mutate = lambda base: replace(base, **{field: None}) # noqa: E731
412
+ elif field in rc.FLAG_NAMES:
413
+ mutate = lambda base: replace( # noqa: E731
414
+ base, flags={k: v for k, v in base.flags.items() if k != field}
415
+ )
416
+ else:
417
+ error(f"Unknown field {field!r}. Choose one of: {', '.join(rc.FIELD_NAMES)}")
418
+ raise typer.Exit(1)
419
+ _revise(
420
+ _store(uri),
421
+ write=write,
422
+ reason=reason,
423
+ change={"field": field, "value": "environment"},
424
+ mutate=mutate,
425
+ )
426
+
427
+
428
+ def _asset_group(uri: str) -> str | None:
429
+ """Return the <group> segment of derived/runtime-assets/<group>/<sha256>/<name>."""
430
+ marker = uri.find(ASSET_PREFIX)
431
+ if marker < 0:
432
+ return None
433
+ parts = uri[marker + len(ASSET_PREFIX) :].split("/")
434
+ return parts[0] if len(parts) == 3 and parts[0] else None
435
+
436
+
437
+ @app.command("pin-config-asset")
438
+ def pin_config_asset(
439
+ stage: str = typer.Option(..., "--stage", help="Recipe stage whose config changes."),
440
+ file: Path = typer.Option(..., "--file", help="Local JSON config to upload."),
441
+ group: str | None = typer.Option(
442
+ None, "--group", help="Asset group. Default: the group of the stage's current config."
443
+ ),
444
+ name: str | None = typer.Option(
445
+ None, "--name", help="Object name under the SHA256 folder. Default: the file name."
446
+ ),
447
+ uri: str | None = URI_OPTION,
448
+ deployment: Path | None = DEPLOYMENT_OPTION,
449
+ write: bool = WRITE_OPTION,
450
+ reason: str | None = REASON_OPTION,
451
+ ) -> None:
452
+ """Upload a content-addressed stage config and pin it in the recipe and trusted assets.
453
+
454
+ The object lands at derived/runtime-assets/<group>/<sha256>/<name>. After the
455
+ upload the command reads it back and checks generation, size, CRC32C and
456
+ SHA256. The recipe's stage config, the method config_sha256, and the trusted
457
+ asset list change together in one new store revision.
458
+ """
459
+ _runtime_config()
460
+ from egoexo.adapters.gcs import publication
461
+
462
+ _load_json_file(file)
463
+ store = _store(uri)
464
+ document, generation, problem = _read_base(store)
465
+ env, deployment_dir = _deployment_env(_deployment_dir(deployment))
466
+ if document is not None and document.recipe is not None:
467
+ recipe = json.loads(json.dumps(document.recipe))
468
+ recipe_source = "store"
469
+ elif env.recipe_json:
470
+ recipe = json.loads(env.recipe_json)
471
+ recipe_source = f"env ({deployment_dir / 'egoexo-recipe.json'})"
472
+ else:
473
+ error("No recipe in the store and no --deployment recipe to start from.")
474
+ raise typer.Exit(1)
475
+ if document is not None and document.trusted_assets is not None:
476
+ assets = json.loads(json.dumps(document.trusted_assets))
477
+ else:
478
+ assets = json.loads(env.trusted_assets_json)
479
+ spec = recipe.get("preset", {}).get(stage)
480
+ if not isinstance(spec, dict) or not isinstance(spec.get("method"), dict):
481
+ error(f"Stage {stage!r} is not in the recipe preset.")
482
+ raise typer.Exit(1)
483
+ old = spec.get("config")
484
+ group = group or (_asset_group(old["uri"]) if isinstance(old, dict) else None)
485
+ if not group or "/" in group:
486
+ error("Pass --group; the stage has no current config to take the group from.")
487
+ raise typer.Exit(1)
488
+ name = name or file.name
489
+ if "/" in name or name in {"", ".", ".."}:
490
+ error("--name must be a plain file name.")
491
+ raise typer.Exit(1)
492
+ size, sha, crc = publication.identity(file)
493
+ target = f"gs://{recipe['bucket']}/{ASSET_PREFIX}{group}/{sha}/{name}"
494
+ change = {
495
+ "stage": stage,
496
+ "recipe_source": recipe_source,
497
+ "file": str(file),
498
+ "target_uri": target,
499
+ "sha256": sha,
500
+ "size_bytes": size,
501
+ "crc32c_base64": crc,
502
+ "replaces_config_uri": old["uri"] if isinstance(old, dict) else None,
503
+ }
504
+ if not write:
505
+ _emit(
506
+ {
507
+ "uri": store.uri,
508
+ "expected_generation": None if generation == "0" else generation,
509
+ "current_problem": problem,
510
+ "change": change,
511
+ "written": False,
512
+ }
513
+ )
514
+ info('Dry run. Nothing was uploaded or written. Re-run with --write --reason "<text>".')
515
+ return
516
+ if not reason or not reason.strip():
517
+ error("--reason is required with --write.")
518
+ raise typer.Exit(1)
519
+ with tempfile.TemporaryDirectory() as scratch:
520
+ published = publication.publish(store.storage, target, file, Path(scratch))
521
+ check = Path(scratch) / "readback.json"
522
+ store.storage.download(target, published.generation, check, size)
523
+ if publication.identity(check) != (size, sha, crc):
524
+ error("Readback of the uploaded object did not match the local file.")
525
+ raise typer.Exit(1)
526
+ ref = {
527
+ "role": "producer_config",
528
+ "uri": target,
529
+ "generation": published.generation,
530
+ "size_bytes": size,
531
+ "sha256": sha,
532
+ "sha256_verification": "declared",
533
+ "crc32c_base64": crc,
534
+ }
535
+ spec["config"] = ref
536
+ spec["method"] = dict(spec["method"]) | {"config_sha256": sha}
537
+ replaced = False
538
+ for index, asset in enumerate(assets):
539
+ if (
540
+ isinstance(old, dict)
541
+ and asset.get("uri") == old["uri"]
542
+ and asset.get("role") == ref["role"]
543
+ ):
544
+ assets[index] = ref
545
+ replaced = True
546
+ if not replaced and not any(
547
+ a.get("uri") == target and a.get("role") == ref["role"] for a in assets
548
+ ):
549
+ assets.append(ref)
550
+ change |= {"uploaded_generation": published.generation, "trusted_asset_replaced": replaced}
551
+ _revise(
552
+ store,
553
+ write=True,
554
+ reason=reason,
555
+ change=change,
556
+ mutate=lambda base: replace(base, recipe=recipe, trusted_assets=assets),
557
+ )
@@ -6,6 +6,7 @@ import json
6
6
  import sys
7
7
  from pathlib import Path
8
8
  from typing import Annotated
9
+ from uuid import UUID
9
10
 
10
11
  import typer
11
12
 
@@ -285,6 +286,35 @@ def fleet_readiness(
285
286
  info(f"next: {action}")
286
287
 
287
288
 
289
+ @fleet_app.command("retry-upload")
290
+ def fleet_retry_upload(
291
+ server: str = typer.Option(..., "--server", help="Inventory server number or exact name."),
292
+ handoff_id: UUID = typer.Option(..., "--handoff-id"),
293
+ transport: str = typer.Option("tailscale", "--transport", help=TRANSPORT_HELP),
294
+ write: bool = typer.Option(False, "--write", help="Retry only the reviewed source state."),
295
+ expected_revision: str | None = typer.Option(None, "--expected-revision"),
296
+ reason: str | None = typer.Option(None, "--reason"),
297
+ ) -> None:
298
+ """Preview a failed card, or retry its exact marker revision after repair."""
299
+ if write and (not expected_revision or not (reason or "").strip()):
300
+ raise typer.BadParameter("--write requires --expected-revision and --reason")
301
+ engine = _ingest_engine()
302
+ try:
303
+ payload = engine.retry_mono_upload(
304
+ server=server,
305
+ handoff_id=str(handoff_id),
306
+ transport=transport,
307
+ write=write,
308
+ expected_revision=expected_revision,
309
+ reason=reason,
310
+ )
311
+ except ValueError as exc:
312
+ payload = {"error": {"code": "invalid_request", "message": str(exc)}}
313
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
314
+ if "error" in payload:
315
+ raise typer.Exit(1)
316
+
317
+
288
318
  @fleet_app.command("preflight")
289
319
  def fleet_preflight(
290
320
  scope: str = typer.Option(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.149"
7
+ version = "0.3.151"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -1,50 +0,0 @@
1
- # BuildAI CLI — Typer CLI with standalone (API-backed) and workspace (DB-direct) modes.
2
-
3
- ## Read first
4
-
5
- - [../../../AGENTS.md](../../../AGENTS.md)
6
- - Docs site Engineering section (`make docs`)
7
- - Docs site Operations > Database roles (`make docs`)
8
-
9
- ## Rules
10
-
11
- **Two planes.**
12
-
13
- | Mode | Install | Auth | DB access |
14
- |------|---------|------|-----------|
15
- | Standalone (`buildai`) | `uv tool install buildai-cli` | API key / JWT | Through API |
16
- | Workspace (`uv run buildai`) | Repo-local editable install | IAM / password | Direct via `db`, through profile-keyed local broker |
17
-
18
- **DB guards.** `db` subcommands require workspace install + gcloud IAM. Inspection/migration uses a profile-keyed `alloydb-auth-proxy` broker with state in `~/.buildai/db-brokers` (shared across worktrees). Use `buildai db broker status|ensure|stop` for local transport.
19
-
20
- **Writes.** Require `--write`. Production migrations prompt for confirmation.
21
-
22
- **Frame-label totals.** A `buildai db query` that counts
23
- `labeling.frame_labels` by `status` and `label_mode` must use
24
- `MATERIALIZED` then filter, or `COUNT(*) FILTER`. Do not push those
25
- predicates into the scan. See
26
- [core/dal/labeling/AGENTS.md](../../../core/dal/labeling/AGENTS.md).
27
-
28
- **Targeting.** Worktree app/runtime targeting comes from explicit env vars and the repo Makefile, not a saved CLI context layer. `buildai db --env` selects `production`, `staging`, or `dev`.
29
-
30
- **Staging.** Private-IP only. Start `buildai db --env staging tunnel`, then pass `--all-proxy socks5://127.0.0.1:1080` for queries.
31
-
32
- **Workers.** Call the API through the SDK; do not import `core/` or access the database directly from worker shells.
33
-
34
- ## Verify
35
-
36
- ```bash
37
- buildai auth whoami
38
- uv run buildai ingest deploy plan --scope active
39
- buildai db broker status
40
- buildai db schema tables
41
- buildai db query "SELECT count(*) FROM core.clips"
42
- buildai db --env staging tunnel # in one terminal; query with --all-proxy in another
43
- ```
44
-
45
- ## Don't
46
-
47
- - Run `db --write` without `--write`.
48
- - Query staging without an IAP/VPN/SOCKS route and `--all-proxy`.
49
- - Assume a saved CLI context picks the worktree's DB lane — use env vars and Makefile.
50
- - Deploy ingest fleet changes from an unclear branch or dirty checkout.
File without changes
File without changes
File without changes