[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 ### 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). - **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 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 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. - **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 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. **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( const [ciWatchWorkflow, setCiWatchWorkflow] = useState(
project.ci_watch_workflow || "", project.ci_watch_workflow || "",
); );
const [videoEngineEnabled, setVideoEngineEnabled] = useState(
project.video_engine_enabled,
);
const [depUpdateCommand, setDepUpdateCommand] = useState( const [depUpdateCommand, setDepUpdateCommand] = useState(
project.dep_update_command || "", project.dep_update_command || "",
); );
@@ -120,6 +123,7 @@ function EditProjectForm({
quality_command: qualityCommand || undefined, quality_command: qualityCommand || undefined,
ci_watch_enabled: ciWatchEnabled, ci_watch_enabled: ciWatchEnabled,
ci_watch_workflow: ciWatchWorkflow || undefined, ci_watch_workflow: ciWatchWorkflow || undefined,
video_engine_enabled: videoEngineEnabled,
dep_update_command: depUpdateCommand || undefined, dep_update_command: depUpdateCommand || undefined,
dep_update_paths: depUpdatePaths.trim() dep_update_paths: depUpdatePaths.trim()
? depUpdatePaths ? depUpdatePaths
@@ -410,6 +414,17 @@ function EditProjectForm({
</p> </p>
</div> </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"> <div className="grid gap-2">
<Label htmlFor="dep_update_command"> <Label htmlFor="dep_update_command">
Dependency-Update Command Dependency-Update Command
+1
View File
@@ -90,6 +90,7 @@ export const projectsApi = {
quality_command: project.quality_command ?? null, quality_command: project.quality_command ?? null,
ci_watch_enabled: false, ci_watch_enabled: false,
ci_watch_workflow: null, ci_watch_workflow: null,
video_engine_enabled: false,
dep_update_command: null, dep_update_command: null,
dep_update_paths: null, dep_update_paths: null,
sandbox_services: null, sandbox_services: null,
+2
View File
@@ -1016,6 +1016,7 @@ export interface Project {
// Autonomous maintenance opt-in // Autonomous maintenance opt-in
ci_watch_enabled: boolean; ci_watch_enabled: boolean;
ci_watch_workflow: string | null; ci_watch_workflow: string | null;
video_engine_enabled: boolean;
dep_update_command: string | null; dep_update_command: string | null;
dep_update_paths: string[] | null; dep_update_paths: string[] | null;
sandbox_services: string[] | null; sandbox_services: string[] | null;
@@ -1064,6 +1065,7 @@ export interface ProjectUpdate {
// Autonomous maintenance opt-in // Autonomous maintenance opt-in
ci_watch_enabled?: boolean; ci_watch_enabled?: boolean;
ci_watch_workflow?: string; ci_watch_workflow?: string;
video_engine_enabled?: boolean;
dep_update_command?: string; dep_update_command?: string;
dep_update_paths?: string[]; dep_update_paths?: string[];
sandbox_services?: string[]; sandbox_services?: string[];
+3
View File
@@ -47,6 +47,7 @@ class ProjectResponse(BaseModel):
# Autonomous maintenance opt-in # Autonomous maintenance opt-in
ci_watch_enabled: bool = False ci_watch_enabled: bool = False
ci_watch_workflow: str | None = None ci_watch_workflow: str | None = None
video_engine_enabled: bool = False
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: list[str] | None = None sandbox_services: list[str] | None = None
@@ -140,6 +141,7 @@ class ProjectUpdateRequest(BaseModel):
# Autonomous maintenance opt-in # Autonomous maintenance opt-in
ci_watch_enabled: bool | None = None ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None ci_watch_workflow: str | None = None
video_engine_enabled: bool | None = None
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: 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, quality_command=project.quality_command,
ci_watch_enabled=bool(project.ci_watch_enabled), ci_watch_enabled=bool(project.ci_watch_enabled),
ci_watch_workflow=project.ci_watch_workflow, ci_watch_workflow=project.ci_watch_workflow,
video_engine_enabled=bool(project.video_engine_enabled),
dep_update_command=project.dep_update_command, dep_update_command=project.dep_update_command,
dep_update_paths=project.dep_update_paths, dep_update_paths=project.dep_update_paths,
sandbox_services=project.sandbox_services, 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) 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 # Dependency-update bot opt-in. A project participates only when
# dep_update_command is set (e.g. "uv lock --upgrade"); dep_update_paths are # 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). # 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" 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 # Dependency-update bot opt-in
dep_update_command: str | None = Field( dep_update_command: str | None = Field(
default=None, default=None,
@@ -207,6 +213,7 @@ class ProjectUpdate(RobocoBase):
is_active: bool | None = None is_active: bool | None = None
ci_watch_enabled: bool | None = None ci_watch_enabled: bool | None = None
ci_watch_workflow: str | None = None ci_watch_workflow: str | None = None
video_engine_enabled: bool | None = None
dep_update_command: str | None = None dep_update_command: str | None = None
dep_update_paths: list[str] | None = None dep_update_paths: list[str] | None = None
sandbox_services: 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() slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug) 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 @staticmethod
def _select_ux_dev(open_tasks: list[TaskTable]) -> UUID: def _select_ux_dev(open_tasks: list[TaskTable]) -> UUID:
"""Deterministically balance authoring assignment across the two ux-devs. """Deterministically balance authoring assignment across the two ux-devs.
@@ -141,7 +166,8 @@ class VideoEngine(BaseService):
) -> TaskTable | None: ) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or 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 (authoring or held draft), the open cap is reached, or the RoboCo
project isn't resolvable. The opened task is a normal, ASSIGNED project isn't resolvable. The opened task is a normal, ASSIGNED
delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``) delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``)
@@ -162,12 +188,8 @@ class VideoEngine(BaseService):
occasion=occasion, occasion=occasion,
) )
return None return None
project = await self._roboco_project() project = await self._opted_in_project(occasion)
if project is None or project.id is None: if project is None:
self.log.warning(
"video-engine: RoboCo project not resolvable; skipping video task",
occasion=occasion,
)
return None return None
from sqlalchemy.exc import SQLAlchemyError 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", ci_watch_workflow="ci.yml",
dep_update_command="uv lock --upgrade", dep_update_command="uv lock --upgrade",
dep_update_paths=["uv.lock"], 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.ci_watch_workflow == "ci.yml"
assert reloaded.dep_update_command == "uv lock --upgrade" assert reloaded.dep_update_command == "uv lock --upgrade"
assert reloaded.dep_update_paths == ["uv.lock"] 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, assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID, created_by=SYSTEM_UUID,
is_active=True, is_active=True,
video_engine_enabled=True,
) )
) )
await session.flush() await session.flush()
@@ -124,6 +124,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND, assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID, created_by=SYSTEM_UUID,
is_active=True, is_active=True,
video_engine_enabled=True,
) )
) )
await session.flush() await session.flush()
+23
View File
@@ -72,6 +72,7 @@ async def _seed(session: AsyncSession) -> None:
assigned_cell=Team.BACKEND, assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID, created_by=SYSTEM_UUID,
is_active=True, is_active=True,
video_engine_enabled=True,
) )
) )
await session.flush() 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() == [] 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 @pytest.mark.asyncio
async def test_open_video_task_insert_error_returns_none_session_usable( async def test_open_video_task_insert_error_returns_none_session_usable(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch 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, assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID, created_by=SYSTEM_UUID,
is_active=True, is_active=True,
video_engine_enabled=True,
) )
) )
await db_session.flush() await db_session.flush()