mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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 3–4).
|
- **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 3–4).
|
||||||
- **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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user