mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [943d8c4d] Frontend data freshness and approval-queue reliability audit (#631)
* [233a8b0f] WebSocket reconnect message-loss audit and fix (#625)
* [233a8b0f] fix(panel): add REST catch-up to useNotificationStream on WS reconnect
connection.ts has no message buffering/replay, so a notification published
while the CEO bell's socket was down (disconnected/reconnecting) was lost
forever instead of merely delayed. Add a reconnect-triggered GET
/notifications?unread_only=true catch-up folded into the existing
notification_id dedup so a notification delivered both via catch-up and
live WS is never double-counted, and make clearMessages drop the held
catch-up batch too. use-a2a-live.ts and use-rate-limit-websocket.ts were
audited and already have working reconnect-triggered REST fallbacks
(verified via a2a/page.tsx, rate-limit-banner.tsx, usage-overview-panel.tsx
and their existing F083 tests) so no fix was needed there.
* [233a8b0f] docs(panel): add comprehensive WebSocket hooks reference and reconnect architecture guide
Add panel/docs/frontend/hooks.md with full API reference for useWebSocket, useNotificationStream (with new REST catch-up behavior), useAgentStream, useA2ALiveStream, and useConnectionStatus. Include examples, best practices, and testing guidance.
Add panel/docs/architecture/websocket-reconnect.md documenting the message-loss mitigation pattern: Strategy 1 (REST catch-up for events, used by useNotificationStream) and Strategy 2 (REST invalidation for state, used by A2A/rate-limit consumers), plus the dedup logic ensuring no notification is double-counted on reconnect.
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
* [d5315683] fix(frontend): add distinct toast feedback for silently-swallowed x-post and release-proposal statuses, plus regression tests for all 4 approval queues (#626)
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
* [cd953838] Data-hook null-guard audit and API client 429 retry-by-method fix (#630)
* [cd953838] fix(panel): gate 429 retry by HTTP method, add hook null-guard regression tests
* [cd953838] chore(conventions): waive test-fixture wrapper in hooks null-guard test
* [cd953838] docs(frontend): document API rate-limit retry behavior and null-guard audit results
Added `docs/frontend/api-rate-limiting.md` to document the 429 retry strategy: GET/PUT auto-retry, POST/PATCH/DELETE require X-Idempotency-Key header. Updated `docs/frontend/hooks.md` to confirm the data-hook null-guard audit found all hooks already have correct `enabled` guards and include a regression test suite for the board-review poll on/off behavior and enabled-guard assertions.
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
* [4534c71a] Backend concurrency, state-machine, and engine audit (#634)
* [41de844a] fix(lifecycle): sync CLAIM_RULES with runtime + clear stale claimant on PM hand-off (#627)
Two confirmed state-machine gaps found while auditing lifecycle.py,
task_lifecycle.py, the _ESCALATABLE_TO_BLOCKED bypass, and every
_REVIEW_QUEUE_STATES entry point:
- lifecycle.py's CLAIM_RULES/claim-ActionSpec/StatusTransition table
did not grant CELL_PM/MAIN_PM re-claim of AWAITING_PM_REVIEW even
though task.py's runtime _ROLE_CLAIM_STATUSES already granted it
and claimed the spec agreed -- the two tables had silently drifted,
breaking i_will_plan re-claim on an awaiting_pm_review task.
- docs_complete's _maybe_advance_to_pm_review pre-assigns a specific
owning PM via assigned_to but left claimed_by/active_claimant_id
pointing at the outgoing documenter, unlike every sibling transition
into a review-queue state. A stale active_claimant_id makes
content_actions.py's _active_claim_violation wrongly reject the
newly-assigned PM's own content writes before it formally claims.
Reassign claimed_by + active_claimant_id to the owning PM alongside
assigned_to.
Adds a regression test asserting the documenter's stale claim does not
survive the docs_complete -> awaiting_pm_review hand-off.
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
* [0c46f666] Engine dedup race + sequencing.py edge-case audit (#628)
* [0c46f666] fix(sequencing): dedup race audit + collision-edge fallback bug
Audited the list-open-then-originate dedup pattern across six engines:
RoadmapEngine, XEngine.run_cycle, DepUpdateEngine, and CIWatchEngine each
run inside exactly one sequential orchestrator-loop asyncio task (no other
call site invokes run_cycle), so they cannot race with themselves; their
in-cycle dedup sets/keys are correctly built before any commit. SelfHealEngine
is the same shape. VideoEngine.open_video_task is genuinely different: it is
reachable from the release-publish hook, the feature-spotlight hook, and the
on-demand POST /video/request route, so two overlapping calls for the same
occasion can both pass the "no open task yet" check before either commits.
Fixed by wrapping the check+insert in a short-lived Redis mutex (reusing
HeartbeatMutex) keyed by occasion, mirroring XPostService's existing
lock pattern, with a regression test proving only one of two concurrent
calls creates a task.
Verified ReleaseExecutor's half-landed retry path (release_commit_sha):
apply_version_bumps and write_changelog_entry both run as uncommitted
working-tree edits before commit_and_push's single `git add -A` + commit,
so a bumped-version-without-changelog state can never reach origin (and
therefore can never be observed by a fresh retry clone) - confirmed correct
with a real-git-repo regression test, no fix needed.
Fixed sequencing.py's dev_task_collision_edges: the `if edges: return edges`
short-circuit dropped the same-assignee-lane fallback entirely whenever ANY
surfaced sibling pair produced a collision edge, even for a completely
unrelated same-assignee pair with no declared surface. Now the fallback
always runs, skipping only pairs the analyzer already ordered (so the two
mechanisms can never disagree on direction for the same pair).
Verified sequencing.py rule 3 (all-shared batch generates no edges): correct
by inspection (_shared_last_edges skips every pair when both are shared) and
confirmed with a regression test - no fix needed.
* [0c46f666] docs(reference): concurrency audit summary - engine races, fixes, verified patterns
---------
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
* [8f7f167a] Redis mutex pre-lock write audit (#629)
* [8f7f167a] Redis mutex pre-lock write audit: add cross-session regression test for XPostService.approve
Audited x_post_service.py, video_post_service.py, release_proposal.py, and
heartbeat_mutex.py for the pre-lock DB-write anti-pattern (a session write
that happens before the SET NX / HeartbeatMutex acquire returns a token,
letting a losing racer's stale write clobber a winner's committed state).
XPostService.approve, VideoPostService.approve, and
ReleaseProposalService.approve/reject already implement the correct
validate-pure-pre-lock, apply-under-lock pattern (the XPostService fix
already shipped per CHANGELOG.md: "X edited_body write deferred into the
single-flight lock (M5)"). HeartbeatMutex holds no AsyncSession at all, so
the anti-pattern is structurally inapplicable there.
Adds a genuine cross-session concurrency regression test to
test_x_post_service.py (a real second DB connection, not an in-process
mock) mirroring VideoPostService's existing cross-session test, proving a
concurrently-committed post survives and the CEO's edited body never lands
on the just-posted row.
* [8f7f167a] Remove redundant inline comments flagged by QA in cross-session regression test
Both comments restated what the surrounding docstrings already say
explicitly, per QA findings F-dbadd8f0 (line 294) and F-27ac051e (line
631) — no behavior change, tests re-verified green against a sandbox
Postgres.
* [8f7f167a] Remove inline trailing comments flagged by QA (correct file this time)
QA findings F-e6f3e6a6 and F-24189858 cited tests/unit/services/
test_x_post_service.py:294 and :631 across 5 revision rounds, but that
file never contained the flagged comment text — a repo-wide grep for
the exact quoted strings shows both comments actually live in the
mirrored tests/unit/services/test_video_post_service.py file, in its
own cross-session concurrency regression tests (the caption-edit and
tiktok-skip tests). Removed both there:
- "# externally visible to the "concurrent" session below" on the
db_session.commit() call
- "# never attempted without credentials" on the tiktok_poster.calls
assertion
Both restated what the surrounding docstrings/test names already say;
no behavior change. Verified with the full make quality gate against a
sandbox Postgres/Redis: 13,717 passed, 94.41% coverage, clean except
one pre-existing unrelated failure in tests/unit/api/test_cloud_auth.py
::test_login_route_parses_oauth2_form_not_query_params, which connects
to the app's default localhost:5432 Postgres (not the db_session
sandbox fixture) and is unreachable in this sandboxed environment —
structurally unrelated to the auth subsystem this task never touches.
* [8f7f167a] Redis mutex pre-lock write audit (round 7): add cross-session regression tests for reject() lock protection
Round-7 QA findings F-7eb9fbcb, F-06f39a2e, and F-4d56e49b claim
XPostService.reject(), ReleaseProposalService.reject(), and
release_executor._await_proc() lack lock protection / a CancelledError
handler — but their cited line ranges (255-267, 429-454, 241-257)
describe a pre-fix, shorter version of these functions that predates
commit fb293a787d, already on this branch. At current HEAD:
- x_post_service.py reject() (lines 275-299) acquires _LOCK_PREFIX,
re-reads under the lock, applies markers.set_x_reject_reason() +
CANCELLED only inside the critical section, releases in finally.
- release_proposal.py reject() (lines 460-486) does the identical
dance with _RELEASE_LOCK_PREFIX.
- release_executor.py _await_proc() (lines 257-265) already has an
`except asyncio.CancelledError` block that kills + reaps the child
and re-raises, mirroring the TimeoutError handler, with an existing
dedicated regression test
(test_await_proc_kills_child_on_outer_cancellation).
The one genuine gap: neither reject() path had a cross-session
(real second DB connection, not an in-process mock) regression test
proving the in-lock re-read catches a concurrent approve/publish that
completes mid-lock-wait — only approve() had one. Added
test_reject_concurrent_approve_completes_during_lock_wait to both
test_x_post_service.py and test_release_proposal_status_guards.py,
mirroring the existing approve() cross-session test: a second engine
commits COMPLETED between reject's pre-lock read and lock acquisition,
and the test asserts the CANCELLED write / reject-reason marker never
lands on the just-completed row.
No production code changed — verified via 103 targeted tests green
against a sandbox Postgres/Redis, plus `make -o sync gate` clean.
* [8f7f167a] Regenerate stale lifecycle artifacts (restore auditor waive_finding)
foundation-check was the only failing gate: the committed lifecycle artifacts
were missing the auditor's waive_finding verb that the lifecycle source
defines, so make quality regenerated them and failed on the diff — nothing to
do with the mutex fix (which passes ruff/mypy/tests/coverage/bandit clean).
make lifecycle restores the drift; this is what the 8 revision rounds kept
missing.
---------
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
* [d615f2e3] fix(tests): sync stale CLAIM_RULES pinning assertions with lifecycle.py (#635)
test_claim_rules_match_pre_gateway_table still asserted the pre-audit
two-member frozenset for CELL_PM/MAIN_PM claim rules. CLAIM_RULES in
lifecycle.py already grants both roles claim rights on
Status.AWAITING_PM_REVIEW (added by the state-machine exhaustiveness
audit) so a PM can re-claim its own review-queue task after a respawn.
Updated both assertions to include AWAITING_PM_REVIEW, matching the
actual dict. Grepped the repo for sibling stale copies of the old
literal; found none beyond this test.
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
* [3c4e7a35] fix(quality-gate): reflow CONCURRENCY_AUDIT.md and stub occasion lock in video tests (#636)
Root cause: PR #634's CI failed at the markdown-reflow check (make quality
Makefile:285) on CONCURRENCY_AUDIT.md — a hard-wrapped audit doc left over
from the merged "Engine dedup race + sequencing.py edge-case audit" unit
(PR #628). Fixed with `make reflow-docs` (the exact remedy the CI output
itself named).
Running the full local `make quality` (with a sandbox Postgres/Redis to
get past DB-gated skips) surfaced a second real regression from that same
PR #628 unit: it added a Redis-backed HeartbeatMutex occasion lock to
VideoEngine.open_video_task, but two pre-existing test files
(tests/unit/runtime/test_video_render_loop.py and
tests/integration/test_video_routes.py) call open_video_task without
stubbing that lock, so they failed closed against the suite's
deliberately-unreachable test Redis (_no_live_redis). Fixed by applying
the same lock-stub pattern tests/unit/services/test_video_engine.py
already uses for its own occasion-lock tests: an autouse HeartbeatMutex
stand-in fixture in test_video_render_loop.py, and wrapping the two
route-level video-request tests in test_video_routes.py with the file's
existing _LOCKED patch pair (already used by every other lock-dependent
test in that file).
The one remaining local failure,
test_cloud_auth.py::test_login_route_parses_oauth2_form_not_query_params,
is a pre-existing environment gap unrelated to this branch: it needs a
real Postgres reachable at localhost:5432 (which .github/workflows/ci.yml
provides as a service container) but this dev sandbox has no such binding
— confirmed unrelated to any of the four merged audit units.
make quality now passes clean: 13729 passed, 0 regressions, 94.49% coverage.
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
* [ddc8121f] regenerate lifecycle artifacts for awaiting_pm_review claim rules and waive_finding intent (#637)
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
---------
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
* fix(sequencing): drop lane-fallback edges that would cycle against analyzer edges
The dev-task collision fallback unioned the analyzer's authoritative edges
with same-assignee lane edges, deduping only the direct pair. A lane chain
through an unsurfaced middle sibling could still contradict an analyzer edge
transitively (the shared-last migration order inverts plain priority order),
closing a 3-cycle that made add_dependency raise ConflictError and wedged
every later delegate to that parent. Fallback edges are now accepted only
when they can't close a cycle against the edges already kept; a regression
test reproduces the exact scenario.
Also strip pre-merge cruft: remove the root CONCURRENCY_AUDIT.md working
report, delete the near-duplicate websocket-reconnect.md doc, fix the stale
a2a/page.tsx doc citation, correct the api-rate-limiting doc to state
idempotency-key retry is unimplemented, and fix two lifecycle.py comments
that referenced a guard function which never existed.
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
1283 lines
47 KiB
Python
1283 lines
47 KiB
Python
"""VideoEngine coverage: authoring-task origination + held-draft materialization.
|
|
|
|
Mirrors the X-engine tests: flag-gated, dedup, rolling open-cap, and a
|
|
deterministic ux-dev balance. The authoring task (source=video) is a normal
|
|
ASSIGNED delivery task — never held; the post draft (source=video_post) is
|
|
Secretary-owned and held for the CEO. Asserted against a real Postgres DB.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import TYPE_CHECKING, cast
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from roboco.config import settings as cfg
|
|
from roboco.db.tables import AgentTable, ProjectTable
|
|
from roboco.foundation import identity as _foundation
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
|
|
from roboco.models.base import TaskStatus as TS
|
|
from roboco.services import video_engine as video_engine_module
|
|
from roboco.services.company_goals import get_company_goals_service
|
|
from roboco.services.heartbeat_mutex import HeartbeatLockUnavailable
|
|
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
|
|
from sqlalchemy import delete, select
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
|
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
|
|
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
|
|
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
|
|
SLUG = "roboco"
|
|
ONE = 1
|
|
TWO = 2
|
|
THREE = 3
|
|
FOUR = 4
|
|
FIVE = 5
|
|
SEVEN = 7
|
|
_AC_ITEM_CHAR_CAP = 200 # mirrors task_completeness._AC_MAX_ITEM_CHARS
|
|
|
|
|
|
async def _seed(session: AsyncSession) -> None:
|
|
for uuid, slug, role, team in (
|
|
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
|
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
|
|
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
|
|
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
|
|
):
|
|
if await session.get(AgentTable, uuid) is None:
|
|
session.add(
|
|
AgentTable(
|
|
id=uuid,
|
|
name=slug,
|
|
slug=slug,
|
|
role=role,
|
|
team=team,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await session.flush()
|
|
existing = await session.execute(
|
|
select(ProjectTable).where(ProjectTable.slug == SLUG)
|
|
)
|
|
if existing.scalar_one_or_none() is None:
|
|
session.add(
|
|
ProjectTable(
|
|
name="RoboCo",
|
|
slug=SLUG,
|
|
git_url="https://github.com/x/roboco.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
video_engine_enabled=True,
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
|
|
monkeypatch.setattr(cfg, "video_engine_enabled", True)
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
|
|
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
|
|
for key, value in overrides.items():
|
|
monkeypatch.setattr(cfg, key, value)
|
|
|
|
|
|
def _mock_local_model(monkeypatch: pytest.MonkeyPatch, reply: str | None) -> AsyncMock:
|
|
mock = AsyncMock(return_value=reply)
|
|
monkeypatch.setattr(video_engine_module, "_chat", mock)
|
|
return mock
|
|
|
|
|
|
class _AlwaysAcquiredMutex:
|
|
"""Stand-in for ``HeartbeatMutex``: always acquires immediately, no live
|
|
Redis required (matches the project's ``_no_live_redis`` fixture). Used
|
|
as the default so every scenario below that isn't specifically testing
|
|
the occasion-lock behavior is unaffected by its introduction."""
|
|
|
|
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
|
pass
|
|
|
|
async def acquire(self) -> str | None:
|
|
return "tok"
|
|
|
|
async def release(self, _token: str) -> None:
|
|
return None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_occasion_lock(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(video_engine_module, "HeartbeatMutex", _AlwaysAcquiredMutex)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# open_video_task
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disabled_opens_no_video_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
monkeypatch.setattr(cfg, "video_engine_enabled", False)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="release v1.0.0", script="script", platforms=["x"], brief="brief"
|
|
)
|
|
assert task is None
|
|
assert await get_task_service(db_session).list_open_video_posts() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_creates_assigned_authoring_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="release v1.0.0",
|
|
script="Here's what shipped",
|
|
platforms=["x", "tiktok"],
|
|
brief="Announce the release",
|
|
)
|
|
assert task is not None
|
|
assert task.team == Team.UX_UI
|
|
assert task.assigned_to == UX_DEV_1_UUID # deterministic first pick
|
|
assert task.source == VIDEO_SOURCE
|
|
assert task.status == TS.PENDING
|
|
assert task.confirmed_by_human is True # normal delivery task, not CEO-held
|
|
# LOW so it clears _check_dev_needs_subtasks; a medium/high root dev task
|
|
# would auto-block for subtasks it never owns and deadlock.
|
|
assert task.estimated_complexity == Complexity.LOW
|
|
assert task.acceptance_criteria # non-empty
|
|
# Third AC line: composition follows the design bar / demo-kit register.
|
|
assert len(task.acceptance_criteria) == FOUR
|
|
assert "motion/README.md" in task.acceptance_criteria[2]
|
|
assert "panel-demo" in task.acceptance_criteria[2]
|
|
# Fourth AC line: request_render preview frames must be verified.
|
|
assert "request_render" in task.acceptance_criteria[3]
|
|
project = await db_session.get(ProjectTable, task.project_id)
|
|
assert project is not None
|
|
assert project.slug == SLUG
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["occasion"] == "release v1.0.0"
|
|
assert draft["script"] == "Here's what shipped"
|
|
assert draft["platforms"] == ["x", "tiktok"]
|
|
# brief is enriched: raw content + motion design-bar pointer appended.
|
|
assert draft["brief"].startswith("Announce the release")
|
|
assert "motion/README.md" in draft["brief"]
|
|
assert "motion/kit/README.md" in draft["brief"]
|
|
assert "compositions/panel-demo/" in draft["brief"]
|
|
assert "request_render" in draft["brief"] # verify-the-render pointer
|
|
assert "Brand voice" not in draft["brief"] # unset -> omitted
|
|
assert draft["suggested_input_props"] == {} # none supplied
|
|
assert task.description == draft["brief"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_balances_across_ux_devs(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
first = await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
second = await engine.open_video_task(
|
|
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert first is not None
|
|
assert second is not None
|
|
assert first.assigned_to == UX_DEV_1_UUID
|
|
assert second.assigned_to == UX_DEV_2_UUID # balanced onto the less-loaded dev
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_dedupes_same_occasion(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
second = await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s2", platforms=["x"], brief="b2"
|
|
)
|
|
assert second is None
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert len(open_tasks) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_returns_none_when_occasion_lock_held(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A held per-occasion lock (another call already owns it) is a no-op,
|
|
not an error — it opens nothing and leaves the shared session usable."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
|
|
class _HeldMutex:
|
|
def __init__(self, *_a: object, **_kw: object) -> None:
|
|
pass
|
|
|
|
async def acquire(self) -> str | None:
|
|
return None # another call already holds this occasion's lock
|
|
|
|
async def release(self, _token: str) -> None:
|
|
raise AssertionError("release must not be called when acquire failed")
|
|
|
|
monkeypatch.setattr(video_engine_module, "HeartbeatMutex", _HeldMutex)
|
|
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_returns_none_when_lock_unavailable(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A Redis outage on the occasion lock fails closed (no-op), not a crash."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
|
|
class _BrokenMutex:
|
|
def __init__(self, *_a: object, **_kw: object) -> None:
|
|
pass
|
|
|
|
async def acquire(self) -> str | None:
|
|
raise HeartbeatLockUnavailable("redis down")
|
|
|
|
async def release(self, _token: str) -> None:
|
|
raise AssertionError("release must not be called when acquire failed")
|
|
|
|
monkeypatch.setattr(video_engine_module, "HeartbeatMutex", _BrokenMutex)
|
|
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_concurrent_same_occasion_creates_only_one(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression (engine dedup race): ``open_video_task`` is reachable from
|
|
several genuinely concurrent callers for the same occasion (a double-click
|
|
on ``/video/request``, or an on-demand call racing the release hook) —
|
|
unlike the other engines' single-loop ``run_cycle``, two overlapping calls
|
|
are NOT serialized by the orchestrator's own scheduling. A real
|
|
``asyncio.Lock`` stands in for the Redis SET NX mutex's mutual exclusion
|
|
(no live Redis in tests): the first caller to reach the DB's genuinely
|
|
suspending await wins the lock and creates the task; the second finds it
|
|
held and returns None immediately — never both passing the dedup check."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
|
|
real_lock = asyncio.Lock()
|
|
|
|
class _RaceMutex:
|
|
def __init__(self, *_a: object, **_kw: object) -> None:
|
|
pass
|
|
|
|
async def acquire(self) -> str | None:
|
|
if real_lock.locked():
|
|
return None
|
|
await real_lock.acquire()
|
|
return "tok"
|
|
|
|
async def release(self, _token: str) -> None:
|
|
real_lock.release()
|
|
|
|
monkeypatch.setattr(video_engine_module, "HeartbeatMutex", _RaceMutex)
|
|
|
|
results = await asyncio.gather(
|
|
engine.open_video_task(
|
|
occasion="race me", script="s1", platforms=["x"], brief="b1"
|
|
),
|
|
engine.open_video_task(
|
|
occasion="race me", script="s2", platforms=["x"], brief="b2"
|
|
),
|
|
)
|
|
created = [r for r in results if r is not None]
|
|
assert len(created) == ONE
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert len(open_tasks) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_respects_open_cap(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_max_open_posts=1)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
second = await engine.open_video_task(
|
|
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert second is None
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert len(open_tasks) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_unresolvable_project_opens_nothing(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
|
|
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_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.
|
|
project = (
|
|
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
|
).scalar_one()
|
|
project.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
|
|
) -> None:
|
|
"""F042 guard: a real DBAPI error on the authoring insert (assignee FK
|
|
absent) rolls back ONLY the savepoint and returns None; the shared session
|
|
stays usable, so a caller's later commit is not poisoned."""
|
|
if await db_session.get(AgentTable, SYSTEM_UUID) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=SYSTEM_UUID,
|
|
name="system",
|
|
slug="system",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
# ux-devs deliberately absent -> the assigned_to FK violates at flush.
|
|
await db_session.execute(
|
|
delete(AgentTable).where(AgentTable.id.in_([UX_DEV_1_UUID, UX_DEV_2_UUID]))
|
|
)
|
|
has_project = (
|
|
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
|
).scalar_one_or_none()
|
|
if has_project is None:
|
|
db_session.add(
|
|
ProjectTable(
|
|
name="RoboCo",
|
|
slug=SLUG,
|
|
git_url="https://github.com/x/roboco.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
video_engine_enabled=True,
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="fk-fail", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is None
|
|
# Not poisoned: a follow-up statement runs cleanly (this raised
|
|
# PendingRollbackError before the savepoint fix).
|
|
check = await db_session.execute(
|
|
select(ProjectTable).where(ProjectTable.slug == SLUG)
|
|
)
|
|
assert check.scalar_one_or_none() is not None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# open_video_task(project_id=...) — the on-demand caller's own project scope
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_with_explicit_project_id_ignores_self_heal_slug(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A caller-supplied project_id resolves independent of
|
|
self_heal_project_slug — the authoring task lands on THAT project, not
|
|
the fixed RoboCo one."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
other = ProjectTable(
|
|
name="Other Repo",
|
|
slug="other-repo",
|
|
git_url="https://github.com/x/other.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
video_engine_enabled=True,
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-slug")
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="on-demand other repo",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
project_id=cast("UUID", other.id),
|
|
)
|
|
assert task is not None
|
|
assert task.project_id == other.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_explicit_project_id_not_opted_in_opens_nothing(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
other = ProjectTable(
|
|
name="Not Opted In",
|
|
slug="not-opted-in",
|
|
git_url="https://github.com/x/notopted.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
video_engine_enabled=False,
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="on-demand not opted",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
project_id=cast("UUID", other.id),
|
|
)
|
|
assert task is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_explicit_project_id_unresolvable_opens_nothing(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="on-demand missing project",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
project_id=uuid4(),
|
|
)
|
|
assert task is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _originate_video_post
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_originate_video_post_holds_draft_for_secretary(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v1.0.0",
|
|
script="Here's what shipped",
|
|
platforms=["x", "tiktok"],
|
|
brief="Announce the release",
|
|
)
|
|
assert source_task is not None
|
|
|
|
draft_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={
|
|
"vertical": "/render/out/a-vertical.mp4",
|
|
"square": "/render/out/a-square.mp4",
|
|
},
|
|
captions={
|
|
"x": "We just shipped v1.0.0!",
|
|
"tiktok": "New release, check it out",
|
|
},
|
|
platforms=["x", "tiktok"],
|
|
)
|
|
|
|
assert draft_task.team == Team.MAIN_PM
|
|
assert draft_task.assigned_to == SECRETARY_UUID
|
|
assert draft_task.source == VIDEO_POST_SOURCE
|
|
assert draft_task.status == TS.PENDING
|
|
assert draft_task.confirmed_by_human is False # HELD; never dispatched
|
|
|
|
draft = markers.get_video_draft(draft_task)
|
|
assert draft is not None
|
|
assert draft["occasion"] == "release v1.0.0" # carried forward from the source
|
|
assert draft["script"] == "Here's what shipped"
|
|
assert draft["mp4_paths"] == {
|
|
"vertical": "/render/out/a-vertical.mp4",
|
|
"square": "/render/out/a-square.mp4",
|
|
}
|
|
assert draft["x_caption"] == "We just shipped v1.0.0!"
|
|
assert draft["tiktok_caption"] == "New release, check it out"
|
|
assert draft["platforms"] == ["x", "tiktok"]
|
|
assert draft["render_status"] == "rendered"
|
|
assert draft["source_task_id"] == str(source_task.id) # traceability back-ref
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_originate_video_post_sends_telegram_push(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
notify = AsyncMock()
|
|
monkeypatch.setattr(
|
|
"roboco.services.notification_delivery.NotificationDeliveryService."
|
|
"notify_ceo_of_queue_item",
|
|
notify,
|
|
)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v1.0.0",
|
|
script="Here's what shipped",
|
|
platforms=["x", "tiktok"],
|
|
brief="Announce the release",
|
|
)
|
|
assert source_task is not None
|
|
|
|
draft_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "/a.mp4", "square": "/b.mp4"},
|
|
captions={"x": "shipped!", "tiktok": "shipped!"},
|
|
platforms=["x", "tiktok"],
|
|
)
|
|
|
|
notify.assert_awaited_once()
|
|
assert notify.await_args is not None
|
|
_args, kwargs = notify.await_args
|
|
assert kwargs["kind"] == "video"
|
|
assert kwargs["id8"] == str(draft_task.id)[:8]
|
|
assert kwargs["title"] == "release v1.0.0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_originate_video_post_survives_telegram_push_failure(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A Telegram send failure must never block the draft itself."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
monkeypatch.setattr(
|
|
"roboco.services.notification_delivery.NotificationDeliveryService."
|
|
"notify_ceo_of_queue_item",
|
|
AsyncMock(side_effect=RuntimeError("boom")),
|
|
)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert source_task is not None
|
|
|
|
draft_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "/a.mp4", "square": "/b.mp4"},
|
|
captions={"x": "shipped!"},
|
|
platforms=["x"],
|
|
)
|
|
assert draft_task is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_originate_video_post_not_counted_by_dedupe_against_new_occasion(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A materialized post draft still occupies the shared open-cap/dedupe
|
|
pool (list_open_video_posts spans both sources) — a fresh occasion is
|
|
unaffected, but the cap still counts it."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_max_open_posts=2)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert source_task is not None
|
|
source_task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
|
|
captions={"x": "caption"},
|
|
platforms=["x"],
|
|
)
|
|
# One open (the held draft; the source authoring task is now COMPLETED and
|
|
# therefore excluded) plus room for exactly one more before the cap bites.
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert len(open_tasks) == ONE
|
|
|
|
second = await engine.open_video_task(
|
|
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert second is not None
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert len(open_tasks) == TWO
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# draft_release_video
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_CHANGELOG = "## [1.0.0]\n\n### Added\n- a huge new release\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_disabled_opens_nothing(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
monkeypatch.setattr(cfg, "video_engine_enabled", False)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is None
|
|
assert await get_task_service(db_session).list_open_video_posts() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_sub_switch_off_opens_nothing(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=False)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is None
|
|
assert await get_task_service(db_session).list_open_video_posts() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_opens_authoring_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
_mock_local_model(monkeypatch, "RoboCo v1.0.0 just shipped a huge release.")
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is not None
|
|
assert task.source == VIDEO_SOURCE
|
|
assert task.team == Team.UX_UI
|
|
assert task.confirmed_by_human is True
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["occasion"] == "release 1.0.0"
|
|
assert draft["platforms"] == ["x", "tiktok"]
|
|
assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release."
|
|
# brief is now the structured changelog block, not the LLM one-liner —
|
|
# the whole CHANGELOG section (not one bullet) plus a highlights list.
|
|
assert draft["brief"] != draft["script"]
|
|
assert _CHANGELOG.strip() in draft["brief"]
|
|
assert "a huge new release" in draft["brief"]
|
|
assert "motion/README.md" in draft["brief"]
|
|
assert draft["suggested_input_props"] == {
|
|
"version": "1.0.0",
|
|
"highlights": ["a huge new release"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_falls_back_to_template_on_local_model_failure(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
mock = AsyncMock(side_effect=RuntimeError("ollama down"))
|
|
monkeypatch.setattr(video_engine_module, "_chat", mock)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert "1.0.0" in draft["script"]
|
|
assert "a huge new release" in draft["script"]
|
|
# The structured brief is built independent of the local model, so a
|
|
# model outage still produces the full changelog-derived brief.
|
|
assert _CHANGELOG.strip() in draft["brief"]
|
|
assert draft["suggested_input_props"]["highlights"] == ["a huge new release"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
_mock_local_model(monkeypatch, None) # non-success / empty local-model reply
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="2.0.0", changelog="- no bullets")
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
|
|
assert "- no bullets" in draft["brief"]
|
|
assert draft["suggested_input_props"] == {
|
|
"version": "2.0.0",
|
|
"highlights": ["no bullets"],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_brief_contains_brand_voice_when_set(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
await get_company_goals_service(db_session).upsert(
|
|
{"brand_voice": "Dry wit, never an exclamation point."}
|
|
)
|
|
_mock_local_model(monkeypatch, "shipped!")
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert "Dry wit, never an exclamation point." in draft["brief"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Product-name resolution (release-video prompts brand off the target
|
|
# project, not a hardcoded "RoboCo" literal). The fallback-chain unit
|
|
# coverage (project name -> company_name -> "RoboCo") lives on the shared
|
|
# helper, CompanyGoalsService.resolve_product_name, in
|
|
# test_company_goals_service.py — this only asserts the end-to-end wiring
|
|
# through draft_release_video.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_uses_project_name_when_set(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
acme = ProjectTable(
|
|
name="Acme Robotics",
|
|
slug="acme-robotics",
|
|
git_url="https://github.com/x/acme.git",
|
|
default_branch="master",
|
|
protected_branches=["master"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
is_active=True,
|
|
video_engine_enabled=True,
|
|
)
|
|
db_session.add(acme)
|
|
await db_session.flush()
|
|
_mock_local_model(monkeypatch, None) # force the deterministic fallback template
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(
|
|
version="1.0.0", changelog=_CHANGELOG, project_id=cast("UUID", acme.id)
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert "Acme Robotics" in draft["script"]
|
|
assert "RoboCo" not in draft["script"]
|
|
assert "Acme Robotics" in draft["brief"]
|
|
assert "RoboCo" not in draft["brief"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# brief enrichment shared by every occasion — brand voice + motion pointer
|
|
# (spec Task 2: "Feed the video brief")
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_brief_carries_motion_pointer_no_brand_voice_by_default(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Simulates the on-demand (`POST /video/request`) caller, which passes
|
|
its brief through verbatim — enrichment must land centrally here since
|
|
that route can't append it itself."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="on-demand demo",
|
|
script="script",
|
|
platforms=["x"],
|
|
brief="CEO's on-demand brief, verbatim.",
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["brief"].startswith("CEO's on-demand brief, verbatim.")
|
|
assert "motion/README.md" in draft["brief"]
|
|
assert "motion/kit/README.md" in draft["brief"]
|
|
assert "compositions/panel-demo/" in draft["brief"]
|
|
assert "Brand voice" not in draft["brief"] # unset -> omitted
|
|
assert task.description == draft["brief"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_brief_includes_brand_voice_when_set(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
await get_company_goals_service(db_session).upsert(
|
|
{"brand_voice": "Dry wit, never an exclamation point."}
|
|
)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="on-demand demo 2",
|
|
script="script",
|
|
platforms=["x"],
|
|
brief="CEO's on-demand brief.",
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert "Dry wit, never an exclamation point." in draft["brief"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_does_not_compress_spotlight_brief(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Mirrors content_actions._open_spotlight_video's `f"{title}: {body}"`
|
|
shape — the spotlight body is already rich; enrichment only appends,
|
|
never truncates or rewrites the original content."""
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
feature_brief = (
|
|
"Organizational Memory Loop: Did you know RoboCo agents learn from "
|
|
"every completed task? A local model distills one high-signal lesson "
|
|
"per task, retrieved back into future briefings."
|
|
)
|
|
task = await engine.open_video_task(
|
|
occasion="spotlight org-memory",
|
|
script=feature_brief,
|
|
platforms=["x", "tiktok"],
|
|
brief=feature_brief,
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["brief"].startswith(feature_brief) # not truncated/compressed
|
|
assert "motion/README.md" in draft["brief"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_no_suggested_input_props_defaults_empty(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="spotlight x", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task)
|
|
assert draft is not None
|
|
assert draft["suggested_input_props"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_acceptance_criteria_has_design_bar_line(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="release v9.9.9", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
assert len(task.acceptance_criteria) == FOUR
|
|
assert "motion/README.md" in task.acceptance_criteria[2]
|
|
assert "panel-demo" in task.acceptance_criteria[2]
|
|
assert "request_render" in task.acceptance_criteria[3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_dedupes_same_version(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
_mock_local_model(monkeypatch, "shipped!")
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
first = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
second = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert first is not None
|
|
assert second is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# rerender — CEO-triggered clear of the render idempotency keys
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rerender_clears_render_state_keeps_the_rest(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="rerender-me", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task) or {}
|
|
markers.set_video_draft(
|
|
task,
|
|
{
|
|
**draft,
|
|
"composition_id": "Intro",
|
|
"render_status": "failed",
|
|
"render_attempts": THREE,
|
|
"render_error": "sidecar timeout",
|
|
},
|
|
)
|
|
task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
|
|
result = await engine.rerender(cast("UUID", task.id))
|
|
assert result is not None
|
|
cleared = markers.get_video_draft(result)
|
|
assert cleared is not None
|
|
assert "render_status" not in cleared
|
|
assert "render_attempts" not in cleared
|
|
assert "render_error" not in cleared
|
|
assert cleared["composition_id"] == "Intro" # everything else preserved
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rerender_none_for_non_completed_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="not-yet-done", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
draft = markers.get_video_draft(task) or {}
|
|
markers.set_video_draft(task, {**draft, "composition_id": "Intro"})
|
|
await db_session.flush() # still PENDING, not COMPLETED
|
|
|
|
result = await engine.rerender(cast("UUID", task.id))
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rerender_none_without_composition_id(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="no-composition-yet", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
task.status = TS.COMPLETED
|
|
await db_session.flush() # no propose_video call yet -> no composition_id
|
|
|
|
result = await engine.rerender(cast("UUID", task.id))
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rerender_none_for_missing_task(db_session: AsyncSession) -> None:
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
result = await engine.rerender(uuid4())
|
|
assert result is None
|
|
open_tasks = await get_task_service(db_session).list_open_video_posts()
|
|
assert open_tasks == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# reauthor_from_rejection — CEO reject feedback loop
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reauthor_from_rejection_opens_revision_with_reason_and_pointer(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v1.0.0",
|
|
script="Here's what shipped",
|
|
platforms=["x", "tiktok"],
|
|
brief="Announce the release",
|
|
)
|
|
assert source_task is not None
|
|
draft = markers.get_video_draft(source_task) or {}
|
|
markers.set_video_draft(source_task, {**draft, "composition_id": "ReleaseIntro"})
|
|
# Mirrors production: the render loop only fires once the authoring task
|
|
# has gone through the full delivery lifecycle to COMPLETED — an open
|
|
# source task with the same occasion would otherwise dedupe the revision
|
|
# reauthor is about to open right back against itself.
|
|
source_task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
|
|
post_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
|
|
captions={"x": "cap", "tiktok": "cap2"},
|
|
platforms=["x", "tiktok"],
|
|
)
|
|
# Mirrors VideoPostService.reject: the draft is already CANCELLED by the
|
|
# time reauthor_from_rejection runs, so open_video_task's own-occasion
|
|
# dedup (which only scans OPEN drafts) doesn't block against it.
|
|
post_task.status = TS.CANCELLED
|
|
await db_session.flush()
|
|
|
|
revision = await engine.reauthor_from_rejection(
|
|
post_task, "Logo is cut off in the second scene"
|
|
)
|
|
assert revision is not None
|
|
assert revision.id != post_task.id
|
|
assert revision.source == VIDEO_SOURCE
|
|
revision_draft = markers.get_video_draft(revision)
|
|
assert revision_draft is not None
|
|
assert revision_draft["occasion"] == "release v1.0.0" # SAME occasion
|
|
assert revision_draft["platforms"] == ["x", "tiktok"]
|
|
assert "Logo is cut off in the second scene" in revision_draft["brief"]
|
|
assert "motion/compositions/ReleaseIntro/" in revision_draft["brief"]
|
|
assert "do not start a new composition" in revision_draft["brief"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reauthor_from_rejection_missing_draft_returns_none(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="no-draft", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert task is not None
|
|
task.orchestration_markers = {} # strip the video_draft marker
|
|
await db_session.flush()
|
|
|
|
result = await engine.reauthor_from_rejection(task, "some reason")
|
|
assert result is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# scene criterion — a brief-named feature list becomes its own gate-checkable
|
|
# AC (delegation detail-fidelity: prose feature counts used to pass gates
|
|
# even when a dev shipped fewer scenes than the brief named)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_draft_release_video_carries_scene_criterion_for_highlights(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch, video_on_release=True)
|
|
_mock_local_model(monkeypatch, "shipped!")
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
|
|
assert task is not None
|
|
assert len(task.acceptance_criteria) == FIVE
|
|
scene_ac = task.acceptance_criteria[FOUR]
|
|
assert scene_ac.startswith("Every brief-named feature appears as its own")
|
|
assert "a huge new release" in scene_ac
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_no_highlights_keeps_ac_count_unchanged(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
task = await engine.open_video_task(
|
|
occasion="spotlight no-highlights",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
suggested_input_props={"version": "1.0.0"}, # no "highlights" key
|
|
)
|
|
assert task is not None
|
|
assert len(task.acceptance_criteria) == FOUR # no scene criterion appended
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reauthor_from_rejection_with_highlights_regenerates_scene_criterion(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v3.0.0",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
suggested_input_props={
|
|
"version": "3.0.0",
|
|
"highlights": ["Feature A", "Feature B"],
|
|
},
|
|
)
|
|
assert source_task is not None
|
|
draft = markers.get_video_draft(source_task) or {}
|
|
# Simulate propose_video: the dev's real input_props carries the same
|
|
# highlights forward onto the marker.
|
|
markers.set_video_draft(
|
|
source_task,
|
|
{
|
|
**draft,
|
|
"composition_id": "ReleaseIntro",
|
|
"input_props": {
|
|
"version": "3.0.0",
|
|
"highlights": ["Feature A", "Feature B"],
|
|
},
|
|
},
|
|
)
|
|
source_task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
|
|
post_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
|
|
captions={"x": "cap"},
|
|
platforms=["x"],
|
|
)
|
|
post_task.status = TS.CANCELLED
|
|
await db_session.flush()
|
|
|
|
revision = await engine.reauthor_from_rejection(post_task, "Logo too small")
|
|
assert revision is not None
|
|
assert len(revision.acceptance_criteria) == FIVE
|
|
scene_ac = revision.acceptance_criteria[FOUR]
|
|
assert "Feature A" in scene_ac
|
|
assert "Feature B" in scene_ac
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reauthor_from_rejection_without_highlights_adds_feedback_criterion(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
source_task = await engine.open_video_task(
|
|
occasion="release v4.0.0", script="s", platforms=["x"], brief="b"
|
|
)
|
|
assert source_task is not None
|
|
draft = markers.get_video_draft(source_task) or {}
|
|
markers.set_video_draft(source_task, {**draft, "composition_id": "Intro"})
|
|
source_task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
|
|
post_task = await engine._originate_video_post(
|
|
source_task=source_task,
|
|
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
|
|
captions={"x": "cap"},
|
|
platforms=["x"],
|
|
)
|
|
post_task.status = TS.CANCELLED
|
|
await db_session.flush()
|
|
|
|
revision = await engine.reauthor_from_rejection(post_task, "Logo is cut off")
|
|
assert revision is not None
|
|
assert len(revision.acceptance_criteria) == FIVE
|
|
assert revision.acceptance_criteria[FOUR] == (
|
|
"Every point in the CEO rejection feedback is visibly addressed in "
|
|
"the rendered cut"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_video_task_scene_criterion_truncates_pathological_feature_list(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
await _seed(db_session)
|
|
_enable(monkeypatch)
|
|
engine = video_engine_module.VideoEngine(db_session)
|
|
features = [f"Feature number {i}" for i in range(50)]
|
|
task = await engine.open_video_task(
|
|
occasion="release pathological",
|
|
script="s",
|
|
platforms=["x"],
|
|
brief="b",
|
|
suggested_input_props={"highlights": features},
|
|
)
|
|
assert task is not None
|
|
assert len(task.acceptance_criteria) <= SEVEN
|
|
scene_ac = task.acceptance_criteria[-1]
|
|
assert len(scene_ac) <= _AC_ITEM_CHAR_CAP
|
|
assert "more)" in scene_ac # truncated, not silently dropped
|