MinIO video storage (chunk 1: config+deps+compose) + event-loop perf fix (#308)

* feat(video): Phase A — VideoEngine origination spine + held-source gates

New default-off engine skeleton: opens a UX/UI authoring task (source=video, assigned to a ux-dev, LOW complexity to clear the dev-needs-subtasks guard) and materializes a held CEO-approval draft (source=video_post). Excludes video_post from all three held-source skip sites; adds the video_draft marker, six config flags, and the feature-flag entries. Origination + gate behavior unit-tested.

* refactor(orchestrator): fold _dispatch_dev_work skip chain into a helper

The per-source if/continue chain grew past xenon's --max-absolute B when the video_post held source joined it. Extract _is_non_dev_dispatch_source (every held-CEO source plus the two Board exploration sources) so the dev loop's skip is one flat call. Behavior-identical.

* feat(video): Phase B — propose_video do-tool (metadata-only, team-gated)

UX/UI dev records a video's composition ref + per-platform captions onto the authoring task's video_draft marker. Team-gated at runtime via _caller_team (Role.DEVELOPER can't tell a ux-dev from a be-dev). Resolves the caller's ACTIVE task via get_active_task_for_agent, not an oldest-first scan that would clobber a second open video task. Metadata only, no render. Wired through do_server + route + schema; added to _DEV_DO.

* feat(video): Phase D — render loop + RemotionRenderer client

Orchestrator-async _video_render_loop renders a completed authoring task's merged composition to MP4 (vertical + square) via the remotion-renderer sidecar and materializes the held video_post draft. RemotionRenderer tars the read-clone's motion/ source, POSTs it, and saves the returned MP4 bytes to a TASK-scoped local path (no shared volume; a composition is reused across videos so a composition-scoped path would clobber an earlier draft). Render failures bounded-retry (read-clone catch-up window, transient sidecar) up to a cap, then terminal-fail. Client tested vs a mock transport; loop vs a mock renderer + real DB.

* feat(video): Phase C — release / spotlight / on-demand video triggers

Three entry points open a UX/UI video-authoring task via VideoEngine.open_video_task: (1) a published release drafts a companion video — best-effort in ReleaseProposalService.approve, never fails the publish; script from the CHANGELOG via the local model with a template fallback. (2) propose_feature_spotlight gains optional wants_video/video_script — best-effort, gated on video_on_spotlight, default-off leaves the spotlight flow byte-for-byte unchanged. (3) POST /video/request (CEO-only) for an on-demand brief, with clean disabled/not_opened responses. All gated on video_engine_enabled.

* fix(video): savepoint-isolate video-task inserts (F042 poisoned session)

The best-effort try/except around open_video_task (release-publish + spotlight hooks) swallowed the Python exception, but a DBAPI error at the insert flush left the shared session must-rollback — so the caller's next commit (release finalize / request boundary) threw PendingRollbackError: the release stuck 'pending' after actually publishing, or the spotlight draft + HTTP response were lost. Wrap both inserts (open_video_task, _originate_video_post) in a begin_nested savepoint (the repo's established F042 pattern) so a DB error rolls back only the insert. open_video_task returns None (every caller already handles it); _originate_video_post propagates to the render loop's handler. Regression test: an insert FK error returns None with the session left usable. Dormant while the flags were off; armed on the NAS.

* feat(video): Phase G — motion/ package + remotion-renderer sidecar + compose

In-repo Remotion v4 motion/ package (ReleaseAnnouncement composition; calculateMetadata returns 1080x1920 vertical / 1080x1080 square from inputProps.orientation) + a credential-free remotion-renderer sidecar: untar the POSTed motion/ source, bundle (LRU-cached per source sha), selectComposition + renderMedia h264, stream the MP4 bytes back — matching the RemotionRenderer client contract. docker/remotion.Dockerfile on Debian (Chrome apt deps, build-time Chrome pre-warm, ffmpeg bundled in @remotion/renderer). Wired into both compose files (roboco_default only, shm_size 1gb, /health check) + the release publish matrix. Verified via a real local render of both cuts; the Debian docker build is the CEO's to run.

* chore(video): D-hardening — video_post source_task_id + render-loop docstring

Add a source_task_id back-reference to the video_post held-draft marker (traceability from a draft to its authoring task; also makes the render loop's two-key idempotency check wireable later). Fix the render-loop test's stale docstring ('never retried' -> bounded-retry). Both from the Phase D critic's non-blocking follow-ups.

* feat(video): Phase E1 — VideoPostService + heartbeat mutex (approve->post)

CEO-approve->post service: heartbeat-renewed Redis mutex (fail-closed, grace=ttl-2*heartbeat), re-read-in-lock double-post guard, per-platform durable commits (asyncio.shield-ed, settle-before-rollback on lock-loss), all writes inside the lock (captions validated pre-lock, applied in-lock — no stale whole-column clobber), idempotent, per-platform retry-skip. Poster interfaces (X/TikTok, mocked here). Reject + list-held-drafts. Survived 3 adversarial rounds; residual = a crash in the poster->commit window (CEO-gated low-freq, documented).

* fix(video): G-hardening — renderer leaks + Share Tech Mono brand font

Sidecar: give bundle() an explicit outDir tracked + deleted on LRU eviction (was leaking ~19MB remotion-webpack-bundle-* per source); res.on('close') cleanup so an aborted/retried download no longer leaks its remotion-out-* MP4 dir. Fonts: vendor Share Tech Mono (roboco-website brand font) as the display face (self-hosted woff2, 400-weight, headline fontWeight 700->400 to avoid faux-bold) + self-hosted Inter body — no gstatic fetch at render time (lsof-verified). Extras: composition_id whitelist (400) + Multer error middleware (400/413).

* feat(video): Phase E2 — X v2 + TikTok posters, tiktok_credentials, routes

LiveXVideoPoster (X v2 chunked media upload: init/append/finalize/STATUS-poll -> tweet w/ media_ids, OAuth1 signer reused). LiveTikTokPoster (OAuth2 inbox: init -> chunked PUT with asymmetric final chunk -> status-fetch; 401 -> refresh_token grant, rotated token persisted). tiktok_credentials Fernet singleton + migration 062 (single head). Routes: CEO approve/reject + list held drafts + write-only tiktok creds, wiring real posters into VideoPostService. Residual: a lock-loss right after a token-refresh flush can discard the rotated token (same rare CEO-gated class as the documented post->commit window).

* feat(video): Phase F — panel video-post queue + TikTok creds card + flags

video-post-queue.tsx: <video> MP4 preview with 9:16/1:1 cut switch, per-platform editable captions (280/2200 counters, over-limit disables approve), approve/reject, Request-a-video dialog. tiktok-credentials-card.tsx (4 write-only OAuth2 fields). feature-flags-card inlines TikTokCredentialsForm under video_engine_enabled. Mounted in command-center. tsc/eslint clean, 273 panel tests green. NOTE: needs the GET /video/posts/{id}/media route + mp4_paths on VideoPostResponse (folded into H) for the preview source.

* feat(video): Phase H — media route + e2e smoke + NAS arming + docs

GET /video/posts/{id}/media?cut= (CEO-gated FileResponse of the rendered MP4; closes the panel preview gap) + mp4_paths on VideoPostResponse. e2e smoke tests/e2e_smoke/test_video_pipeline.py (full flow, sidecar+X/TikTok mocked; asserts dispatcher skips, render-loop materialize, propose_video team-gate, approve idempotency). NAS arming: docker-compose.yml/.yaml ROBOCO_VIDEO_ENGINE_ENABLED/ON_RELEASE/ON_SPOTLIGHT default-on (.yaml resynced to .yml); registry stays off. CLAUDE.md video-engine section + CHANGELOG. Fixed 2 pre-existing route-test pollution leaks. Full suite 11763 passed.

* fix(video): auth-carrying preview, media route confinement, VideoPost type drift

Three fixes along the video preview path:

1. panel video preview auth: the <video> element was pointed straight at
   GET /video/posts/{id}/media, but a native <video src> GET carries none
   of axios's X-Agent-ID/X-Agent-Role headers — so in the default
   header-trust deployment the request 401s. Fetch the cut via
   videoApi.getMediaBlob (axios, responseType: blob) and drive <video>
   off a URL.createObjectURL result instead. The object URL is revoked
   on cut-change (the previous cut's URL) and on unmount, so neither
   cut switches nor row teardown leak blob URLs.

2. backend media route confinement: GET /video/posts/{id}/media now
   resolves mp4_path and refuses it with 404 when it falls outside
   settings.video_output_dir. Defense-in-depth against any future
   writer of mp4_paths serving files from arbitrary disk locations.

3. panel VideoPost type/comment drift: added mp4_paths to the
   VideoPost interface (the committed VideoPostResponse already
   carries it), and corrected the stale comment on videoMediaUrl
   that claimed no route served the rendered bytes — the route has
   existed since the media endpoint landed; the comment now describes
   why getMediaBlob exists instead of a direct <video src>.

* Persist rendered videos to data in physical storage.

* ++

* docs(video): 0.18.0 CHANGELOG entry + RAG + map reference for video engine

- Move the video engine bullet from [Unreleased] into [0.18.0] and note
  the ROBOCO_VIDEO_OUTPUT_DIR bind-mount persistence.
- Add docs/rag/architecture/video-engine.md (mirrors x-engine.md shape:
  enable/disable, three triggers, render loop + sidecar, CEO gate, media
  route confinement, credentials).
- Reference the video render loop in docs/map/orchestrator.md's engine list.

* chore(video): re-bump to 0.19.0 + sync registry compose defaults

Version was wrongly bumped to 0.18.0; 0.18.0 is an already-released
section. Restore its 2026-07-04 date and move the video-engine CHANGELOG
bullet into a new [0.19.0] - 2026-07-05 section above it. Bump
pyproject.toml, roboco/__init__.py, roboco/config.py (app_version),
panel/package.json, and the motion/README inputProps example to 0.19.0.

docker-compose.registry.yml: add ROBOCO_VIDEO_ENGINE_ENABLED /
_VIDEO_ON_RELEASE / _VIDEO_ON_SPOTLIGHT defaulted false (NAS arms them
true), and comment out the video-renders bind mount with a short note
so the public registry image ships video off by default. Structural
sync with docker-compose.yml maintained.

* fix(video): rate-limit /render + reflow motion/README

CodeQL flagged js/missing-rate-limiting on the renderer /render route.
The sidecar is container-network-only with one trusted caller (the
orchestrator, which renders cuts serially), so this limiter is a
retry-storm ceiling (30/min, well above legit render rate), not the
primary control. Also reflows motion/README.md hard-wrapped prose that
failed the markdown quality gate.

* fix(build): finish pnpm 11 migration + regen verb tables

The panel Docker image build failed on `pnpm install --frozen-lockfile`:
node:22-alpine's corepack resolved to its bundled pnpm 11, but
panel/package.json pinned packageManager to pnpm@10.25.0, and pnpm 11
refuses to run against that pin. The Dockerfiles were already written for
pnpm 11 (comments, CI=true, strictDepBuilds); the package.json pin was the
stale outlier. Finish the migration instead of working around it:

- panel/package.json: packageManager pnpm@10.25.0 -> pnpm@11.10.0; drop the
  `pnpm` field (pnpm 11 ignores it — build approval lives in
  panel/pnpm-workspace.yaml's allowBuilds). Lockfile unchanged (pnpm 11
  accepts it as-is); frozen-lockfile verified.
- remotion-renderer/package.json: pin packageManager pnpm@11.10.0 for
  determinism (was relying on corepack's implicit default); engines.node
  >=22.13 (pnpm 11 requirement).
- docker/panel.Dockerfile + docker/remotion.Dockerfile: `corepack prepare
  pnpm@11.10.0 --activate` so the build uses the pinned version explicitly
  instead of trusting corepack's bundled default (which a future
  node:22-alpine could change).
- .github/workflows/panel-ci.yml: Node 20 -> 22 (pnpm 11 requires
  Node >=22.13; Node 20 fails the engines check).

Also regenerate agents/prompts/_generated/{developer,head_marketing,verbs}.md
— the video engine added propose_video and extended propose_feature_spotlight
(wants_video, video_script) but the verb tables weren't refreshed, failing
the foundation-check quality gate.

* chore(build): approve esbuild build script in remotion pnpm-workspace.yaml

pnpm 11 generated this file with a placeholder ('set this to true or false')
during install; resolve it to true so local dev of the renderer doesn't
re-prompt. esbuild's postinstall only verifies the prebuilt platform binary
(@esbuild/<platform> is installed as an optional dep), so approving it is
safe and silences the ERR_PNPM_IGNORED_BUILDS warning.

* fix(build): copy pnpm-workspace.yaml into panel + remotion images

pnpm 11 hard-errors with [ERR_PNPM_IGNORED_BUILDS] (exit 1) when a
dependency ships a postinstall script that isn't approved in
allowBuilds. Both Dockerfiles copied only package.json + pnpm-lock.yaml,
so the build-approval map in pnpm-workspace.yaml never made it into the
image — the remotion image build died on esbuild@0.28.1's postinstall.

Copy pnpm-workspace.yaml alongside the manifests in both images. In
panel, this also drops the --config.strictDepBuilds=false workaround:
with sharp and unrs-resolver now approved, their postinstalls run and
install the platform-specific binaries (previously skipped, leaving
sharp without its @img/sharp-* binary at runtime).

Verified locally: remotion + panel `pnpm install --frozen-lockfile`
exit 0 with the workspace file present; both exit 1 without it.

* fix(perf): offload conventions + release-readiness blocking I/O off the event loop

The orchestrator runs uvicorn and the orchestration background loops on a
single shared event loop, so any sync I/O anywhere — even inside a background
loop — blocks API responsiveness for its duration. Two call sites were missing
asyncio.to_thread wrappers:

- ConventionsService.get_map/health/restore called the sync _resolve
  (`git rev-parse`), _read_committed_standard (file read + yaml parse), and
  _derive (filesystem walk via derive_from_scan) inline. Reachable from
  GET /api/projects/{id}/conventions and from the agent spawn-prepare path.
- ReleaseManagerEngine._production_assess called gather_snapshot inline —
  multiple `subprocess.run` git calls + a filesystem walk, running inside the
  release-manager background loop.

Wrap each blocking call in asyncio.to_thread at the async boundary. No
signature changes; helpers stay sync. Verified: targeted tests pass
(196 passed, 36 DB-skipped), ruff + format clean.

These were the only responsiveness gaps surfaced by the concurrency audit —
the rest of the heavy paths (agent spawn via `docker run -d`, video render
loop, git ops via the 16-worker ThreadPoolExecutor, workspace subprocess
calls) already offload correctly. No API/worker container split needed.

* feat(storage): add MinIO config + dep + compose (no-op, default-off)

Chunk 1 of the MinIO video-storage plan (§1, §2, §6). No behavior change:
minio_endpoint defaults to empty = disabled, the existing FileResponse serve
path is untouched (chunk 4 wires the serve path; chunk 2 adds the client).

- pyproject.toml: add `minio` (minio-py) to dependencies; regenerate uv.lock
  (resolves minio v7.2.20 + pycryptodome transitive).
- roboco/config.py: add 5 settings fields after video_output_dir
  (minio_endpoint/_access_key/_secret_key/_bucket/_region). Plain str Fields
  matching the existing ROBOCO_ENCRYPTION_KEY style; no SecretStr, no
  presign_ttl_seconds (YAGNI — we don't presign in phase 1).
- docker-compose.yml: add `minio` service (data network only, named
  minio-data volume, host ports 19000/19001 for debugging, mc healthcheck)
  and a one-shot `minio-init` service mirroring the ollama-init pattern
  (mc alias set + mb -p, idempotent via || true). Add ROBOCO_MINIO_* env to
  the orchestrator env block (endpoint, access/secret key, bucket, region).
- docker-compose.registry.yml: intentionally omit the minio/minio-init
  services and leave ROBOCO_MINIO_* unset (NAS default-on, registry
  default-off — the established pattern); comment added to the orchestrator
  env block noting the omission.

* docs(storage): 0.19.0 CHANGELOG + RAG + map reference for MinIO chunk 1

Backfills the release-polish docs for MinIO chunk 1 (§10 of the plan):
- docker-compose.yaml synced to docker-compose.yml (the two NAS compose files
  must stay byte-identical; .yml was edited in chunk 1, .yaml was stale).
- CHANGELOG [0.19.0]: Added (MinIO scaffolding) + Fixed (event-loop I/O offload).
- docs/rag/architecture/minio-storage.md: RAG doc mirroring video-engine.md.
- docs/map/deployment-tooling.md: one-line storage reference.

* MinIO chunk 2: minio_client module (singleton + unconfigured guard) (#309)

* feat(storage): minio_client module (singleton + unconfigured guard)

Chunk 2 of the MinIO plan (§3). roboco/services/minio_client.py adds:
- get_client(): singleton minio-py Minio from settings; returns None when
  minio_endpoint is empty (the disabled path used by the chunk 3/4 guards).
  Parses http://... endpoint into host:port + secure flag.
- put_object(bytes, key): no-ops when unconfigured; otherwise PUTs to
  settings.minio_bucket with ContentType video/mp4.
- get_object_stream(key): yields object bytes for StreamingResponse; lets
  S3Error propagate so the serve route (chunk 4) can fall back to disk.

Sync calls — every call site wraps in asyncio.to_thread (chunks 3/4). One
unit test covers the unconfigured guard + endpoint scheme parsing (mocks,
no real MinIO). Not yet wired into remotion_client._save or the media route.

* MinIO chunk 3: wire write path (remotion_client._save PUT) (#310)

* feat(storage): wire MinIO write path in remotion_client._save

Chunk 3 of the MinIO plan (§3). After the local mp4 write, _save PUTs the bytes
to MinIO under key = Path(mp4_path).name (already {render_key}-{orientation}.mp4),
guarded by minio_client.get_client() (None when minio_endpoint empty) and
wrapped in asyncio.to_thread. Local disk stays the source of truth for the
poster publish path (x_video_client/tiktok_client read mp4_path from disk);
the PUT is additive. _save still returns the local path str — mp4_paths,
marker, and schema unchanged. Disabled (local-only) when MinIO unconfigured.

One test: asserts put_object is called with the basename key when configured
and the local file is still written; existing test stays green via the
unconfigured-default path. Mocks only.

* fix(storage): make MinIO PUT non-fatal in remotion_client._save

A configured-but-down MinIO made put_object raise inside the worker thread,
failing the render and retry-looping a task whose local file was already
written. Local disk is the source of truth and the serve route falls back to
FileResponse on S3Error, so a failed durable-copy PUT must never fail the
render — log and continue; the next render re-attempts the PUT.

Adds test_save_swallows_minio_put_failure (PUT raises -> _save still returns
the local path and the local file is written). Extends the CHANGELOG write-
path bullet with the non-fatal guarantee.

* MinIO chunk 4: serve path (StreamingResponse + FileResponse fallback) (#311)

* feat(storage): serve MinIO via the media route (StreamingResponse + FileResponse fallback)

Chunk 4 of the MinIO plan (§4 — the crux). GET /api/video/posts/{id}/media
derives key = Path(mp4_path).name and, when minio_endpoint is set, returns a
StreamingResponse over minio_client.get_object_stream(key), keeping
_require_ceo so auth stays end-to-end (no presigned URLs). Falls back to
FileResponse on S3Error (old render not in MinIO) or when MinIO is
unconfigured — the panel's axios-blob flow is unchanged (same URL, headers,
body, just chunked). The confinement check is kept as defense-in-depth (the
key is a basename so traversal is impossible, but the check is cheap and
protects the poster path).

Two integration tests: configured serve path streams from a stubbed
get_object_stream (CEO 200, non-CEO 403); unconfigured fallback serves the
local file via FileResponse. Mocks only — no real MinIO.

* fix(storage): eager stat_object probe so the MinIO serve fallback actually fires

The chunk-4 route wrapped StreamingResponse(get_object_stream(key), ...) in a
try/except, but get_object_stream is a lazy generator — its client.get_object
call runs on the first next(), i.e. AFTER the route returned and Starlette
started streaming. An S3Error (NoSuchKey / MinIO down) there is uncatchable;
the try/except caught nothing and the FileResponse fallback never triggered.

Add minio_client.stat_object(key): an eager existence/readiness probe that
runs INSIDE the route's try/except, so a missing object or down MinIO raises
before the StreamingResponse starts and the fallback serves the local file.
stat-then-get is two round trips; a mid-stream failure after a successful stat
is a rare race the CEO can retry (documented ceiling).

Tests: the configured test now stubs stat_object; a new test asserts the
S3Error fallback serves the local file via FileResponse and that
get_object_stream is never called. RAG doc updated to record the eager-probe
correctness detail + the non-fatal PUT.

* docs(rag): mark MinIO deployment note landed (chunk 5) (#312)

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* fix(video): offload minio stat_object off the event loop

stat_object was called inline in the async media route, blocking the
shared event loop for one sync urllib3 round-trip per preview request —
contradicting minio_client's own 'every call site wraps in to_thread'
docstring and this PR's perf-fix theme. Wrap in asyncio.to_thread; the
try/except still catches S3Error (to_thread re-raises) so the
FileResponse fallback is unchanged. Also add the trailing newline to
the minio-storage RAG doc.

* Fix red CI

* Make CI green

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-05 16:12:44 +02:00
committed by GitHub
co-authored by Renn F
parent e9d0e0bd48
commit 4923ee3ff3
17 changed files with 835 additions and 26 deletions
+8
View File
@@ -11,6 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### Added
- **RoboCo video engine (default-off).** With `ROBOCO_VIDEO_ENGINE_ENABLED`, a release/feature-spotlight/on-demand CEO trigger opens a normal, assigned UX/UI authoring task (balanced across the two ux-devs) instead of a held draft — the dev builds a Remotion composition under `motion/` and proposes its composition id + per-platform captions via the team-gated `propose_video` do-tool, then ships it through the standard commit/PR/QA/doc/review lifecycle. Once that task completes, an orchestrator render loop tars the merged `motion/` source to a new credential-free `remotion-renderer` sidecar, renders both the 9:16 and 1:1 MP4 cuts, and materializes a held `video_post` draft (mirroring the X-post/release-proposal shape: Secretary-owned, skipped by every dispatcher). The CEO previews, edits, approves, or rejects each draft in a new panel video queue; approving posts the rendered clip to X (native video, v2 media upload) and/or TikTok (inbox upload) under a heartbeat-renewed lock and is idempotent — an already-posted draft is a no-op. `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT` gate the two automatic triggers independently of the CEO's on-demand `POST /video/request`; TikTok's OAuth2 secrets live Fernet-encrypted alongside the existing X credentials, and every unconfigured leg (renderer, X, TikTok) degrades to a graceful no-op rather than a crash. Rendered MP4s persist under `ROBOCO_VIDEO_OUTPUT_DIR` (bind-mounted in all three compose files so renders survive container recreation). - **RoboCo video engine (default-off).** With `ROBOCO_VIDEO_ENGINE_ENABLED`, a release/feature-spotlight/on-demand CEO trigger opens a normal, assigned UX/UI authoring task (balanced across the two ux-devs) instead of a held draft — the dev builds a Remotion composition under `motion/` and proposes its composition id + per-platform captions via the team-gated `propose_video` do-tool, then ships it through the standard commit/PR/QA/doc/review lifecycle. Once that task completes, an orchestrator render loop tars the merged `motion/` source to a new credential-free `remotion-renderer` sidecar, renders both the 9:16 and 1:1 MP4 cuts, and materializes a held `video_post` draft (mirroring the X-post/release-proposal shape: Secretary-owned, skipped by every dispatcher). The CEO previews, edits, approves, or rejects each draft in a new panel video queue; approving posts the rendered clip to X (native video, v2 media upload) and/or TikTok (inbox upload) under a heartbeat-renewed lock and is idempotent — an already-posted draft is a no-op. `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT` gate the two automatic triggers independently of the CEO's on-demand `POST /video/request`; TikTok's OAuth2 secrets live Fernet-encrypted alongside the existing X credentials, and every unconfigured leg (renderer, X, TikTok) degrades to a graceful no-op rather than a crash. Rendered MP4s persist under `ROBOCO_VIDEO_OUTPUT_DIR` (bind-mounted in all three compose files so renders survive container recreation).
- **MinIO object storage scaffolding (default-off).** `ROBOCO_MINIO_*` config (`minio_endpoint`, `minio_access_key`, `minio_secret_key`, `minio_bucket`, `minio_region`) + a `minio` service and a one-shot `minio-init` (idempotent bucket create) in the NAS compose files, on the `data` network with a named `minio-data` volume; `minio` (minio-py) added as a dependency. Empty `minio_endpoint` = disabled and the existing `FileResponse` media-serve path is byte-for-byte unchanged — this is scaffolding; the write path (PUT after local save) and serve path (`StreamingResponse` with `FileResponse` fallback) land in later chunks. The registry compose omits MinIO entirely (NAS default-on, registry default-off).
- **MinIO storage client.** `roboco/services/minio_client.py` — a singleton minio-py client with an unconfigured guard (`get_client()` returns `None` when `minio_endpoint` is empty), plus `put_object` and `get_object_stream`. Sync; call sites wrap in `asyncio.to_thread`. Not yet wired into the write/serve paths (chunks 34).
- **MinIO write path.** `remotion_client._save` now PUTs each rendered MP4 to MinIO (key = the basename `{render_key}-{orientation}.mp4`) after the local write, guarded by `minio_endpoint`. Local disk stays the source of truth for the poster publish path; the PUT is additive and **non-fatal** — a failed PUT (MinIO down, transient 5xx) is logged and the render still succeeds, since the serve route falls back to `FileResponse` on `S3Error`. No schema, marker, or `mp4_paths` change. Disabled (local-only) when MinIO is unconfigured.
- **MinIO serve path (the user-visible switch).** The panel video-preview media route (`GET /api/video/posts/{id}/media`) now streams the MP4 from MinIO (`StreamingResponse` over `minio_client.get_object_stream`, key = the basename) when `minio_endpoint` is set, keeping `_require_ceo` so auth stays end-to-end (no presigned URLs). Falls back to `FileResponse` from the local video-renders dir when MinIO is unconfigured OR on `S3Error` (old renders not yet in MinIO / MinIO down). The panel's axios-blob flow is unchanged — same URL, headers, body. Set `ROBOCO_MINIO_ENDPOINT` and renders start serving from MinIO.
### Fixed
- **Conventions + release-readiness I/O no longer blocks the API event loop.** `ConventionsService.get_map/health/restore` and `ReleaseManagerEngine._production_assess` ran sync `git rev-parse`, filesystem walks, and yaml parses inline on the orchestrator's shared uvicorn event loop, stalling API responsiveness during conventions reads (reachable from `GET /api/projects/{id}/conventions` and the agent spawn-prepare path) and the release-manager background loop. Each blocking call is now wrapped in `asyncio.to_thread` at the async boundary; no signature changes. A concurrency audit confirmed the rest of the heavy paths (agent spawn via `docker run -d`, the video render loop, git ops via the 16-worker ThreadPoolExecutor, workspace subprocess calls) already offload correctly — no API/worker container split is warranted.
## [0.18.0] - 2026-07-04 ## [0.18.0] - 2026-07-04
+5
View File
@@ -295,6 +295,11 @@ services:
ROBOCO_VIDEO_ENGINE_ENABLED: ${ROBOCO_VIDEO_ENGINE_ENABLED:-false} ROBOCO_VIDEO_ENGINE_ENABLED: ${ROBOCO_VIDEO_ENGINE_ENABLED:-false}
ROBOCO_VIDEO_ON_RELEASE: ${ROBOCO_VIDEO_ON_RELEASE:-false} ROBOCO_VIDEO_ON_RELEASE: ${ROBOCO_VIDEO_ON_RELEASE:-false}
ROBOCO_VIDEO_ON_SPOTLIGHT: ${ROBOCO_VIDEO_ON_SPOTLIGHT:-false} ROBOCO_VIDEO_ON_SPOTLIGHT: ${ROBOCO_VIDEO_ON_SPOTLIGHT:-false}
# MinIO object storage for rendered videos is intentionally omitted from
# this registry compose (NAS default-on, registry default-off — the
# established pattern). The minio/minio-init services are absent here and
# ROBOCO_MINIO_* is left unset, so minio_endpoint defaults to empty and the
# media route falls back to FileResponse. Arm via a custom override file.
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ${CLAUDE_AUTH_DIR:-${HOME}/.claude}:/root/.claude - ${CLAUDE_AUTH_DIR:-${HOME}/.claude}:/root/.claude
+62
View File
@@ -47,6 +47,55 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ==========================================================================
# MinIO - Object storage for rendered videos (NAS default-on; registry OFF)
# ==========================================================================
minio:
image: minio/minio:latest
container_name: roboco-minio
restart: unless-stopped
# data-only network — keeps MinIO off the agent mesh; the multi-homed
# orchestrator reaches it via its data NIC. Host ports for debugging.
networks:
- data
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
MINIO_ROOT_PASSWORD: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ports:
- "19000:9000"
- "19001:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# MinIO bucket init - one-shot, mirrors the ollama-init pattern: depends on
# minio healthy, creates the bucket idempotently, then exits. `|| true`
# covers re-runs (bucket already exists).
minio-init:
image: minio/mc:latest
container_name: roboco-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- data
entrypoint: ["/bin/sh", "-c"]
# Note: $$ escapes $ for docker-compose variable substitution so the
# container's own env vars reach mc.
command:
- mc alias set local http://roboco-minio:9000 $$ROBOCO_MINIO_ACCESS_KEY $$ROBOCO_MINIO_SECRET_KEY && mc mb -p local/$$ROBOCO_MINIO_BUCKET || true
environment:
ROBOCO_MINIO_ACCESS_KEY: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
ROBOCO_MINIO_SECRET_KEY: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ROBOCO_MINIO_BUCKET: ${ROBOCO_MINIO_BUCKET:-roboco-video-renders}
restart: "no"
# ========================================================================== # ==========================================================================
# Ollama - Local LLM and Embedding Server # Ollama - Local LLM and Embedding Server
# ========================================================================== # ==========================================================================
@@ -351,6 +400,14 @@ services:
# itself is default-off (video_engine_enabled); this just points the # itself is default-off (video_engine_enabled); this just points the
# client at the sidecar for when it's armed. # client at the sidecar for when it's armed.
ROBOCO_REMOTION_BASE_URL: http://roboco-remotion:3001 ROBOCO_REMOTION_BASE_URL: http://roboco-remotion:3001
# MinIO object storage for rendered videos (use container name). Empty
# endpoint = disabled (FileResponse fallback); armed here for the NAS
# deploy, left OFF in docker-compose.registry.yml.
ROBOCO_MINIO_ENDPOINT: http://roboco-minio:9000
ROBOCO_MINIO_ACCESS_KEY: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
ROBOCO_MINIO_SECRET_KEY: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ROBOCO_MINIO_BUCKET: ${ROBOCO_MINIO_BUCKET:-roboco-video-renders}
ROBOCO_MINIO_REGION: ${ROBOCO_MINIO_REGION:-us-east-1}
# Host paths for spawning agent containers (required for Docker-in-Docker) # Host paths for spawning agent containers (required for Docker-in-Docker)
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem # IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco} ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
@@ -605,3 +662,8 @@ networks:
# ports keep working. # ports keep working.
data: data:
name: roboco_data name: roboco_data
volumes:
# Named volume for MinIO — keeps rendered-video storage out of the
# ${ROBOCO_DATA_DIR} bind-mount sprawl; docker-managed durable store.
minio-data:
+62
View File
@@ -47,6 +47,55 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# ==========================================================================
# MinIO - Object storage for rendered videos (NAS default-on; registry OFF)
# ==========================================================================
minio:
image: minio/minio:latest
container_name: roboco-minio
restart: unless-stopped
# data-only network — keeps MinIO off the agent mesh; the multi-homed
# orchestrator reaches it via its data NIC. Host ports for debugging.
networks:
- data
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
MINIO_ROOT_PASSWORD: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ports:
- "19000:9000"
- "19001:9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# MinIO bucket init - one-shot, mirrors the ollama-init pattern: depends on
# minio healthy, creates the bucket idempotently, then exits. `|| true`
# covers re-runs (bucket already exists).
minio-init:
image: minio/mc:latest
container_name: roboco-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- data
entrypoint: ["/bin/sh", "-c"]
# Note: $$ escapes $ for docker-compose variable substitution so the
# container's own env vars reach mc.
command:
- mc alias set local http://roboco-minio:9000 $$ROBOCO_MINIO_ACCESS_KEY $$ROBOCO_MINIO_SECRET_KEY && mc mb -p local/$$ROBOCO_MINIO_BUCKET || true
environment:
ROBOCO_MINIO_ACCESS_KEY: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
ROBOCO_MINIO_SECRET_KEY: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ROBOCO_MINIO_BUCKET: ${ROBOCO_MINIO_BUCKET:-roboco-video-renders}
restart: "no"
# ========================================================================== # ==========================================================================
# Ollama - Local LLM and Embedding Server # Ollama - Local LLM and Embedding Server
# ========================================================================== # ==========================================================================
@@ -351,6 +400,14 @@ services:
# itself is default-off (video_engine_enabled); this just points the # itself is default-off (video_engine_enabled); this just points the
# client at the sidecar for when it's armed. # client at the sidecar for when it's armed.
ROBOCO_REMOTION_BASE_URL: http://roboco-remotion:3001 ROBOCO_REMOTION_BASE_URL: http://roboco-remotion:3001
# MinIO object storage for rendered videos (use container name). Empty
# endpoint = disabled (FileResponse fallback); armed here for the NAS
# deploy, left OFF in docker-compose.registry.yml.
ROBOCO_MINIO_ENDPOINT: http://roboco-minio:9000
ROBOCO_MINIO_ACCESS_KEY: ${ROBOCO_MINIO_ACCESS_KEY:-minio}
ROBOCO_MINIO_SECRET_KEY: ${ROBOCO_MINIO_SECRET_KEY:-minio123}
ROBOCO_MINIO_BUCKET: ${ROBOCO_MINIO_BUCKET:-roboco-video-renders}
ROBOCO_MINIO_REGION: ${ROBOCO_MINIO_REGION:-us-east-1}
# Host paths for spawning agent containers (required for Docker-in-Docker) # Host paths for spawning agent containers (required for Docker-in-Docker)
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem # IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco} ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
@@ -605,3 +662,8 @@ networks:
# ports keep working. # ports keep working.
data: data:
name: roboco_data name: roboco_data
volumes:
# Named volume for MinIO — keeps rendered-video storage out of the
# ${ROBOCO_DATA_DIR} bind-mount sprawl; docker-managed durable store.
minio-data:
+1
View File
@@ -258,6 +258,7 @@ deployment-tooling
- ROBOCO_ROADMAP_ENGINE_ENABLED / _INTERVAL_SECONDS (default 604800) / _MIN_ITEMS_PER_CYCLE / _MAX_ITEMS_PER_CYCLE — the board roadmap engine - ROBOCO_ROADMAP_ENGINE_ENABLED / _INTERVAL_SECONDS (default 604800) / _MIN_ITEMS_PER_CYCLE / _MAX_ITEMS_PER_CYCLE — the board roadmap engine
- ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED / _INTERVAL_SECONDS (default 259200/3d) — X-engine feature-spotlight sub-switch (requires ROBOCO_X_ENGINE_ENABLED also on), default off - ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED / _INTERVAL_SECONDS (default 259200/3d) — X-engine feature-spotlight sub-switch (requires ROBOCO_X_ENGINE_ENABLED also on), default off
- ROBOCO_FABLE_MODE_ENABLED — opus-fable-playbook adoption (doctrine layer in the composed prompt + 5 Claude-path hook scripts + 1 grok-path hook), default off; off = byte-for-byte unchanged spawn path - ROBOCO_FABLE_MODE_ENABLED — opus-fable-playbook adoption (doctrine layer in the composed prompt + 5 Claude-path hook scripts + 1 grok-path hook), default off; off = byte-for-byte unchanged spawn path
- ROBOCO_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET / _REGION — MinIO object storage (default-off; empty endpoint = disabled, media route falls back to `FileResponse`; when set, `remotion_client._save` PUTs each render to MinIO after the local write and `GET /api/video/posts/{id}/media` streams it via `StreamingResponse` over `minio_client.get_object_stream`, key = basename, `_require_ceo` kept so auth stays end-to-end — no presigned URLs; `S3Error` falls back to `FileResponse`); NAS compose runs `minio` + `minio-init` on the `data` network with a named `minio-data` volume, registry compose omits MinIO; see `docs/rag/architecture/minio-storage.md`
- ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS - ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS
- ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS - ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS
- ROBOCO_GIT_COMMAND_TIMEOUT_SECONDS / _COMMIT_TIMEOUT_SECONDS / _NETWORK_TIMEOUT_SECONDS - ROBOCO_GIT_COMMAND_TIMEOUT_SECONDS / _COMMIT_TIMEOUT_SECONDS / _NETWORK_TIMEOUT_SECONDS
+36
View File
@@ -0,0 +1,36 @@
# Object Storage (MinIO)
## What It Is
Rendered MP4s are written to a host bind mount (`ROBOCO_VIDEO_OUTPUT_DIR`, default `/data/video-renders`) and served back via `FileResponse` from the media route. MinIO adds decoupled, docker-managed durable object storage so renders outlive the orchestrator container without relying on the bind-mount sprawl, and gives a clean serve path that keeps app-level auth end-to-end. The client is `minio` (minio-py), sync, with every call site wrapped in `asyncio.to_thread` — same pattern as the existing `_save` in `roboco/services/remotion_client.py`.
## Enable/Disable
| Variable | Default | Effect |
|----------|---------|--------|
| `ROBOCO_MINIO_ENDPOINT` | `` (empty) | MinIO endpoint, e.g. `http://roboco-minio:9000`. Empty = disabled (the media route falls back to `FileResponse` from the local video-renders dir). |
| `ROBOCO_MINIO_ACCESS_KEY` | `` | Access key. Required when endpoint is set. |
| `ROBOCO_MINIO_SECRET_KEY` | `` | Secret key. Required when endpoint is set. |
| `ROBOCO_MINIO_BUCKET` | `roboco-video-renders` | Bucket for rendered videos. Created idempotently by the `minio-init` one-shot service. |
| `ROBOCO_MINIO_REGION` | `us-east-1` | MinIO region. |
Armed in the NAS compose (`docker-compose.yml` / `docker-compose.yaml`); intentionally omitted from `docker-compose.registry.yml` (NAS default-on, registry default-off).
## Current state (0.19.0 chunk 4 — serve path landed)
Config fields, the `minio` dependency, and the compose services (`minio` + `minio-init`) are landed (chunk 1). Chunk 2 adds `roboco/services/minio_client.py` — a singleton `Minio` with an unconfigured guard (`get_client()` returns `None` when `minio_endpoint` is empty), plus `put_object`, `get_object_stream`, and `stat_object`. Chunk 3 wires the write path: `remotion_client._save` keeps the local write (the poster publish path in `x_video_client.py` / `tiktok_client.py` still reads `Path(mp4_path).read_bytes()`) and adds a MinIO `put_object` after it, guarded by `settings.minio_endpoint` and non-fatal — a failed PUT (MinIO down) is logged and the render still succeeds, since local disk is the source of truth. Key = `Path(mp4_path).name` (already `{render_key}-{orientation}.mp4`) — no schema change, no new marker field. Chunk 4 wires the serve path (now live): the media route (`roboco/api/routes/video.py`) keeps `_require_ceo(agent)` and returns a `StreamingResponse` wrapping `minio_client.get_object_stream(key)` when configured, falling back to `FileResponse` when unconfigured OR when the eager `minio_client.stat_object(key)` probe raises (missing object / MinIO down). The probe is eager because `get_object_stream` is a lazy generator — its `get_object` call runs on first iteration, after Starlette has started streaming and the response is no longer take-back-able; the probe runs inside the route's `try/except` so the `S3Error` fallback actually fires. The key is the basename, so the existing confinement check stays as defense-in-depth. With `minio_endpoint` empty, the serve path is byte-for-byte the pre-MinIO `FileResponse`. The `minio` service runs on the `data` network only (off the agent mesh); the orchestrator reaches it via its `data` NIC. Host ports `19000:9000` / `19001:9001` are published for debugging only.
## Planned end state (later chunks)
- (none — the deployment note landed in chunk 5; see docs.roboco.tech/deploy/deployment)
## Why not presigned URLs
A presigned URL is a TTL bearer token for the object; the browser's `<video>` element making a direct GET to MinIO cannot carry `X-Agent-ID`/`X-Agent-Role`, so once issued, MinIO cannot enforce the app's CEO-role check — anyone with the URL gets the bytes for the TTL. Proxying through the authenticated route keeps the app-level auth boundary end-to-end at the cost of one loopback streaming hop on a single-host docker deploy. **Now live** — the chunk-4 serve path proxies through the route. Presigned URLs are deferred until CDN / direct-browser-to-MinIO becomes a goal and panel auth is reworked to mint short-lived tokens.
## Skipped (add when)
- Presigned URLs / `presign_ttl_seconds` — see above.
- Storage interface / factory — one implementation, no abstraction.
- Full MinIO-only switch (drop local disk) — when the publish path takes bytes instead of a path.
- Lifecycle policy / bucket versioning / replication — when there's a retention or multi-site requirement.
+2
View File
@@ -39,6 +39,8 @@ dependencies = [
"passlib[bcrypt]", # Password hashing "passlib[bcrypt]", # Password hashing
"tenacity", # Retry logic "tenacity", # Retry logic
"structlog", # Structured logging "structlog", # Structured logging
# Storage
"minio", # Object storage client for rendered videos (sync, to_thread-wrapped)
# Streaming # Streaming
"sse-starlette", # Server-Sent Events for A2A streaming "sse-starlette", # Server-Sent Events for A2A streaming
# Direct imports (promoted from transitive) # Direct imports (promoted from transitive)
+53 -16
View File
@@ -5,12 +5,13 @@ API never returns plaintext)."""
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, StreamingResponse
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.video import ( from roboco.api.schemas.video import (
@@ -26,6 +27,7 @@ from roboco.api.schemas.video import (
from roboco.config import settings from roboco.config import settings
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.security import guard_deco from roboco.security import guard_deco
from roboco.services import minio_client
from roboco.services.task import VIDEO_POST_SOURCE, get_task_service from roboco.services.task import VIDEO_POST_SOURCE, get_task_service
from roboco.services.tiktok_client import build_tiktok_poster from roboco.services.tiktok_client import build_tiktok_poster
from roboco.services.tiktok_credentials import ( from roboco.services.tiktok_credentials import (
@@ -56,6 +58,25 @@ def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view or act on the video engine") require_ceo_role(agent.role, action="view or act on the video engine")
def _resolve_video_cut(task: TaskTable, cut: str) -> Path:
"""Resolve the on-disk MP4 path for ``cut`` off the task's held draft, or
404. The ``is_relative_to`` confinement check stays even though the MinIO
key is a basename (traversal-proof) it also guards the ``FileResponse``
fallback path that reads ``mp4_path`` straight from disk."""
draft = markers.get_video_draft(task) or {}
mp4_path = (draft.get("mp4_paths") or {}).get(cut)
if not mp4_path or not Path(mp4_path).is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
output_dir = Path(settings.video_output_dir).resolve()
if not Path(mp4_path).resolve().is_relative_to(output_dir):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut"
)
return Path(mp4_path)
@router.post("/request", response_model=VideoRequestResponse) @router.post("/request", response_model=VideoRequestResponse)
@guard_deco.rate_limit(requests=20, window=60) @guard_deco.rate_limit(requests=20, window=60)
@guard_deco.block_clouds() @guard_deco.block_clouds()
@@ -144,16 +165,25 @@ async def list_video_posts(
return [_to_response(t) for t in tasks] return [_to_response(t) for t in tasks]
@router.get("/posts/{task_id}/media") @router.get("/posts/{task_id}/media", response_model=None)
async def get_video_post_media( async def get_video_post_media(
task_id: UUID, task_id: UUID,
cut: str, cut: str,
db: DbSession, db: DbSession,
agent: CurrentAgentContext, agent: CurrentAgentContext,
) -> FileResponse: ) -> StreamingResponse | FileResponse:
"""Serve one rendered MP4 cut of a held video_post draft — the panel """Serve one rendered MP4 cut of a held video_post draft — the panel
preview player's ``src``. 404s on a missing task/cut/file; 400 on a preview player's ``src``. 404s on a missing task/cut/file; 400 on a
``cut`` outside {vertical, square}.""" ``cut`` outside {vertical, square}.
When MinIO is configured (``minio_endpoint`` set) the route streams the
object from MinIO via ``minio_client.get_object_stream`` (key = the
basename of ``mp4_path``). Auth stays end-to-end ``_require_ceo`` is
kept, no presigned URLs, no redirect so the panel's axios-blob flow is
unchanged (same URL, headers, body just chunked). Falls back to
``FileResponse`` from the local render dir when MinIO is unconfigured OR
on ``S3Error`` (old renders not yet in MinIO / MinIO down). The local file
existence + confinement checks stay as defense-in-depth."""
_require_ceo(agent) _require_ceo(agent)
if cut not in _VALID_CUTS: if cut not in _VALID_CUTS:
raise HTTPException( raise HTTPException(
@@ -165,18 +195,25 @@ async def get_video_post_media(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such video draft" status_code=status.HTTP_404_NOT_FOUND, detail="No such video draft"
) )
draft = markers.get_video_draft(task) or {} mp4_path = _resolve_video_cut(task, cut)
mp4_path = (draft.get("mp4_paths") or {}).get(cut) key = mp4_path.name
if not mp4_path or not Path(mp4_path).is_file(): if minio_client.get_client() is not None:
raise HTTPException( try:
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut" # Eager probe so a missing object / down MinIO raises HERE — the
) # fallback below catches it. get_object_stream is a lazy generator,
output_dir = Path(settings.video_output_dir).resolve() # so wrapping StreamingResponse(...) alone wouldn't catch S3Error:
if not Path(mp4_path).resolve().is_relative_to(output_dir): # it fires on the first next(), after Starlette has started
# Defense-in-depth: a mp4_paths entry pointing outside the configured # streaming and the response is no longer take-back-able.
# render dir is refused even though the file exists on disk. await asyncio.to_thread(minio_client.stat_object, key)
raise HTTPException( except Exception:
status_code=status.HTTP_404_NOT_FOUND, detail=f"No rendered {cut} cut" # NoSuchKey (old render not yet in MinIO) or MinIO down — fall
# back to the local file, which is the source of truth. Auth
# already passed; this is purely a storage-read fallback.
pass
else:
return StreamingResponse(
minio_client.get_object_stream(key),
media_type="video/mp4",
) )
return FileResponse(mp4_path, media_type="video/mp4") return FileResponse(mp4_path, media_type="video/mp4")
+26
View File
@@ -956,6 +956,32 @@ class Settings(BaseSettings):
"The sidecar never writes here directly — it only returns bytes." "The sidecar never writes here directly — it only returns bytes."
), ),
) )
# MinIO object storage for rendered MP4s. Empty endpoint = disabled (the
# media route falls back to FileResponse from the local video-renders dir).
# Armed in the NAS compose; intentionally left OFF in the registry compose.
minio_endpoint: str = Field(
default="",
description=(
"MinIO endpoint, e.g. http://roboco-minio:9000. Empty = disabled "
"(FileResponse fallback)."
),
)
minio_access_key: str = Field(
default="",
description="MinIO access key. Required when minio_endpoint is set.",
)
minio_secret_key: str = Field(
default="",
description="MinIO secret key. Required when minio_endpoint is set.",
)
minio_bucket: str = Field(
default="roboco-video-renders",
description="MinIO bucket for rendered videos.",
)
minio_region: str = Field(
default="us-east-1",
description="MinIO region.",
)
# Board roadmap engine — weekly, the Product Owner explores the company's # Board roadmap engine — weekly, the Product Owner explores the company's
# projects and proposes a themed cycle of roadmap items; the CEO approves # projects and proposes a themed cycle of roadmap items; the CEO approves
+18 -7
View File
@@ -13,6 +13,7 @@ on are pure.
from __future__ import annotations from __future__ import annotations
import asyncio
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -87,7 +88,11 @@ class ConventionsService(BaseService):
) -> ConventionsStandard: ) -> ConventionsStandard:
"""Return the effective standard for ``project`` at its current HEAD.""" """Return the effective standard for ``project`` at its current HEAD."""
pid = self._pid(project) pid = self._pid(project)
root, head = self._resolve(project, workspace) # _resolve runs `git rev-parse` and _read_committed_standard/_derive
# walk the filesystem + parse yaml — all sync I/O. Offload to a thread
# so the shared API event loop stays responsive during conventions reads
# (reachable from GET /api/projects/{id}/conventions and the spawn path).
root, head = await asyncio.to_thread(self._resolve, project, workspace)
cached = await self._cache_get(pid, head) cached = await self._cache_get(pid, head)
# A cached ``degraded`` row is not trusted: a degraded file may have # A cached ``degraded`` row is not trusted: a degraded file may have
# been repaired in place at the same (stale) head key, and serving the # been repaired in place at the same (stale) head key, and serving the
@@ -95,7 +100,9 @@ class ConventionsService(BaseService):
if cached is not None and cached.status != "degraded": if cached is not None and cached.status != "degraded":
return ConventionsStandard.model_validate(cached.effective_map) return ConventionsStandard.model_validate(cached.effective_map)
file_standard, status = self._read_committed_standard(root) file_standard, status = await asyncio.to_thread(
self._read_committed_standard, root
)
if status == "degraded": if status == "degraded":
last_good = await self._latest_ok_map(pid) last_good = await self._latest_ok_map(pid)
if last_good is not None: if last_good is not None:
@@ -103,7 +110,9 @@ class ConventionsService(BaseService):
# repaired in place), so never pin it — re-derive next call. # repaired in place), so never pin it — re-derive next call.
return last_good return last_good
mapping = effective_map(self._derive(root), file_standard) mapping = effective_map(
await asyncio.to_thread(self._derive, root), file_standard
)
if status != "degraded": if status != "degraded":
await self._cache_put(pid, head, mapping, status) await self._cache_put(pid, head, mapping, status)
return mapping return mapping
@@ -185,8 +194,8 @@ class ConventionsService(BaseService):
if last_good is not None: if last_good is not None:
mapping = last_good mapping = last_good
else: else:
root, _ = self._resolve(project, workspace) root, _ = await asyncio.to_thread(self._resolve, project, workspace)
mapping = self._derive(root) mapping = await asyncio.to_thread(self._derive, root)
return await self._publish( return await self._publish(
project, render_yaml(mapping), restore=True, workspace=workspace project, render_yaml(mapping), restore=True, workspace=workspace
) )
@@ -215,11 +224,13 @@ class ConventionsService(BaseService):
resolvable workspace at all. resolvable workspace at all.
""" """
pid = self._pid(project) pid = self._pid(project)
root, head = self._resolve(project, workspace) root, head = await asyncio.to_thread(self._resolve, project, workspace)
if root is None: if root is None:
status = "unknown" status = "unknown"
else: else:
_file_standard, status = self._read_committed_standard(root) _file_standard, status = await asyncio.to_thread(
self._read_committed_standard, root
)
last_ok = await self._latest_ok_row(pid) last_ok = await self._latest_ok_row(pid)
return ConventionsHealth( return ConventionsHealth(
status=status, status=status,
+129
View File
@@ -0,0 +1,129 @@
"""MinIO storage client for rendered MP4s.
A thin wrapper around a singleton `minio.Minio` built from settings. The client
is sync (minio-py is sync); every call site wraps the call in
`asyncio.to_thread` same pattern as `remotion_client._save`.
Unconfigured guard: when `settings.minio_endpoint` is empty, `get_client()`
returns `None`. The write/serve paths (chunks 3/4) check `get_client()` and
fall back to the local-disk path, so MinIO is opt-in via a single env var.
`put_object` is defensive on the same guard (no-ops when unconfigured) so a
caller that forgets the guard cannot crash; `get_object_stream` assumes the
caller checked (the route's fallback catches `S3Error` for the not-found case).
"""
from __future__ import annotations
import io
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from minio import Minio
from minio.error import S3Error # re-exported for callers (the serve route)
from roboco.config import settings
if TYPE_CHECKING:
from collections.abc import Iterator
_client: Minio | None = None
_initialised = False
def _reset_client() -> None:
"""Test-only: drop the cached singleton so the next `get_client()` rebuilds."""
global _client, _initialised # noqa: PLW0603 - singleton cache, by design
_client = None
_initialised = False
def get_client() -> Minio | None:
"""Return the singleton `Minio`, or `None` when MinIO is unconfigured.
`None` is the disabled path: an empty `settings.minio_endpoint` means the
write/serve paths fall back to local disk. Settings are load-time, so a
plain module-level singleton is fine (no runtime-config drift to guard).
"""
global _client, _initialised # noqa: PLW0603 - singleton cache, by design
if _initialised:
return _client
_initialised = True
endpoint = settings.minio_endpoint.strip()
if not endpoint:
_client = None
return None
parsed = urlparse(endpoint if "://" in endpoint else f"//{endpoint}")
secure = parsed.scheme == "https"
host = parsed.netloc or endpoint # no scheme → use as-is
_client = Minio(
endpoint=host,
access_key=settings.minio_access_key or None,
secret_key=settings.minio_secret_key or None,
secure=secure,
region=settings.minio_region or None,
)
return _client
def put_object(data: bytes, key: str) -> None:
"""PUT `data` to `settings.minio_bucket` under `key` as video/mp4.
No-ops when MinIO is unconfigured (`get_client()` is `None`). The write path
is guarded by `settings.minio_endpoint` upstream anyway; this is defensive
so a caller that forgets the guard cannot crash.
"""
client = get_client()
if client is None:
return
client.put_object(
bucket_name=settings.minio_bucket,
object_name=key,
data=io.BytesIO(data),
length=len(data),
content_type="video/mp4",
)
def stat_object(key: str) -> None:
"""Eager existence/readiness probe — raises if the object is missing or
MinIO is down, so the serve route can fall back to ``FileResponse`` BEFORE
starting a ``StreamingResponse`` it can no longer take back.
``get_object_stream`` is a lazy generator: its ``client.get_object`` call
runs on the first ``next()``, i.e. after the route has returned and
Starlette has started streaming an ``S3Error`` there is uncatchable. This
probe runs eagerly inside the route's ``try/except`` so the fallback
actually fires. No-op when unconfigured (the route checks ``get_client()``
first; this is defensive).
ponytail: stat-then-get is two round trips; a mid-stream failure after a
successful stat is a rare race (object deleted / MinIO blips between the
two calls) the CEO can retry accept, or merge into one eager get_object
returning the open response for a single round trip if preview latency
ever matters.
"""
client = get_client()
if client is None:
return
client.stat_object(bucket_name=settings.minio_bucket, object_name=key)
def get_object_stream(key: str) -> Iterator[bytes]:
"""Yield object bytes from `settings.minio_bucket`/`key` for `StreamingResponse`.
Assumes `get_client()` is not `None` (the route checks first). Lets
`S3Error` propagate for the not-found / connection-refused case so the
serve route's `try/except` can fall back to `FileResponse` from disk.
"""
client = get_client()
if client is None: # defensive: caller should have checked
raise RuntimeError("MinIO client is unconfigured (minio_endpoint empty)")
response = client.get_object(bucket_name=settings.minio_bucket, object_name=key)
try:
yield from response.stream(amt=2**16)
finally:
response.close()
response.release_conn()
__all__ = ["S3Error", "get_client", "get_object_stream", "put_object", "stat_object"]
+7 -1
View File
@@ -22,6 +22,7 @@ Correctness is deterministic: the readiness audit lives in code
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -227,7 +228,12 @@ class ReleaseManagerEngine(BaseService):
) )
conclusion = (ci or {}).get("conclusion") conclusion = (ci or {}).get("conclusion")
today = datetime.now(UTC).strftime("%Y-%m-%d") today = datetime.now(UTC).strftime("%Y-%m-%d")
snapshot = gather_snapshot(Path(root), master_ci_conclusion=conclusion) # gather_snapshot runs multiple sync `subprocess.run` git calls + a
# filesystem walk; offload so the shared API event loop isn't blocked
# while the release-manager background loop assesses.
snapshot = await asyncio.to_thread(
gather_snapshot, Path(root), master_ci_conclusion=conclusion
)
return assess(snapshot, today=today) return assess(snapshot, today=today)
+27 -1
View File
@@ -22,8 +22,12 @@ from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
import structlog
from roboco.config import settings from roboco.config import settings
from roboco.services import minio_client
log = structlog.get_logger(__name__)
class RemotionRendererError(Exception): class RemotionRendererError(Exception):
@@ -136,11 +140,33 @@ class RemotionRenderer:
@staticmethod @staticmethod
def _save(mp4_bytes: bytes, *, render_key: str, orientation: str) -> str: def _save(mp4_bytes: bytes, *, render_key: str, orientation: str) -> str:
"""Write MP4 bytes under video_output_dir at a task-scoped path.""" """Write MP4 bytes under video_output_dir at a task-scoped path.
Durable copy to MinIO when configured. Local disk stays the source of
truth for the poster publish path (x_video_client/tiktok_client read
mp4_path from disk), so this is an additive PUT, not a replacement.
Key = basename, already ``{render_key}-{orientation}.mp4`` no
schema/marker change. ``_save`` is wrapped in ``asyncio.to_thread`` by
``render()``, so the sync ``put_object`` call runs in that thread.
"""
out_dir = Path(settings.video_output_dir) out_dir = Path(settings.video_output_dir)
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{render_key}-{orientation}.mp4" path = out_dir / f"{render_key}-{orientation}.mp4"
path.write_bytes(mp4_bytes) path.write_bytes(mp4_bytes)
if minio_client.get_client() is not None:
# MinIO is a durable COPY, not the render's source of truth — local
# disk is. The serve route falls back to FileResponse on S3Error, so
# a failed PUT (MinIO down, full disk, transient 5xx) must never fail
# the render or it'd retry-loop a task whose local file is already
# fine. Log and continue; the next render re-attempts the PUT.
try:
minio_client.put_object(mp4_bytes, path.name)
except Exception as exc: # durable copy, never fatal to the render
log.warning(
"minio put failed; render kept on local disk",
key=path.name,
error=str(exc),
)
return str(path) return str(path)
+150
View File
@@ -5,6 +5,7 @@ sub-router. CEO-only throughout."""
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -14,6 +15,7 @@ import pytest_asyncio
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes import video as video_module
from roboco.api.routes.video import router as video_router from roboco.api.routes.video import router as video_router
from roboco.api.routes.video import tiktok_router from roboco.api.routes.video import tiktok_router
from roboco.config import settings as cfg from roboco.config import settings as cfg
@@ -23,6 +25,7 @@ from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext from roboco.models.permissions import AgentContext
from roboco.services import minio_client
from roboco.services.heartbeat_mutex import HeartbeatMutex from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_credentials import get_tiktok_credentials_service from roboco.services.tiktok_credentials import get_tiktok_credentials_service
@@ -543,3 +546,150 @@ async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
assert media_resp.status_code == HTTPStatus.FORBIDDEN assert media_resp.status_code == HTTPStatus.FORBIDDEN
assert creds_resp.status_code == HTTPStatus.FORBIDDEN assert creds_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear() app.dependency_overrides.clear()
# --- MinIO serve path (chunk 4) — unit-style, no DB / no real MinIO ------------
# These two tests monkeypatch ``get_task_service`` in the video routes module
# so they run without postgres (the ``db_session``-based tests above are
# skipped when Postgres is unreachable). Mocks only — no testcontainers.
def _stub_task_service_factory(task: object) -> object:
"""A ``get_task_service``-shaped stub (the real one is a sync factory
returning a service with an async ``.get``). Patched in place of
``video_module.get_task_service`` so the route runs without postgres."""
class _Svc:
async def get(self, _task_id: UUID) -> object:
return task
return _Svc()
def _make_task(mp4_path: str, task_id: UUID) -> SimpleNamespace:
"""A minimal task-shaped stub carrying the video_draft marker the route
reads enough for the media route, no DB row needed."""
return SimpleNamespace(
id=task_id,
source=VIDEO_POST_SOURCE,
orchestration_markers={"video_draft": {"mp4_paths": {"vertical": mp4_path}}},
)
def _patch_task_service(monkeypatch: pytest.MonkeyPatch, task: object) -> None:
monkeypatch.setattr(
video_module,
"get_task_service",
lambda _db: _stub_task_service_factory(task),
)
def _minio_stream(_key: str) -> object:
"""Stub ``get_object_stream`` yielding fixed bytes for ``StreamingResponse``."""
return iter([b"minio-stream-bytes"])
@pytest.mark.asyncio
async def test_media_serves_from_minio_when_configured(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Configured serve path: when MinIO is configured, the media route streams
the object via ``minio_client.get_object_stream`` (key = basename) and the
panel-preview URL/headers stay identical. ``_require_ceo`` still 403s a
non-CEO agent. No DB / no real MinIO ``get_task_service`` is stubbed so
the route runs without postgres."""
# A real local file so the route's is_file() + confinement checks pass.
# The served bytes come from the stubbed MinIO stream below, NOT this
# file — that's what proves the MinIO path was taken rather than the
# FileResponse fallback.
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
vertical = tmp_path / "clip-vertical.mp4"
vertical.write_bytes(b"local-file-bytes")
task_id = uuid4()
_patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
# non-None sentinel so the route takes the MinIO branch.
monkeypatch.setattr(minio_client, "get_client", lambda: True)
# The route probes stat_object eagerly before streaming; stub it to pass.
monkeypatch.setattr(minio_client, "stat_object", lambda _key: None)
monkeypatch.setattr(minio_client, "get_object_stream", _minio_stream)
# CEO 200 — streamed from MinIO.
app = _build_app(None, AgentRole.CEO, uuid4()) # type: ignore[arg-type]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "video/mp4"
assert resp.content == b"minio-stream-bytes"
app.dependency_overrides.clear()
# Non-CEO 403 — _require_ceo still gates end-to-end (no presigned URL).
app = _build_app(None, AgentRole.DEVELOPER, uuid4()) # type: ignore[arg-type]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_media_falls_back_to_local_file_when_minio_unconfigured(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Unconfigured fallback: with ``get_client`` returning None
(``minio_endpoint`` empty), the media route serves the local file via
``FileResponse`` the body equals the local file's bytes. No DB / no
real MinIO."""
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
vertical = tmp_path / "clip-vertical.mp4"
vertical.write_bytes(b"local-file-bytes")
task_id = uuid4()
_patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
monkeypatch.setattr(minio_client, "get_client", lambda: None)
app = _build_app(None, AgentRole.CEO, uuid4()) # type: ignore[arg-type]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "video/mp4"
assert resp.content == b"local-file-bytes"
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_media_falls_back_to_local_file_when_minio_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""S3Error fallback: when MinIO is configured but the object is missing
(NoSuchKey an old render not yet in MinIO) or MinIO is down, the route's
eager ``stat_object`` probe raises, the ``try/except`` catches it, and the
route serves the local file via ``FileResponse``. ``get_object_stream`` is
never called. No DB / no real MinIO."""
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
vertical = tmp_path / "clip-vertical.mp4"
vertical.write_bytes(b"local-file-bytes")
task_id = uuid4()
_patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
monkeypatch.setattr(minio_client, "get_client", lambda: True)
def _stat_raises(_key: str) -> None:
raise RuntimeError("minio NoSuchKey / down")
monkeypatch.setattr(minio_client, "stat_object", _stat_raises)
# If the route wrongly takes the MinIO stream branch, this would be called
# and the assertion below would fail — guard against a regression.
def _stream_must_not_be_called(_key: str) -> object:
pytest.fail("get_object_stream must not be called when stat_object raises")
monkeypatch.setattr(minio_client, "get_object_stream", _stream_must_not_be_called)
app = _build_app(None, AgentRole.CEO, uuid4()) # type: ignore[arg-type]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "video/mp4"
assert resp.content == b"local-file-bytes" # FileResponse fallback, not MinIO
app.dependency_overrides.clear()
+112
View File
@@ -0,0 +1,112 @@
"""MinIO client coverage: unconfigured guard + endpoint scheme parsing (mocks)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from roboco.config import settings as cfg
from roboco.services import minio_client
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(autouse=True)
def _reset_singleton() -> Iterator[None]:
"""Each test rebuilds the singleton (the test-isolation hazard)."""
minio_client._reset_client()
yield
minio_client._reset_client()
def test_get_client_returns_none_when_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "minio_endpoint", "")
monkeypatch.setattr(cfg, "minio_access_key", "")
monkeypatch.setattr(cfg, "minio_secret_key", "")
assert minio_client.get_client() is None
def test_get_client_parses_endpoint_scheme(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""http://roboco-minio:9000 → endpoint=roboco-minio:9000, secure=False."""
built: dict[str, Any] = {}
class FakeMinio:
def __init__(
self,
*,
endpoint: str,
access_key: str | None,
secret_key: str | None,
secure: bool,
region: str | None,
) -> None:
built["endpoint"] = endpoint
built["access_key"] = access_key
built["secret_key"] = secret_key
built["secure"] = secure
built["region"] = region
monkeypatch.setattr(minio_client, "Minio", FakeMinio)
monkeypatch.setattr(cfg, "minio_endpoint", "http://roboco-minio:9000")
monkeypatch.setattr(cfg, "minio_access_key", "minio")
monkeypatch.setattr(cfg, "minio_secret_key", "minio123")
monkeypatch.setattr(cfg, "minio_region", "us-east-1")
minio_client.get_client()
assert built["endpoint"] == "roboco-minio:9000"
assert built["secure"] is False
assert built["access_key"] == "minio"
assert built["secret_key"] == "minio123"
assert built["region"] == "us-east-1"
def test_get_client_parses_https_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
built: dict[str, Any] = {}
class FakeMinio:
def __init__(self, **kwargs: Any) -> None:
built["endpoint"] = kwargs["endpoint"]
built["secure"] = kwargs["secure"]
monkeypatch.setattr(minio_client, "Minio", FakeMinio)
monkeypatch.setattr(cfg, "minio_endpoint", "https://minio.example:9000")
minio_client._reset_client()
minio_client.get_client()
assert built["endpoint"] == "minio.example:9000"
assert built["secure"] is True
def test_put_object_noops_when_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "minio_endpoint", "")
minio_client._reset_client()
# Must not raise — the write path's upstream guard is the source of truth,
# but this stays defensive.
minio_client.put_object(b"bytes", "key.mp4")
def test_get_object_stream_raises_when_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "minio_endpoint", "")
minio_client._reset_client()
with pytest.raises(RuntimeError, match="unconfigured"):
next(minio_client.get_object_stream("key.mp4"))
def test_get_client_singleton_cached(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "minio_endpoint", "http://roboco-minio:9000")
minio_client._reset_client()
a = minio_client.get_client()
b = minio_client.get_client()
assert a is b
@@ -9,6 +9,7 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
from roboco.config import settings as cfg from roboco.config import settings as cfg
from roboco.services import minio_client
from roboco.services.remotion_client import ( from roboco.services.remotion_client import (
NullRemotionRenderer, NullRemotionRenderer,
RemotionRenderer, RemotionRenderer,
@@ -166,3 +167,90 @@ def test_get_remotion_renderer_returns_real_client_when_set(
renderer = get_remotion_renderer() renderer = get_remotion_renderer()
assert isinstance(renderer, RemotionRenderer) assert isinstance(renderer, RemotionRenderer)
assert not isinstance(renderer, NullRemotionRenderer) assert not isinstance(renderer, NullRemotionRenderer)
@pytest.mark.asyncio
async def test_save_puts_to_minio_when_configured(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When MinIO is configured, _save PUTs the bytes under the basename key
and still writes the local mp4 file. Mocks only no real MinIO."""
source = _make_source(tmp_path)
out_dir = tmp_path / "out"
monkeypatch.setattr(cfg, "video_output_dir", str(out_dir))
monkeypatch.setattr(cfg, "video_request_timeout_seconds", 5.0)
monkeypatch.setattr(cfg, "video_render_timeout_seconds", 30.0)
minio_client._reset_client()
# Sentinel client so get_client() returns a non-None (the guard passes).
monkeypatch.setattr(minio_client, "get_client", object)
put_calls: list[tuple[bytes, str]] = []
def fake_put_object(data: bytes, key: str) -> None:
put_calls.append((data, key))
monkeypatch.setattr(minio_client, "put_object", fake_put_object)
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"fake-mp4-bytes")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = RemotionRenderer(base_url="http://fake-remotion", client=http_client)
try:
path = await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={"title": "hello"},
orientation="vertical",
render_key="task-77",
)
finally:
await http_client.aclose()
minio_client._reset_client()
# Local file still written (the poster publish path reads from disk).
saved = Path(path)
assert saved.exists()
assert saved.read_bytes() == b"fake-mp4-bytes"
assert saved.name == "task-77-vertical.mp4"
# One PUT, key = the basename, body = the rendered bytes (reused, not re-read).
assert len(put_calls) == 1
data, key = put_calls[0]
assert key == "task-77-vertical.mp4"
assert data == b"fake-mp4-bytes"
@pytest.mark.asyncio
async def test_save_swallows_minio_put_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failed MinIO PUT (MinIO down, transient 5xx) must not fail the render —
local disk is the source of truth and the serve route falls back to
FileResponse on S3Error. _save logs and returns the local path; the local
file is written. Mocks only."""
out_dir = tmp_path / "out"
monkeypatch.setattr(cfg, "video_output_dir", str(out_dir))
minio_client._reset_client()
monkeypatch.setattr(minio_client, "get_client", object) # guard passes
def failing_put_object(_data: bytes, _key: str) -> None:
raise RuntimeError("minio unreachable")
monkeypatch.setattr(minio_client, "put_object", failing_put_object)
try:
# _save is a sync @staticmethod; call it directly (no httpx needed).
path = RemotionRenderer._save(
b"fake-mp4-bytes", render_key="task-88", orientation="square"
)
finally:
minio_client._reset_client()
saved = Path(path)
assert saved.exists()
assert saved.read_bytes() == b"fake-mp4-bytes"
assert saved.name == "task-88-square.mp4"
Generated
+48
View File
@@ -1611,6 +1611,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "minio"
version = "7.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
]
[[package]] [[package]]
name = "msgpack" name = "msgpack"
version = "1.2.1" version = "1.2.1"
@@ -2115,6 +2131,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@@ -2547,6 +2593,7 @@ dependencies = [
{ name = "hiredis" }, { name = "hiredis" },
{ name = "httpx" }, { name = "httpx" },
{ name = "mcp" }, { name = "mcp" },
{ name = "minio" },
{ name = "openai" }, { name = "openai" },
{ name = "packaging" }, { name = "packaging" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
@@ -2625,6 +2672,7 @@ requires-dist = [
{ name = "import-linter", marker = "extra == 'dev'" }, { name = "import-linter", marker = "extra == 'dev'" },
{ name = "ipython", marker = "extra == 'dev'" }, { name = "ipython", marker = "extra == 'dev'" },
{ name = "mcp" }, { name = "mcp" },
{ name = "minio" },
{ name = "mypy", marker = "extra == 'dev'" }, { name = "mypy", marker = "extra == 'dev'" },
{ name = "openai" }, { name = "openai" },
{ name = "packaging" }, { name = "packaging" },