buildai-cli 0.3.150__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 (81) hide show
  1. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/Dockerfile.rescaling-migration-jobs +1 -0
  2. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/Dockerfile.rescaling-migration-jobs.dockerignore +3 -0
  3. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/PKG-INFO +1 -1
  4. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/egoexo.py +2 -0
  5. buildai_cli-0.3.151/cli/commands/egoexo_config.py +557 -0
  6. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/pyproject.toml +1 -1
  7. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/.gitignore +0 -0
  8. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/AGENTS.md +0 -0
  9. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/CLAUDE.md +0 -0
  10. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/buildai_bootstrap.py +0 -0
  11. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/__init__.py +0 -0
  12. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/_has_core.py +0 -0
  13. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/auth_local.py +0 -0
  14. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/__init__.py +0 -0
  15. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/api_proxy.py +0 -0
  16. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/auth.py +0 -0
  17. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/__init__.py +0 -0
  18. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/broker.py +0 -0
  19. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/common.py +0 -0
  20. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/migrate.py +0 -0
  21. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/query.py +0 -0
  22. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/rescaling.py +0 -0
  23. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/schema.py +0 -0
  24. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/status.py +0 -0
  25. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/db/tunnel.py +0 -0
  26. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/dev.py +0 -0
  27. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/doctor.py +0 -0
  28. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/ego_frame_search.py +0 -0
  29. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/gigcamera.py +0 -0
  30. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/ingest.py +0 -0
  31. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/ingest_docs.py +0 -0
  32. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/intake.py +0 -0
  33. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/label_replay_recovery.py +0 -0
  34. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/labeling.py +0 -0
  35. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/processing.py +0 -0
  36. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/rescaling.py +0 -0
  37. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/spec.py +0 -0
  38. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/commands/spec_pr.py +0 -0
  39. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/config.py +0 -0
  40. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/console.py +0 -0
  41. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/context.py +0 -0
  42. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/db_broker.py +0 -0
  43. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/guard.py +0 -0
  44. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/internal_api.py +0 -0
  45. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/labeling_onboarding.py +0 -0
  46. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/main.py +0 -0
  47. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/nl_query/__init__.py +0 -0
  48. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/nl_query/dataset_tools.py +0 -0
  49. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/ops_init.py +0 -0
  50. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/output.py +0 -0
  51. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/pagination.py +0 -0
  52. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/__init__.py +0 -0
  53. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/canonical.py +0 -0
  54. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/export.py +0 -0
  55. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/feed_proofs.py +0 -0
  56. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/fence.py +0 -0
  57. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/importer.py +0 -0
  58. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/jobs.py +0 -0
  59. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/management.py +0 -0
  60. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/orchestrator.py +0 -0
  61. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/probes.py +0 -0
  62. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/reconcile.py +0 -0
  63. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/run_record.py +0 -0
  64. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/runtime.py +0 -0
  65. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_export_closeout.sql +0 -0
  66. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_export_prepare.sql +0 -0
  67. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_fence.sql +0 -0
  68. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/legacy_unfreeze.sql +0 -0
  69. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_export_closeout.sql +0 -0
  70. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_export_prepare.sql +0 -0
  71. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_fence.sql +0 -0
  72. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/sql/v2_unfreeze.sql +0 -0
  73. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/storage.py +0 -0
  74. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rescaling_migration/transform.py +0 -0
  75. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/cli/rls_guard.py +0 -0
  76. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_logical_backup/__init__.py +0 -0
  77. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_logical_backup/__main__.py +0 -0
  78. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_migration_job/__init__.py +0 -0
  79. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_migration_job/__main__.py +0 -0
  80. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_recovery_extractor/__init__.py +0 -0
  81. {buildai_cli-0.3.150 → buildai_cli-0.3.151}/rescaling_recovery_extractor/__main__.py +0 -0
@@ -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.150
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
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.150"
7
+ version = "0.3.151"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
File without changes
File without changes
File without changes
File without changes