[video-engine] Per-project video_engine_enabled opt-in toggle

Mirrors ci_watch_enabled (migration 048): the global
ROBOCO_VIDEO_ENGINE_ENABLED flag arms the subsystem; the new
projects.video_engine_enabled column (migration 063) opts a repo into
authoring against its motion/ dir. VideoEngine._opted_in_project no-ops
open_video_task at the single chokepoint covering all three trigger
paths (on-release, on-spotlight, CEO on-demand) until the operator
flips it in the panel edit-project dialog. Existing projects stay
opted out (server_default=false).
This commit is contained in:
Renn F
2026-07-06 04:29:53 +02:00
parent 6ed4e1391b
commit ba646da07a
14 changed files with 136 additions and 8 deletions
+1
View File
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### 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 HyperFrames HTML composition under `motion/compositions/<id>/` 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 `video-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).
- **Per-project video-engine opt-in.** `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global `ROBOCO_VIDEO_ENGINE_ENABLED` flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/` dir — `VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it in the panel's edit-project dialog. Existing projects stay opted out.
- **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.** `video_renderer_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.
+1 -1
View File
@@ -387,7 +387,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes, drafts replies to meaningful mentions, and — a third, independent capability — periodically investigates RoboCo's own shipped features and drafts a spotlight for an under-publicized one. NOTHING auto-posts across any of the three; every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply` / `x_feature`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a body clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps — both are **local-model-drafted** (never a cloud LLM in the hot path). The spotlight half is the one exception to "no agent spawn": gated by its own sub-switch `ROBOCO_X_FEATURE_SPOTLIGHT_ENABLED` (+ `_INTERVAL_SECONDS`, default 3 days) on top of `x_engine_enabled`, `_x_feature_spotlight_loop` opens a held PENDING exploration task (`source=x_feature_exploration`, team=Board, assigned to Head of Marketing, carrying a `x_seen_features` dedup-ledger snapshot marker) that `_dispatch_pm_work` routes (mirroring `ROADMAP_SOURCE`) to a one-shot real cloud-LLM spawn of the Head of Marketing — full read tools, investigates CHANGELOG.md/feature-flags/docs/map/charter/KB, calls the Head-of-Marketing-only `propose_feature_spotlight` do-tool exactly once, which marks the feature slug seen (`x_seen_features` table, migration 061) and materializes a brand-new `source=x_feature` held draft (completing the exploration task as a side effect — a deliberate asymmetry from `propose_roadmap`, which instead leaves its own task open). The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). All three draft kinds share one voice: `XEngine._voice_guide` reads the CEO-editable `company_goals.brand_voice` charter field (migration 061, panel-editable in Business → Goals) and appends it to a generic baseline (`_HOM_VOICE`) — the baseline alone until the CEO supplies a real sample. Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject, a `sourceMeta`-driven label/icon per source including "Feature spotlight") + `x-credentials-card.tsx` (4 write-only secret inputs).
**RoboCo video engine (default-off).** Bespoke motion-graphics videos (release announcements, feature spotlights, on-demand CEO briefs) authored by a UX/UI dev and distributed to X/TikTok — nothing renders or posts without the flags on, and nothing posts without an explicit CEO approval. Gated by `ROBOCO_VIDEO_ENGINE_ENABLED` (+ sub-switches `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT`, and `_MAX_OPEN_POSTS` / `_RENDER_INTERVAL_SECONDS` / `_RENDER_TIMEOUT_SECONDS` / `_REQUEST_TIMEOUT_SECONDS` / `_OUTPUT_DIR`); a CEO on-demand brief rides `POST /video/request` regardless of the release/spotlight sub-switches. Two task kinds mirror the XEngine/ReleaseManagerEngine "originate a CEO-scoped artifact" shape but split across the real delivery lifecycle: `VideoEngine.open_video_task` (`roboco/services/video_engine.py`) opens a normal, ASSIGNED **authoring task** (`source=video`, `confirmed_by_human=True`, team=UX/UI, balanced across `ux-dev-1`/`ux-dev-2` by open-task count) that dispatches like any other pre-assigned code task — NOT held, NOT in any dispatcher's skip bucket. The assigned dev builds a HyperFrames HTML composition under `motion/compositions/<id>/` and calls the UX/UI-team-gated `propose_video` do-tool (metadata-only: composition id, input props, per-platform captions — every developer role carries the tool on their manifest, but the runtime `_caller_team` check rejects a be-dev/fe-dev) to stamp the task's `video_draft` marker, then commits + `open_pr` through the normal PR-review gate. Once that authoring task reaches `completed`, the orchestrator's `_video_render_loop` (bounded retry, `_MAX_VIDEO_RENDER_ATTEMPTS`) tars the merged `motion/` dir from the project's read-clone and POSTs it to the credential-free **video-renderer sidecar** (`VideoRenderer` in `roboco/services/video_renderer_client.py`, `ROBOCO_VIDEO_RENDERER_BASE_URL`) to render both the 9:16 and 1:1 cuts to MP4 (`video_output_dir`); on success `VideoEngine._originate_video_post` materializes a held **video-post draft** (`source=video_post`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) carrying `mp4_paths` (`{vertical, square}` absolute paths) + the per-platform captions. The CEO reviews it in the panel's video queue (`video-post-queue.tsx`; `GET /video/posts` lists drafts including `mp4_paths` so the panel knows which cuts exist, `GET /video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player, CEO-gated throughout) and edits captions / approves / rejects. `VideoPostService.approve` (`roboco/services/video_post_service.py`) is the ONLY caller of the X-v2 (`XVideoPoster` in `x_video_client.py`) and TikTok inbox-upload (`TikTokPoster` in `tiktok_client.py`) posters; because a video upload + transcode/poll can run well past a minute, the critical section runs under a heartbeat-renewed Redis mutex (`heartbeat_mutex.py`, mirroring `ReleaseProposalService`'s release-execute lock shape) rather than a flat lock, commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster again). TikTok's four OAuth2 secrets live Fernet-encrypted in a singleton `tiktok_credentials` row (mirroring the git-token / `x_credentials` pattern; the API only ever returns `has_credentials`) — set via the panel's TikTok credentials card. `NullVideoRenderer` / `NullXVideoPoster` / `NullTikTokPoster` make every unconfigured leg a graceful no-op rather than a crash.
**RoboCo video engine (default-off).** Bespoke motion-graphics videos (release announcements, feature spotlights, on-demand CEO briefs) authored by a UX/UI dev and distributed to X/TikTok — nothing renders or posts without the flags on, and nothing posts without an explicit CEO approval. Gated by `ROBOCO_VIDEO_ENGINE_ENABLED` (+ sub-switches `ROBOCO_VIDEO_ON_RELEASE` / `ROBOCO_VIDEO_ON_SPOTLIGHT`, and `_MAX_OPEN_POSTS` / `_RENDER_INTERVAL_SECONDS` / `_RENDER_TIMEOUT_SECONDS` / `_REQUEST_TIMEOUT_SECONDS` / `_OUTPUT_DIR`); a CEO on-demand brief rides `POST /video/request` regardless of the release/spotlight sub-switches. A project opts in via `projects.video_engine_enabled` (migration 063, mirroring `ci_watch_enabled`): the global flag arms the subsystem, the per-project flag opts a repo into authoring against its `motion/``VideoEngine._opted_in_project` no-ops `open_video_task` until the operator flips it in the panel's edit-project dialog. Two task kinds mirror the XEngine/ReleaseManagerEngine "originate a CEO-scoped artifact" shape but split across the real delivery lifecycle: `VideoEngine.open_video_task` (`roboco/services/video_engine.py`) opens a normal, ASSIGNED **authoring task** (`source=video`, `confirmed_by_human=True`, team=UX/UI, balanced across `ux-dev-1`/`ux-dev-2` by open-task count) that dispatches like any other pre-assigned code task — NOT held, NOT in any dispatcher's skip bucket. The assigned dev builds a HyperFrames HTML composition under `motion/compositions/<id>/` and calls the UX/UI-team-gated `propose_video` do-tool (metadata-only: composition id, input props, per-platform captions — every developer role carries the tool on their manifest, but the runtime `_caller_team` check rejects a be-dev/fe-dev) to stamp the task's `video_draft` marker, then commits + `open_pr` through the normal PR-review gate. Once that authoring task reaches `completed`, the orchestrator's `_video_render_loop` (bounded retry, `_MAX_VIDEO_RENDER_ATTEMPTS`) tars the merged `motion/` dir from the project's read-clone and POSTs it to the credential-free **video-renderer sidecar** (`VideoRenderer` in `roboco/services/video_renderer_client.py`, `ROBOCO_VIDEO_RENDERER_BASE_URL`) to render both the 9:16 and 1:1 cuts to MP4 (`video_output_dir`); on success `VideoEngine._originate_video_post` materializes a held **video-post draft** (`source=video_post`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) carrying `mp4_paths` (`{vertical, square}` absolute paths) + the per-platform captions. The CEO reviews it in the panel's video queue (`video-post-queue.tsx`; `GET /video/posts` lists drafts including `mp4_paths` so the panel knows which cuts exist, `GET /video/posts/{id}/media?cut=vertical|square` streams the MP4 bytes for the preview player, CEO-gated throughout) and edits captions / approves / rejects. `VideoPostService.approve` (`roboco/services/video_post_service.py`) is the ONLY caller of the X-v2 (`XVideoPoster` in `x_video_client.py`) and TikTok inbox-upload (`TikTokPoster` in `tiktok_client.py`) posters; because a video upload + transcode/poll can run well past a minute, the critical section runs under a heartbeat-renewed Redis mutex (`heartbeat_mutex.py`, mirroring `ReleaseProposalService`'s release-execute lock shape) rather than a flat lock, commits each platform's posted-id durably before attempting the next (a partial failure never re-posts an already-succeeded platform on retry), and is idempotent (an already-`COMPLETED` draft returns the stored ids without calling a poster again). TikTok's four OAuth2 secrets live Fernet-encrypted in a singleton `tiktok_credentials` row (mirroring the git-token / `x_credentials` pattern; the API only ever returns `has_credentials`) — set via the panel's TikTok credentials card. `NullVideoRenderer` / `NullXVideoPoster` / `NullTikTokPoster` make every unconfigured leg a graceful no-op rather than a crash.
**Board roadmap engine (default-off).** The Board originating strategic work: on a weekly interval (`ROBOCO_ROADMAP_ENGINE_ENABLED` + `_INTERVAL_SECONDS` / `_MIN_ITEMS_PER_CYCLE` / `_MAX_ITEMS_PER_CYCLE`) `RoadmapEngine` (`roboco/services/roadmap_engine.py`) opens ONE held **exploration** task (`source="board_roadmap"`, `confirmed_by_human=False`, PENDING, Product-Owner-assigned, `Team.BOARD`), deduped to one open cycle at a time. A dedicated one-shot `_dispatch_roadmap_exploration` spawns the Product Owner **solo** — deliberately NOT `_handle_board_assigned_task` (which would also spawn Head of Marketing and fire the Approve-&-Start handoff, both wrong for a PO-authored cycle) — reusing the `_board_dispatched` one-shot tracker + respawn breaker, and short-circuiting once the cycle is authored. The PO explores (read-only git, KB/RAG, metrics, releases, charter, optional web research) and makes ONE `propose_roadmap` call (a content verb gated to `product_owner` only, `_ROADMAP_ROLES`; wired through the do_server/Choreographer like `pitch`) authoring a **themed cycle** — a one-line goal + 3-7 item drafts — persisted as a `roadmap_cycle` marker on the exploration task (no table/migration). The CEO acts per-item in the panel roadmap queue (`roadmap-review-queue.tsx`; `/api/roadmap/cycles{,/items/{id}/approve,/items/{id}/reject}`, CEO-only): approve materializes that item as a BACKLOG task (`source="roadmap"`, no assignee — never auto-starts; normal PM activation picks it up) via `PrompterService.create_task_from_draft`, reject records a reason; when every item is terminal the exploration task completes (`RoadmapService`, idempotent per item). Dispatchers skip `board_roadmap` (never delivery work). `create_task_from_draft` honors a draft-declared `source` only from a `{prompter, roadmap}` whitelist — an LLM-authored draft can't impersonate a privileged origin.
@@ -0,0 +1,42 @@
"""Per-project video-engine opt-in column.
The video engine is armed by the global ``ROBOCO_VIDEO_ENGINE_ENABLED`` flag,
but authoring writes a HyperFrames composition into a project's ``motion/``
dir, so a project opts in via ``video_engine_enabled``. Additive and
default-off, mirroring ``ci_watch_enabled`` (migration 048): existing projects
keep today's behavior (no video authoring) until the operator flips it in the
panel.
Revision ID: 063_video_engine_project_toggle
Revises: 062_tiktok_credentials
Create Date: 2026-07-06
NOTE: revision id is 25 chars alembic's ``alembic_version.version_num`` is
``VARCHAR(32)`` and a longer id raises at record time.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "063_video_engine_project_toggle"
down_revision = "062_tiktok_credentials"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column(
"video_engine_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("projects", "video_engine_enabled")
@@ -76,6 +76,9 @@ function EditProjectForm({
const [ciWatchWorkflow, setCiWatchWorkflow] = useState(
project.ci_watch_workflow || "",
);
const [videoEngineEnabled, setVideoEngineEnabled] = useState(
project.video_engine_enabled,
);
const [depUpdateCommand, setDepUpdateCommand] = useState(
project.dep_update_command || "",
);
@@ -120,6 +123,7 @@ function EditProjectForm({
quality_command: qualityCommand || undefined,
ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined,
video_engine_enabled: videoEngineEnabled,
dep_update_command: depUpdateCommand || undefined,
dep_update_paths: depUpdatePaths.trim()
? depUpdatePaths
@@ -410,6 +414,17 @@ function EditProjectForm({
</p>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="video_engine_enabled">
Video engine (author marketing videos into this project)
</Label>
<Switch
id="video_engine_enabled"
checked={videoEngineEnabled}
onCheckedChange={setVideoEngineEnabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dep_update_command">
Dependency-Update Command
+1
View File
@@ -90,6 +90,7 @@ export const projectsApi = {
quality_command: project.quality_command ?? null,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
+2
View File
@@ -1016,6 +1016,7 @@ export interface Project {
// Autonomous maintenance opt-in
ci_watch_enabled: boolean;
ci_watch_workflow: string | null;
video_engine_enabled: boolean;
dep_update_command: string | null;
dep_update_paths: string[] | null;
sandbox_services: string[] | null;
@@ -1064,6 +1065,7 @@ export interface ProjectUpdate {
// Autonomous maintenance opt-in
ci_watch_enabled?: boolean;
ci_watch_workflow?: string;
video_engine_enabled?: boolean;
dep_update_command?: string;
dep_update_paths?: string[];
sandbox_services?: string[];
+3
View File
@@ -47,6 +47,7 @@ class ProjectResponse(BaseModel):
# Autonomous maintenance opt-in
ci_watch_enabled: bool = False
ci_watch_workflow: str | None = None
video_engine_enabled: bool = False
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None
@@ -140,6 +141,7 @@ class ProjectUpdateRequest(BaseModel):
# Autonomous maintenance opt-in
ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None
video_engine_enabled: bool | None = None
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None
@@ -226,6 +228,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
quality_command=project.quality_command,
ci_watch_enabled=bool(project.ci_watch_enabled),
ci_watch_workflow=project.ci_watch_workflow,
video_engine_enabled=bool(project.video_engine_enabled),
dep_update_command=project.dep_update_command,
dep_update_paths=project.dep_update_paths,
sandbox_services=project.sandbox_services,
+8
View File
@@ -500,6 +500,14 @@ class ProjectTable(Base):
)
ci_watch_workflow: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Video-engine opt-in. The global ROBOCO_VIDEO_ENGINE_ENABLED flag arms the
# subsystem; a project opts in via video_engine_enabled before any
# authoring task opens against its motion/ dir. Default-off, mirroring
# ci_watch_enabled (migration 048).
video_engine_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false", default=False
)
# Dependency-update bot opt-in. A project participates only when
# dep_update_command is set (e.g. "uv lock --upgrade"); dep_update_paths are
# the lockfile globs the probe inspects (null → infer uv.lock/pnpm-lock.yaml).
+7
View File
@@ -128,6 +128,12 @@ class Project(TimestampMixin):
default=None, description="Workflow file to scope the CI-watch signal to"
)
# Video-engine opt-in (global ROBOCO_VIDEO_ENGINE_ENABLED arms the subsystem)
video_engine_enabled: bool = Field(
default=False,
description="Opt this project into the video engine (authoring into motion/)",
)
# Dependency-update bot opt-in
dep_update_command: str | None = Field(
default=None,
@@ -207,6 +213,7 @@ class ProjectUpdate(RobocoBase):
is_active: bool | None = None
ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None
video_engine_enabled: bool | None = None
dep_update_command: str | None = None
dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None
+29 -7
View File
@@ -115,6 +115,31 @@ class VideoEngine(BaseService):
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _opted_in_project(self, occasion: str) -> ProjectTable | None:
"""The RoboCo project if resolvable AND opted into video, else None.
Two skip reasons, both logged: unresolvable project (warning a
config gap) vs. project not opted in (info the operator hasn't
flipped the per-project ``video_engine_enabled`` toggle). The global
flag arms the subsystem; the project's flag opts this repo into
authoring against its ``motion/`` dir (mirrors ``ci_watch_enabled``).
"""
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning(
"video-engine: RoboCo project not resolvable; skipping video task",
occasion=occasion,
)
return None
if not getattr(project, "video_engine_enabled", False):
self.log.info(
"video-engine: project not opted into video; skipping video task",
occasion=occasion,
project_slug=str(getattr(project, "slug", "")),
)
return None
return project
@staticmethod
def _select_ux_dev(open_tasks: list[TaskTable]) -> UUID:
"""Deterministically balance authoring assignment across the two ux-devs.
@@ -141,7 +166,8 @@ class VideoEngine(BaseService):
) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or None.
No-ops when the flag is off, a task for this occasion is already open
No-ops when the global flag is off, the RoboCo project hasn't opted in
(``video_engine_enabled``), a task for this occasion is already open
(authoring or held draft), the open cap is reached, or the RoboCo
project isn't resolvable. The opened task is a normal, ASSIGNED
delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``)
@@ -162,12 +188,8 @@ class VideoEngine(BaseService):
occasion=occasion,
)
return None
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning(
"video-engine: RoboCo project not resolvable; skipping video task",
occasion=occasion,
)
project = await self._opted_in_project(occasion)
if project is None:
return None
from sqlalchemy.exc import SQLAlchemyError
@@ -61,6 +61,7 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
ci_watch_workflow="ci.yml",
dep_update_command="uv lock --upgrade",
dep_update_paths=["uv.lock"],
video_engine_enabled=True,
),
)
@@ -70,3 +71,4 @@ async def test_update_sets_autonomy_opt_ins(db_session: AsyncSession) -> None:
assert reloaded.ci_watch_workflow == "ci.yml"
assert reloaded.dep_update_command == "uv lock --upgrade"
assert reloaded.dep_update_paths == ["uv.lock"]
assert reloaded.video_engine_enabled is True
+1
View File
@@ -83,6 +83,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
@@ -124,6 +124,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
+23
View File
@@ -72,6 +72,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
@@ -214,6 +215,27 @@ async def test_open_video_task_unresolvable_project_opens_nothing(
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_no_op_when_project_not_opted_in(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
# Flip the per-project opt-in back off — the global flag stays on.
await db_session.execute(
ProjectTable.__table__.update()
.where(ProjectTable.__table__.c.slug == SLUG)
.values(video_engine_enabled=False)
)
await db_session.flush()
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_insert_error_returns_none_session_usable(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
@@ -255,6 +277,7 @@ async def test_open_video_task_insert_error_returns_none_session_usable(
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await db_session.flush()