feat(web-platform): full-stack AI video production UI — FastAPI + Next.js 16#241
Open
sxm1129 wants to merge 47 commits into
Open
feat(web-platform): full-stack AI video production UI — FastAPI + Next.js 16#241sxm1129 wants to merge 47 commits into
sxm1129 wants to merge 47 commits into
Conversation
- Implementation Details:
Complete M0–M2 web platform implementation built on the existing OpenMontage
agent-first architecture. Key technical decisions:
Backend (server/):
- FastAPI sidecar with in-memory JobStore (asyncio.Event for approval gates)
- Stage Runner drives cinematic pipeline stage-by-stage via MaaS LLM
(anthropic/claude-sonnet-4.6 via OpenAI-compatible gateway)
- Tool Bridge dispatches agent tool calls to existing tools/tool_registry.py
- SSE event stream (sse-starlette) with seq-based reconnect support
- 8-stage cinematic pipeline: research→proposal→script→scene_plan→assets→edit→compose→publish
- human_approval_default stages pause via asyncio.Event until REST /approve call
Frontend (web/):
- Next.js 16 App Router, dark-first, shadcn/ui + Tailwind
- proxy.ts (Next.js 16 rename from middleware.ts) for passphrase auth gate
- Job detail page: 8-step Stepper + SSE EventSource + approval panel + video player
- New project wizard: content type cards → brand info + video params form
- Prisma 7 schema (url moved to prisma.config.ts, no url in datasource block)
Infrastructure:
- Docker Compose: postgres + server + web (multi-stage build)
- MaaS tools: maas_video.py, maas_image.py, maas_tts.py for LTX-2.3/Flux2/IndexTTS
- Spike validation (spike_agent_runner.py) confirmed headless agent pattern works
Validated: production build clean (0 TS errors), FastAPI routes confirmed,
SSE streaming confirmed live with real agent running research stage.
- Affected Files:
* server/ — FastAPI sidecar (main, routers, runner, store)
* web/ — Next.js app (pages, components, proxy, prisma schema)
* tools/audio/maas_tts.py, tools/graphics/maas_image.py, tools/video/maas_video.py
* docker-compose.yml, docs/WEB_PLATFORM_DESIGN.md, .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Implementation Details:
Two bugs found during E2E validation of the cinematic pipeline:
1. write_artifact JSON truncation loop: When the LLM generates a large
artifact JSON as function-call arguments, it hits max_tokens=4096 mid-
string. json.loads() raises JSONDecodeError, tool_args falls back to {},
and execute_tool("write_artifact", {}) raises KeyError on 'artifact_name'.
This unhandled exception crashes the background task silently, leaving
the job stuck in "running" forever.
Fix: (a) wrap execute_tool in try/except in stage_runner so exceptions
become error events instead of crashes; (b) detect JSONDecodeError and
return a structured error message to the agent so it retries with a
shorter content; (c) raise max_tokens to 8192; (d) add explicit null
checks in execute_tool for required args; (e) add size guidance to the
write_artifact tool schema description.
2. Blocking event loop: _run_agent_stage() uses the synchronous OpenAI
client, which blocks asyncio when called directly from the async
run_pipeline_job(). This prevented SSE keepalive, health check responses,
and approval gate signaling while an LLM call was in-flight.
Fix: wrap _run_agent_stage calls with asyncio.to_thread() so the sync
blocking work runs in a thread pool without starving the event loop.
Validated: full 8-stage cinematic pipeline ran end-to-end — research,
proposal (approval gate), script (approval gate), scene_plan, assets
(maas_video + maas_image), edit, compose all confirmed working.
- Affected Files:
* server/app/runner/stage_runner.py
* server/app/runner/tool_bridge.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Implementation Details:
Server:
- GET /jobs list endpoint (newest-first, sorted by created_at)
- JobStore.all() and created_at field added
- Brand Kit CRUD: POST/GET/PATCH/DELETE /brands — JSON file store
under brand_kits/<slug>/kit.json (no Postgres required)
- Brand Kit injection in stage_runner: if options.brand_kit_id is set,
loads brand_kits/<kit_id>/kit.json and injects a "Brand Kit" section
into every stage's system prompt for visual/tonal consistency
Web:
- Dashboard page: client-side polling (8s), real jobs from GET /jobs,
animated status badges, brand_name displayed instead of project_name
- /dashboard/brands: full Brand Kit CRUD UI with create/edit/delete,
color swatch preview, tone keyword chips
- /dashboard/new: Brand Kit quick-apply chips at top of wizard;
selecting a kit pre-fills brand_name + slogan and passes brand_kit_id
in options so the pipeline uses it for prompt injection
- Affected Files:
* server/app/main.py
* server/app/store.py
* server/app/routers/jobs.py
* server/app/routers/brands.py (new)
* server/app/runner/stage_runner.py
* web/app/dashboard/page.tsx
* web/app/dashboard/brands/page.tsx (new)
* web/app/dashboard/new/page.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Implementation Details:
At proposal/script approval gates the user can now directly edit the AI-
generated artifact JSON instead of only approving or rejecting. The
edited version is persisted to disk via a new endpoint so the next stage
picks it up as canonical truth.
Server:
- POST /jobs/{id}/artifact — saves req.content as projects/<name>/
artifacts/<stage>.json, overwriting the AI-generated version. Used
exclusively by the UI inline editor at approval gates.
Web (job detail page):
- Inline edit toggle in approval panel header (proposal and script stages)
- Monospace Textarea pre-filled with artifact JSON
- Save: validates JSON, POSTs to /artifact, updates local preview
- Restore: resets editor to last server preview
- Approve/reject hidden while in edit mode
- Affected Files:
* server/app/routers/jobs.py
* web/app/dashboard/jobs/[jobId]/page.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Implementation Details:
When a pipeline stage fails (tool error, LLM timeout, max-turns exceeded),
the job status becomes "failed" and the pipeline halts. Previously there
was no recovery path; the user had to create a brand new job.
Server:
- POST /jobs/{id}/retry — re-queues a failed or stuck job, starting the
pipeline from the beginning but with all previously written artifacts
intact on disk. The stage_runner will skip generating artifacts that
already exist (handled naturally by the artifact-load path) so
completed stages are effectively skipped.
Web:
- Failed-state card shown below the Stepper with a "↺ 重试" button.
- Clicking re-POSTs to /retry, sets local status back to "queued",
and the SSE stream reconnect logic picks up new events automatically.
- Retry button disabled while request is in-flight.
- Affected Files:
* server/app/routers/jobs.py
* web/app/dashboard/jobs/[jobId]/page.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nterfaces
- Implementation Details:
* Media serving: mount FastAPI StaticFiles at /media/ pointing to projects/
directory — render_url from job_completed events is now a valid src for <video>
* Cost accumulation: tool_bridge.execute_tool() accepts cost_accumulator list;
stage_runner passes one shared list across all stages and emits cost_updated SSE
events after every stage; cost_cny = sum(usd) × 7.25 stored in JobStore
* M5-2 cost display: job detail page handles cost_updated SSE event and renders
¥N.NNNN chip next to the status badge; updates in real-time as stages complete
* M5-3 settings/evolution page at /dashboard/settings: system health check,
current tech stack summary, and roadmap with 6 evolution items (queue, object
storage, OAuth, Postgres migration, multi-pipeline, budget gate) — each tagged
"接口已预留" or "规划中"
* Sidebar already includes the Settings nav item (layout.tsx was unchanged)
- Affected Files:
* server/app/main.py
* server/app/runner/tool_bridge.py
* server/app/runner/stage_runner.py
* web/app/dashboard/jobs/[jobId]/page.tsx
* web/app/dashboard/settings/page.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brand kit JSON files are created by the API at runtime and regenerable from user input — no need to track them in source control. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The final render was never reachable by the UI. run_openmontage_tool only
auto-assigned output paths for capabilities in {video_generation,
image_generation, tts}; video_compose's capability is "video_post", so its
output fell through to assets/video_post/video_compose_output.bin — never an
mp4, never under renders/. The runner globbed only renders/*.mp4, found
nothing, emitted job_completed with render_url=null, and the <video> player
never appeared.
- tool_bridge: add ext_map entries for video_post/music_generation/
audio_processing; route the final compose op to renders/final.mp4 while
keeping intermediate video_post ops (trim/stitch) in assets/.
- stage_runner: _discover_render_url() finds the newest mp4 in renders/,
falling back to assets/**/*.mp4 and misnamed compose outputs, then returns
a browser-servable /media/<project>/<relpath> URL.
Affected Files:
* server/app/runner/tool_bridge.py
* server/app/runner/stage_runner.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eject Three real defects in the pipeline driver: 1. Silent freeze: run_pipeline_job had no top-level exception guard, so any unhandled raise killed the BackgroundTask coroutine and left the job stuck at "running" forever. Now wrapped — unhandled errors mark the job failed and emit job_failed with a truncated trace. 2. Retry destroyed approved work: retry re-ran run_pipeline_job from stage 0, and each write_artifact unconditionally overwrote the approved research/proposal/script with freshly (nondeterministically) generated content, re-triggered approval gates, and re-incurred cost. Now stages are recorded in job.completed_stages as they finish (post-approval), and a (re)run skips any completed stage — so retry resumes at the failed stage and never clobbers approved artifacts. Cost is preserved across retries via base_cost. 3. Reject blocked the event loop: the rejection re-run called _run_agent_stage synchronously (no asyncio.to_thread), freezing the loop during the LLM call, dropped the cost_accumulator, and — worse — never re-presented the regenerated artifact for approval (a rejected proposal silently passed). Now the reject path loops: regenerate in a thread, keep accumulating cost, and re-present for approval each round. Affected Files: * server/app/runner/stage_runner.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- store: events were appended with seq=len(list) and never trimmed — unbounded growth on long jobs, and trimming would have collided seq values. Added a monotonic per-job seq counter independent of buffer length, and cap the ring at MAX_EVENTS_PER_JOB (5000) so replay filtering stays correct while memory stays bounded. Also seed completed_stages in the job record. - events: the SSE generator looped forever on sleep(0.5). A client reconnecting after the job finished (seq past the terminal event) got [] every tick and the connection leaked. Now returns once the job is terminal and all events are drained. - web: the reconnect guard read the captured `status`, which is always the initial "queued" (effect deps are [jobId]) — so it reconnected forever after completion. Switched to doneRef/cancelledRef so reconnect happens only while the job is live and the view is mounted. Affected Files: * server/app/store.py * server/app/routers/events.py * web/app/dashboard/jobs/[jobId]/page.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-memory store lost every job on restart: running jobs orphaned, the retry endpoint 404'd, and the dashboard went blank. Made it filesystem-first (no DB needed, consistent with the project's storage model): - Job records write through to .jobstore/<job_id>.json (atomic tmp+replace). - Events append to .jobstore/<job_id>.events.jsonl (O(1) per event, no full-file rewrite); the in-memory ring stays capped, full history on disk. - On startup the store rehydrates all jobs and their events, restoring the monotonic seq counter. Jobs left mid-flight (queued/running/awaiting_approval) are marked failed+interrupted so the UI offers retry, which resumes from completed_stages. - .jobstore lives at repo root, NOT under projects/, because projects/ is exposed via the /media StaticFiles mount — persisting there would leak brand_info/options/cost over HTTP. Affected Files: * server/app/store.py * .gitignore Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The image ran `uvicorn server.app.main:app` with PYTHONPATH=/app, but app/main.py imports the server package as top-level `app` (`from app.routers import ...`). Under that config there is no top-level `app` on the path (it lives at /app/server/app), so the container crashed on import with ModuleNotFoundError — only the undocumented local `cd server` workaround ran. Aligned the container with the code's import model: WORKDIR /app/server, PYTHONPATH=/app:/app/server, CMD `uvicorn app.main:app`. Dropped --reload so a file-watch restart can't interrupt a running job. Also mount ./.jobstore and ./brand_kits so job state and brand kits survive container recreation. Affected Files: * server/Dockerfile * docker-compose.yml Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gate M5-2 was display-only and structurally always ¥0: the maas tools hardcoded cost_usd=0.0, the stage runner multiplied by a bogus USD_TO_CNY=7.25, no budget gate existed, and tools/cost_tracker.py was never used. - maas tools now report cost via their own estimate_cost(): maas_video returns its real CNY per-second charge (per-resolution price table), image/tts return 0.0 (free MaaS models). The truthiness guard no longer drops legit entries. - stage_runner accumulates CNY directly (MaaS bills in CNY — no FX conversion; the earlier 7.25 multiply was removed in the robustness pass) and preserves cost across retries via base_cost. - CostTracker is now the itemized ledger: tool_bridge estimate()/reserve()/ reconcile() each paid call, persisting projects/<name>/cost_log.json. Run in OBSERVE mode with USD thresholds neutralized — the human gate lives in the runner, which owns the currency semantics. - Budget gate: opt-in options.budget_cny. When cumulative cost crosses it the pipeline pauses (awaiting_approval, gate="budget", budget_exceeded event); the user approves the overspend to continue or aborts. cost_updated now carries budget_cny. - Web: job detail shows ¥spent / ¥budget (red when over) and a budget-specific approval card (approve overspend / abort); the new-job wizard has an optional budget field. Affected Files: * tools/video/maas_video.py, tools/graphics/maas_image.py, tools/audio/maas_tts.py * server/app/runner/tool_bridge.py, server/app/runner/stage_runner.py * web/app/dashboard/jobs/[jobId]/page.tsx, web/app/dashboard/new/page.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…M5-3)
M5-3 was a UI-only roadmap card — no code abstraction existed. Added an
interfaces package where each capability expected to change at scale sits
behind an ABC with a v1 default adapter, selected centrally by env var
(OM_JOB_QUEUE / OM_STORAGE_BACKEND / OM_AUTH_PROVIDER). Swapping to
Redis/Celery, S3/OSS, or OAuth/SSO is adding an adapter class + flipping an env
var — not rewriting call sites.
The seams are load-bearing, not dead code:
- queue: jobs.py create/retry now enqueue via get_job_queue() (default
AsyncioJobQueue schedules the coroutine on the loop, decoupled from any single
request's BackgroundTasks — a job now outlives its HTTP response cleanly).
- storage: stage_runner._discover_render_url routes the render URL through
get_storage().url_for(), so object storage can later return signed URLs
unchanged.
- auth: PassphraseAuth wraps the current v1 cookie; a future OAuthProvider drops
in behind the same login()/verify() contract.
- GET /system/capabilities exposes the live active adapter per seam; the
settings page now renders real backend state ("运行中: local" + planned
adapters) instead of static text — and no longer imports the unused Badge.
Affected Files:
* server/app/interfaces/{__init__,storage,queue,auth}.py
* server/app/routers/system.py, server/app/main.py, server/app/routers/jobs.py
* server/app/runner/stage_runner.py
* web/app/dashboard/settings/page.tsx
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`npm run lint` reported 901 problems because the generated Prisma client wasn't ignored, burying 8 real issues in authored source. Now clean (exit 0): - eslint: ignore lib/generated/** (generated Prisma client, not source). - Removed unused imports: Badge (dashboard), Card/CardContent (new-job wizard). - Escaped literal quotes around the brand slogan (react/no-unescaped-entities). - Silenced react-hooks/set-state-in-effect at three async-fetch-on-mount / external-sync call sites with rationale — these setState after await (or sync the browser viewport), not synchronously in the effect body, so they are false positives of the strict rule, not real cascading-render bugs. Affected Files: * web/eslint.config.mjs * web/app/dashboard/page.tsx, web/app/dashboard/brands/page.tsx * web/app/dashboard/new/page.tsx, web/hooks/use-mobile.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two agent-loop defects: 1. `if not msg.tool_calls or finish == "stop": return True` ended the stage whenever finish_reason=="stop" — but the MaaS gateway (aiapbot proxies Anthropic/DashScope/etc. via an OpenAI-compatible shim) can report finish_reason=="stop" while the assistant message still carries tool_calls. That silently skipped tool execution and marked the stage complete without ever writing the artifact. Gate the early return on tool_calls presence only. 2. Tool results were appended to the running message history verbatim; read_file returns up to 12000 chars and generation-tool results can be large, so over MAX_TURNS turns the history could exceed the model context window and fail the next completion. Cap each result at TOOL_RESULT_CHAR_CAP (8000) before appending. Affected Files: * server/app/runner/stage_runner.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The budget gate only ran between stages, so a single stage (notably assets, which the agent drives to generate many maas_video clips at real CNY each) could overrun the ceiling 10x+ before the gate ever fired — the money was already spent. Now enforce a hard pre-call ceiling: - execute_tool: before a paid tool runs, if base_cost + accumulated + this call's estimate_cost would cross budget_cny, refuse it and raise the shared BudgetExceededError (defined authoritatively in tool_bridge, imported by the runner). Total spend is thereby bounded to <= budget. - _run_agent_stage re-raises BudgetExceededError past its tool try/except so it unwinds the worker thread instead of being swallowed into an error string. - The runner catches it and drives the human budget gate (force=True — pause even when spent is still within budget, since it's the *next* call that would cross). On approve-overspend, budget_overridden disables the pre-call check and the stage re-runs; on abort, the job fails. The between-stages gate stays. Affected Files: * server/app/runner/tool_bridge.py * server/app/runner/stage_runner.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
asyncio.get_event_loop().create_task returns a task the loop only weak-refs; the caller must hold a strong reference or it can be garbage-collected mid-execution. AsyncioJobQueue.enqueue dropped the return value, so a pipeline job could vanish between await points (stuck at running/awaiting_approval with no job_failed) — a regression vs FastAPI BackgroundTasks, which held a strong ref for the task's life. Keep tasks in a set and discard via done-callback. Affected Files: * server/app/interfaces/queue.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d jobs Both maas_video and maas_image aborted the whole generation on the FIRST poll exception (a transient 502/504, connection reset, or read timeout). But the job is already submitted server-side and billed in CNY regardless, so a single blip during the 60-120s poll window wasted the spend and failed the stage. Tolerate up to 5 consecutive poll errors (resetting on success) before treating it as fatal; the overall deadline still bounds the loop. Affected Files: * tools/video/maas_video.py * tools/graphics/maas_image.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_events ran a full O(n) list comprehension over up to MAX_EVENTS_PER_JOB (5000) events under the lock on every 0.5s SSE poll — even for a caught-up client whose correct result is empty — and the events generator called it a SECOND time each poll near completion. With a couple of open tabs on a high-event job that's tens of thousands of wasted comparisons per second. - store.get_events: seq is strictly ascending, so bisect_right the cut point and slice the tail — O(log n + k), empty slice returned cheaply when caught up. - events generator: an empty drain already means "caught up", so use it as the terminal-close signal instead of issuing a second get_events per poll. Affected Files: * server/app/store.py * server/app/routers/events.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every fix this session was validated only by throwaway `python -c` smoke checks.
Fossilize the important behaviors as regression tests so they can't silently
rot. 31 tests, no network (MaaS tools stubbed), runs in <1s.
Coverage:
- store: CRUD, get_events bisect semantics (tail slices, empty when caught up),
event-ring cap with monotonic seq, durable persistence round-trip (reload +
in-flight→failed+interrupted + seq continuity), approval gate (approve/reject/
timeout/not-awaiting).
- tool_bridge: read_file/write_artifact incl. missing-param errors, compose
routing to renders/final.mp4 vs assets/ for non-compose video_post, the budget
pre-check (blocks over-budget before spend, allows within budget, no-budget
runs free), and CostTracker ledger recording to cost_log.json.
- interfaces: default seams, active_backends shape, LocalStorage url_for/paths,
PassphraseAuth login/verify (incl. empty-passphrase safety), and the
AsyncioJobQueue strong-reference-held-then-discarded guarantee.
- stage_runner: _discover_render_url (renders → assets → misnamed .bin → None),
_load_artifacts skipping invalid JSON, and the O-1 regression — a
finish_reason=="stop" response carrying tool_calls still executes the tools.
Scaffolding: server/pytest.ini (pythonpath=. .., asyncio_mode=auto),
server/requirements-dev.txt.
Affected Files:
* server/pytest.ini, server/requirements-dev.txt
* server/tests/conftest.py, test_store.py, test_tool_bridge.py,
test_interfaces.py, test_stage_runner.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No frontend test runner existed. Added Vitest + Testing Library (jsdom) and extracted the job detail view's presentational pieces — StatusBadge, EventRow, and the eventLabel precedence helper — into components/job-status.tsx so their mapping logic is testable without the SSE-driven page shell. page.tsx now imports them (and the shared SseEvent type) instead of defining them inline. 7 tests: every status → Chinese label, unknown-status fallback, status colour class, eventLabel precedence (summary→text→artifact→message→type), and EventRow tag/label rendering. Scripts: `npm test` (vitest run), `npm run test:watch`. Affected Files: * web/package.json, web/vitest.config.ts, web/vitest.setup.ts * web/components/job-status.tsx, web/app/dashboard/jobs/[jobId]/page.tsx * web/__tests__/job-status.test.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The workflow only exercised the engine's Python tests. Added two jobs so the web platform is covered on every PR/push: - server-tests: Python 3.12, install engine requirements + server/ requirements-dev.txt, run `pytest` from server/ (31 tests). - web: Node 22, `npm ci`, then lint → tsc --noEmit → `npm test` (Vitest) → `next build`. The existing engine "validate" job is unchanged. Affected Files: * .github/workflows/ci.yml Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the suite from 31 to 44 tests, covering the two areas the first pass left thin: the HTTP surface and the runner's control flow. - test_routers.py (TestClient, pipeline enqueue stubbed): health, system capabilities, job create/list/get/404, retry gating (400 unless failed/running, 404 missing), approve-requires-awaiting (404), save-artifact file write + 404, and full brand-kit CRUD incl. 404s. State isolated via monkeypatched job_store / OM_ROOT / BRAND_KITS_DIR. - test_pipeline_orchestration.py (_run_agent_stage stubbed): happy path completes with completed_stages set, resume skips already-done stages (stage_skipped, only remaining stage runs), unhandled error hits the top-level guard (status=failed + "Unhandled pipeline error" event), and the budget gate pauses then resumes on approve / aborts on reject — the between- stages gate exercised end to end. Affected Files: * server/tests/test_routers.py * server/tests/test_pipeline_orchestration.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ading
The runner hardcoded CINEMATIC_STAGES and a 2-entry PIPELINE_MAP, so the web
platform could only run cinematic/marketing_film even though the engine ships
11+ pipelines in pipeline_defs/. Now stages are resolved dynamically:
- _resolve_stages(name): explicit PIPELINE_MAP override (cinematic +
marketing_film alias, kept for stability/tests) → else load pipeline_defs/
<name>.yaml via lib.pipeline_loader, mapping each stage's skill
"pipelines/x/y-director" → "skills/pipelines/x/y-director.md" and
human_approval_default → approval → else fall back to cinematic. Unknown or
malformed manifests degrade safely.
- GET /pipelines lists every runnable pipeline with description/category/
stability/stages/approval_stages for the UI; GET /pipelines/{name} returns
per-stage approval detail (404 unknown, 400 malformed).
animated-explainer, screen-demo, podcast-repurpose, etc. are now runnable
end-to-end through the existing job API — just pass their name as `pipeline`.
Tests: override precedence, dynamic manifest load (skill normalization +
approval gates), unknown→fallback, and both endpoints (49 server tests total).
Affected Files:
* server/app/runner/stage_runner.py
* server/app/routers/pipelines.py, server/app/main.py
* server/tests/test_pipelines.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new-job wizard hardcoded which content types were available (only cinematic was on; explainer/podcast/demo/short were "即将上线"). Now it fetches /pipelines and: - enables each curated Chinese entry once the engine reports its mapped pipeline (all five are live now that the backend loads pipelines dynamically), showing "未启用" only if genuinely absent; - lists every remaining engine pipeline under "更多引擎流水线" (name, stability badge for non-production, stage count, description) so users can run animation / avatar-spokesperson / hybrid / etc. directly. Selecting either kind sets content_type + pipeline for the job submit. Affected Files: * web/app/dashboard/new/page.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two engine manifests fail strict schema validation — documentary-montage (category "documentary" not in the enum) and screen-demo (extra "production_modes" key). load_pipeline() raised on them, so they were dropped from GET /pipelines entirely and, worse, selecting them made _resolve_stages silently fall back to cinematic — the user would get a different pipeline than requested. (All 88 stage director skills, by contrast, exist — nothing missing there.) Added app/pipeline_catalog.load_manifest: strict validation first, falling back to a raw YAML read on schema drift, without editing the engine's manifests. _resolve_stages and both /pipelines endpoints now use it, so every pipeline in pipeline_defs/ (13) lists and resolves to its own real stages. Genuine FileNotFoundError still 404s. Tests: listed set == all manifest names incl. the two drifted ones, drifted pipeline resolves its own (non-cinematic) stages, detail endpoint returns 200 for a drifted manifest (51 server tests). Affected Files: * server/app/pipeline_catalog.py (new) * server/app/runner/stage_runner.py, server/app/routers/pipelines.py * server/tests/test_pipelines.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up audit of last session's dynamic pipeline loading found three real
defects, all confirmed and fixed:
1. HIGH — skill-less stages crashed the job. _resolve_stages mapped a manifest
stage with no "skill" key to "" (not None). Path(OM_ROOT) / "" evaluates to
OM_ROOT itself — a real, existing directory — so .exists() was True and the
intended "no skill → placeholder text" fallback was never taken;
read_text() on a directory raised IsADirectoryError, uncaught in
_run_pipeline_impl, surfacing as a generic "Unhandled pipeline error" and
failing the job outright. Reachable today: framework-smoke's research/script
stages have no skill key, and the picker UI added last session lists
framework-smoke as fully selectable. Fixed by using None (not "") for
skill-less stages and only building skill_path when it's set.
2. LOW (defense in depth) — pipeline-name path traversal. The user-supplied
"pipeline" string (free-form JSON field on POST /jobs, or the {name} path
segment on GET /pipelines/{name}) was joined into a filesystem path with no
allowlist check, so "../config" (or deeper "../../" chains) escaped
pipeline_defs/ and load_manifest would read + return the contents of an
arbitrary reachable .yaml file. load_manifest now rejects any name not in
list_manifest_names() before touching the filesystem.
3. LOW — load_manifest could return None. The lenient yaml.safe_load fallback
on an empty/null-only file returns None, not a dict; GET /pipelines and GET
/pipelines/{name} call .get() on that outside their try/except, which would
500 the entire pipeline listing (not just the one broken manifest) if such a
file ever appeared. Fixed with `manifest or {}`.
Tests: skill-less stage resolves to skill=None (not ""), the same stage
completes end-to-end via run_pipeline_job without crashing, traversal payloads
raise FileNotFoundError, and an empty-YAML fallback never returns None
(56 server tests total, up from 51).
Affected Files:
* server/app/runner/stage_runner.py
* server/app/pipeline_catalog.py
* server/tests/test_pipelines.py
* server/tests/test_pipeline_orchestration.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The follow-up audit's picker_ui_review agent malfunctioned (returned garbage), so this was done by direct inspection instead. Manually verified the flagged "does a 'more pipelines' selection carry everything selectedType needs" concern is NOT a bug — the inline object literal already set id/label/ description/pipeline/stability, matching every field selectedType.* is read as (content_type/pipeline at submit, label/description in the wizard header). The real, actionable gap was the total absence of test coverage for this session's picker logic. Extracted the picker's pure decision logic into web/lib/pipeline-picker.ts — CONTENT_TYPES, isPipelineAvailable (availability gating, incl. the size===0-means-everything-enabled pre-load behavior), computeMorePipelines (featured-vs-more dedup), and toPipelineOption (the /pipelines entry → PipelineOption mapping) — mirroring the components/job-status.tsx extraction pattern. page.tsx now imports these instead of defining them inline. 8 new tests: availability gating (empty-set and populated-set), dedup (partial/ full overlap/no overlap), the option mapping (full fields + undefined stability), and CONTENT_TYPES has no duplicate ids/pipeline names (15 web tests total, up from 7). Affected Files: * web/lib/pipeline-picker.ts (new) * web/app/dashboard/new/page.tsx * web/__tests__/pipeline-picker.test.ts (new) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The job detail page hardcoded STAGES/STAGE_LABELS to cinematic's 8-stage shape (research/proposal/script/scene_plan/assets/edit/compose/publish). Only 3 of the 13 now-selectable pipelines (cinematic, animation, animated-explainer) actually match it. 9 others use different stage sets — most collapse research+proposal into a single "idea" stage; character-animation adds character_design/rig_plan; documentary-montage has only 5 stages total. Two concrete breaks: the Stepper's stageIndex/progress used STAGES.indexOf(currentStage), which is -1 for any stage outside the hardcoded list, so progress stayed at 0% and no circle ever lit up for those pipelines. Worse, the approval panel title did STAGE_LABELS[awaitingStage] with no fallback — for a pipeline whose approval-gated first stage is "idea" (talking- head, screen-demo, hybrid, podcast-repurpose, localization-dub, clip-factory, documentary-montage — 7 of 13), it rendered the literal string "undefined — 等待你的审批" as the button the user must act on to continue. Fixed by using the job's actual stage list instead of a constant: the backend already sends it on job_started (server/app/runner/stage_runner.py "stages": [...]); the frontend now stores it in state and derives stageIndex/progress/ the Stepper circles from it. Added stageLabel() with a safe `?? stage` fallback (covers the 11-name union across all manifests + "budget"), used for both the Stepper labels and the approval title, so an unmapped name degrades to its raw form instead of "undefined". Affected Files: * web/components/job-status.tsx * web/app/dashboard/jobs/[jobId]/page.tsx * web/__tests__/job-status.test.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… name
Most pipelines name a stage differently from what it actually produces — e.g.
9 of 13 manifests use stage "idea" (produces "brief"+"decision_log"); cinematic
uses "publish" (produces "publish_log"). The approval-preview lookup only had
one hardcoded alias ("proposal"→"proposal_packet"), so every other mismatched
stage showed a null/empty preview at its approval gate even though the agent's
artifact had written successfully — the user had nothing to review before
approving or rejecting.
- _resolve_stages now carries each stage's manifest `produces` list through
(CINEMATIC_STAGES gets the same field, matching cinematic.yaml, so the
override path benefits too).
- _preview() tries the stage name first (back-compat), then each produces name
in order, returning the first artifact that actually exists.
- The agent's prompt now states the expected artifact_name explicitly ("##
Expected Artifact Name") instead of leaving it to guess from the stage name
— closes the root cause, not just the preview symptom. write_artifact's tool
schema hint no longer mixes stage-name and produces-name examples with no
indication of which applies where.
- Found and fixed an adjacent bug while threading this through: the reject-
regenerate call to _run_agent_stage omitted budget_cny/base_cost entirely,
so a reject loop could bypass the pre-call budget ceiling with no check at
all. It now passes the same ceiling as the initial run and is wrapped in the
same BudgetExceededError → pause-for-human-decision handling.
Tests: a stage named "idea" that writes "brief.json" now shows it in the
approval preview; the prompt states the exact produces name (with secondary
artifacts) or falls back to the stage name; the reject-retry call is proven to
receive the real budget ceiling, not None (60 server tests, up from 56).
Affected Files:
* server/app/runner/stage_runner.py
* server/app/runner/tool_bridge.py
* server/tests/test_pipeline_orchestration.py
* server/tests/test_stage_runner.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ng a stage required_artifacts_in was parsed by every manifest but never read by the runner — _resolve_stages dropped it on the floor, so a stage with a missing upstream artifact (a broken resume, a naming drift, a hand-edited/skipped stage) still launched the LLM call normally. The agent only saw an incomplete "Available artifacts" summary and had no signal that something upstream was actually wrong — it would likely improvise plausible-looking content instead of surfacing the real problem. _resolve_stages now carries required_artifacts_in through (by produces-name, matching the manifest convention — verified against all 13 pipeline_defs/*.yaml that every required name is actually produced by an earlier stage, so this can't spuriously fail any current pipeline). Before launching a stage, the runner checks its required artifacts against what's actually on disk; if any are missing, the job fails immediately with a message naming exactly which artifact(s) are missing and where, instead of silently prompting the agent. Tests: a stage missing its required upstream artifact fails fast without ever invoking _run_agent_stage, naming the missing artifact in the failure message; the same setup with the artifact present proceeds normally (62 server tests, up from 60). Affected Files: * server/app/runner/stage_runner.py * server/tests/test_pipeline_orchestration.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent bugs that would each break `docker compose up` on a fresh
checkout:
1. web/Dockerfile COPYs .next/standalone into the runner stage, but
next.config.ts never set output: "standalone" — next build doesn't emit
that directory without it. Reproduced locally: a real `npm run build`
confirmed no .next/standalone existed before this fix. `docker build`
would fail at that COPY step every time.
2. docker-compose.yml's web service set environment: SERVER_URL=... — wrong
var name (every page reads NEXT_PUBLIC_SERVER_URL, confirmed via grep
across web/app/**/*.tsx) AND the wrong mechanism (NEXT_PUBLIC_* vars are
inlined into the client bundle at `next build` time; a runtime
`environment:` entry has zero effect on already-built JS). It was also the
wrong VALUE for the mechanism it should've used: "http://server:8000" only
resolves between containers on the compose network, but every fetch in
this app runs in the browser (client components), which can't resolve
docker-internal hostnames — it needs the host-mapped port instead.
Fixed by adding `output: "standalone"` to next.config.ts, threading
NEXT_PUBLIC_SERVER_URL through web/Dockerfile as a build ARG (set before `RUN
npm run build`, defaulting to http://localhost:8000), and switching
docker-compose.yml's web service from a runtime `environment:` entry to
`build.args: NEXT_PUBLIC_SERVER_URL: http://localhost:8000` — reachable from
the browser via the server's host-mapped port.
Verified without a Docker daemon: a real `npm run build` now produces
.next/standalone/{server.js,node_modules,package.json} (matching the
Dockerfile's COPY paths exactly), and grepping the built .next/static/chunks
confirms "http://localhost:8000" is actually inlined into the client bundle.
`docker compose config --quiet` validates the compose file syntax cleanly.
Affected Files:
* web/next.config.ts
* web/Dockerfile
* docker-compose.yml
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y dead Nothing in server/ or web/ reads or writes through Prisma today — all job/ event state lives in the disk-backed JobStore (server/app/store.py) — yet nothing said so anywhere. A contributor running `docker compose up` sees a healthy postgres container and could reasonably conclude the app is Postgres-backed, then waste time debugging connectivity for a service nothing queries, or generate migrations for tables nothing touches. Added a plain status note at the three places someone would actually encounter this: web/lib/db.ts (where you'd import `db` to use it — says where job data really lives instead), web/prisma/schema.prisma (where you'd edit the schema), and docker-compose.yml's postgres service (where you'd see the container start). All three frame it the same way: intentionally scaffolded ahead of a future multi-tenant/OAuth milestone, not abandoned — but not wired to anything yet, and safe to remove if that milestone isn't imminent. Affected Files: * web/lib/db.ts * web/prisma/schema.prisma * docker-compose.yml Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found live: an in-flight job (awaiting approval at the "script" stage) got silently stuck with no visible next action after the dev server restarted. Root cause — store._load_all() marks a mid-flight job (queued/running/ awaiting_approval) failed+interrupted on startup (by design, since no worker survives a restart), but never appends a job_failed event to its persisted log. Its last REAL event stays whatever it was pre-crash (e.g. awaiting_approval). A (re)connecting SSE client drains history, the stream closes with nothing new (job already terminal, events.py just `return`s), and page.tsx's onerror handler reconnects every 2s forever — status stays stuck at the stale "awaiting_approval", the approval buttons render but POST /approve now 404s since the job is really "failed" server-side, and the retry button never appears (it's gated on status === "failed", which the client never learns). - events.py: when a client (re)connects to a job whose events are fully drained but whose status is already terminal, synthesize a job_completed/ job_failed event (carrying render_url and an "interrupted" message when applicable) before closing, instead of closing silently. This fixes it for both a fresh page load and an already-open tab's next reconnect attempt — no manual refresh needed. - page.tsx: handleApproval/handleRetry now check response.ok and surface the server's error detail via a visible banner instead of silently doing nothing on a 404/400 (the same failure class, cheap to close off here too). Tests: 5 new SSE tests covering normal terminal replay, the interrupted-job synthesis (both fresh connect and reconnect-past-all-history), and unknown-job handling (67 server tests total). Also adds .claude/launch.json (dev server + web dev, port 8010 to avoid a local port-8000 conflict with an unrelated project on this machine). Affected Files: * server/app/routers/events.py * web/app/dashboard/jobs/[jobId]/page.tsx * server/tests/test_events.py (new) * .claude/launch.json (new) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # .claude/launch.json
…ifact Found live: the assets stage's generation tools hit real 400s from the MaaS API for that particular request; per the provider skill contract the agent correctly stopped to ask a human instead of silently substituting a fallback provider. But _run_agent_stage returning True only means "the LLM's turn ended with no more tool calls" — it says nothing about whether the stage's canonical artifact actually got written. The runner treated that as unconditional success: 'assets' was added to completed_stages and permanently skipped on every future retry (line 470's resume-skip), so the job died one stage later at 'edit' (required_artifacts_in correctly caught the missing asset_manifest — that check worked as designed) with no way to ever re-run the stage that actually needed it. A dead-end retry loop. _missing_produces(): after a stage's agent loop reports success, verify at least one of its declared `produces` artifacts actually exists on disk before treating it as done. If none do, fail the job at THIS stage with a message pointing at the real cause, and — critically — do NOT add it to completed_stages, so a subsequent retry actually re-attempts it instead of skipping straight to the same downstream required-artifact failure forever. Only applied after the stage's first run (not the reject-regenerate loop, where a stale artifact from the prior round would trivially satisfy the check either way — no real detection power there). Tests: a stage whose stub reports success without writing anything now fails at that stage with current_stage/completed_stages reflecting it correctly (not silently marked done); updated the existing budget-ceiling reject-retry test whose stub never wrote its produces artifact, which this new check now correctly flags (68 server tests total). Affected Files: * server/app/runner/stage_runner.py * server/tests/test_pipeline_orchestration.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the previous commit, found in the same live test: compose
declares produces=[render_report, final_review], and publish's
required_artifacts_in names final_review specifically. The just-added
_missing_produces() check used any() — so an agent run that wrote
render_report but stopped before writing final_review was still treated as
"stage done," cascading the real failure one stage further downstream to
publish with a more confusing message ("publish requires final_review") and a
wasted round-trip through an already-failed compose.
Downstream required_artifacts_in can name ANY of a stage's declared produces,
so the produces contract means all of them are real dependencies, not just
"at least one." Changed to report every name that's actually missing;
partial completion now fails at the stage that stalled, with a message naming
exactly what's missing — not a downstream stage with a misleading one.
Test: an agent stub that writes only the first of two declared produces names
now fails at that stage (not completed_stages) with a message naming the
missing one and NOT the one that was actually written (69 server tests total).
Affected Files:
* server/app/runner/stage_runner.py
* server/tests/test_pipeline_orchestration.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found live while manually retrying a stuck test job: two run_pipeline_job
invocations for the SAME job_id ended up racing each other, interleaving
stage-tagged events from an earlier still-finishing "compose" run with a
later "publish" run's events in the SSE log, and current_stage flip-flopping
between two coroutines' job_store.update() calls. Root cause: POST
/jobs/{id}/retry accepted status == "running" as retryable (docstring: "or
stuck"), but the persistence layer already flips any job actually orphaned by
a process restart to "failed" on startup (JobStore._load_all) — so "running"
now only ever means a job IS actively, validly being driven right now.
Retrying it enqueues a second concurrent execution: two LLM sessions writing
artifacts for the same project, each clobbering whatever the other just wrote.
- jobs.py: retry now requires status == "failed" only.
- stage_runner.py: _ACTIVE_JOB_IDS, an in-process re-entrancy guard, as
defense-in-depth for the TOCTOU gap between retry's status check and the
enqueue (two near-simultaneous calls could both pass the check before either
updates status). A duplicate call for a job_id already in flight is now an
immediate, clearly-logged no-op instead of a silent data race.
Tests: retry rejects a "running" job (400, was previously accepted), and two
overlapping run_pipeline_job calls for the same job_id — the second is a
no-op, only one drives the job to completion (71 server tests total).
Affected Files:
* server/app/routers/jobs.py
* server/app/runner/stage_runner.py
* server/tests/test_routers.py
* server/tests/test_pipeline_orchestration.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found live testing this session's own retry fixes: a job failed once, was retried, and genuinely succeeded — appending many more events after the old job_failed, including a real job_completed further down the persisted log. The SSE generator returned as soon as it saw ANY job_completed/job_failed while iterating a batch, so a full replay (a fresh page load, or any reconnect spanning more than one retry cycle) stopped dead at the FIRST — old, already-superseded — terminal event and never delivered the retry's real, later outcome. A client would sit there reporting the stale old failure forever, exactly the "stuck, no next action" symptom this same job hit twice already this session. Only decide to close the stream AFTER draining the whole batch just fetched, using the type of the LAST event delivered — never mid-batch on an intermediate one. This composes cleanly with the earlier interrupted-job synthesis fix (same file): if the batch's last real event is already job_completed/job_failed, close on it; if the job is terminal but nothing new was ever appended (the original interrupted-mid-flight case), synthesize one as before. Test: a log with an old job_failed followed by a resumed run's events ending in a real job_completed must deliver every single event and stop only at the last one (72 server tests total). Affected Files: * server/app/routers/events.py * server/tests/test_events.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MaaS platform renamed its self-hosted-GPU model namespace from "h100/" to "leapfast/" (confirmed against the live provider console: leapfast/flux2, leapfast/indextts, leapfast/ltx-2.3). This likely explains a chunk of the persistent "400 Bad Request" errors hit repeatedly this session on video/image generation — the tools were defaulting to and documenting a now-stale model ID. Updated all "h100/" references (MODELS pricing tables, DEFAULT_MODEL, schema defaults/descriptions, docstrings) in maas_video.py, maas_tts.py, maas_image.py, and the new-job wizard's default video_model/image_model options. Verified live: maas_image and maas_video both succeed with the leapfast/ names (previously untested this session); maas_tts hit an unrelated transient 502 from the gateway, not a model-name rejection — and the job wizard's actual TTS default was already qwen3-tts-flash, not indextts, so unaffected either way. Affected Files: * tools/video/maas_video.py * tools/audio/maas_tts.py * tools/graphics/maas_image.py * web/app/dashboard/new/page.tsx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…errors
Found live: the compose stage repeatedly hit KeyError('operation') because
video_compose.execute() indexed inputs["operation"] with no fallback, while
tool_bridge's own output-path routing already assumed "compose" as the
default when unspecified. str(KeyError('operation')) is just "'operation'" —
cryptic enough that the agent couldn't tell what to fix and kept repeating
the same call, burning through the entire MAX_TURNS budget before the stage
finally failed and had to retry from scratch.
- video_compose.py: default operation to "compose" — the overwhelmingly
common call and the only truly obvious default for this stage. A missing
key no longer raises; it now routes to _compose() and fails (if it must)
on that operation's own real precondition, with a real message.
- stage_runner.py: defense in depth for any OTHER tool with the same bare-
dict-access anti-pattern — a KeyError caught from execute_tool is now
reported to the agent as "missing required parameter: 'x'" instead of the
bare repr, so it can self-correct on the next turn instead of looping.
Tests: video_compose treats a missing operation as "compose" (fails on
edit_decisions, never on operation) and still rejects a genuinely unknown one;
stage_runner's KeyError path names the parameter instead of repeating the
bare exception text (73 server + 2 engine tests).
Affected Files:
* tools/video/video_compose.py
* server/app/runner/stage_runner.py
* tests/tools/test_video_compose_operation_default.py (new)
* server/tests/test_stage_runner.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ompose
Two UX gaps noticed live while watching a real job run:
1. The job detail page's title was the raw job UUID
(b177e3b9-3d8c-4670-a952-4eecbdd0928f) — not identifiable at a glance.
Added an initial REST fetch (GET /jobs/{id}) on mount to seed project_name
and show it as the H1, with the UUID demoted to a small mono subtitle.
This fetch also seeds render_url/preview_render_url immediately, so a page
refresh mid-run doesn't have to wait for the full SSE replay to resolve.
2. No way to see the actual render until the ENTIRE job (through publish)
finished — but the compose stage already produces the real deliverable;
publish only adds packaging/distribution metadata. That's several more
turns of waiting with nothing to show for a render that already exists.
stage_runner: right after the compose stage completes, run the same
render-discovery used at job completion and, if found, persist it as
job.preview_render_url and emit a new "preview_ready" SSE event.
page.tsx: a new interim "👁 合成预览" card renders the preview as soon as
preview_ready arrives (or is present on the initial REST fetch), hidden
once the job fully completes and the final "🎬 成片已就绪" card takes over.
Tests: preview_ready fires (with the correct render_url) before job_completed,
and the render is persisted on the job record for a page load mid-publish
(74 server + 18 web tests total).
Affected Files:
* web/app/dashboard/jobs/[jobId]/page.tsx
* server/app/runner/stage_runner.py
* server/tests/test_pipeline_orchestration.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found live, in a real browser, the first time this session's video player was
actually tested end-to-end (previous verification only curl'd the backend
directly): render_url/preview_render_url are root-relative paths ("/media/...")
meant for the FastAPI server's own static mount, but <video src="/media/...">
resolves against the CURRENT page's origin — the Next.js dev server, on a
different port — instead. The request 404s and the video silently fails to
load: readyState 0, networkState NETWORK_NO_SOURCE, no visible error to the
user beyond a blank, uncontrollable player. Both the final "🎬 成片已就绪" card
and the new interim "👁 合成预览" card had this bug.
Added mediaUrl(serverBase, path) to components/job-status.tsx (absolute URLs
pass through untouched; root-relative ones get the backend origin prefixed)
and used it at both <video src> sites and the download link.
Tests: root-relative prefixing, absolute-URL passthrough, null/undefined/empty
handling, and the missing-leading-slash edge case (22 web tests total).
Affected Files:
* web/components/job-status.tsx
* web/app/dashboard/jobs/[jobId]/page.tsx
* web/__tests__/job-status.test.tsx
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… render fallback
Found live investigating "why is the finished video only 3 seconds" —
小猫牌扫地机器人's compose stage genuinely never rendered anything (all
video/image/TTS blocked by real MaaS API errors; render_report honestly
documented the failure in its warnings), yet the player showed a real,
playable clip. Root cause, two compounding bugs:
1. tool_bridge auto-assigned output_path as a FIXED
"{tool_name}_output.{ext}" per tool — every call to the same tool within a
job silently overwrote the previous one's file. The assets stage's "6
scene clips" (generated without the agent overriding output_path) left
exactly ONE file on disk: each call clobbered the last. Fixed with a
per-call uuid suffix so repeated calls never collide.
2. _discover_render_url's fallback globbed assets/**/*.mp4 — broad enough to
match assets/video_generation/*.mp4, the RAW per-scene clips from
maas_video, not just assets/video_post/ (the compose-family tools'
capability folder). With bug calesthio#1 fixed, that raw-clips folder now
legitimately holds multiple real files, making the over-broad fallback
even more likely to grab one and present it as "the finished film" when
compose never actually composed anything. Scoped the fallback to
assets/video_post/ specifically.
Tests: 5 calls to the same tool with an identical prompt now get 5 distinct
output paths; a raw clip sitting in assets/video_generation/ (with nothing in
renders/ or assets/video_post/) is correctly NOT picked up as the render
(76 server tests total).
Affected Files:
* server/app/runner/tool_bridge.py
* server/app/runner/stage_runner.py
* server/tests/test_tool_bridge.py
* server/tests/test_stage_runner.py
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same defect class as the earlier video_compose fix, confirmed live across two
separate jobs (小狗牌咖啡机, 小猫牌扫地机器人v2): video_stitch.execute()
indexed inputs["operation"] with no fallback. An agent supplying exactly the
params _stitch() needs (clips, transitions, output_path) but omitting
"operation" hit a bare KeyError('operation') — cryptic enough that it kept
repeating the same call until the compose stage burned through its entire
MAX_TURNS budget, both times.
"stitch" (join clips with transitions) is the tool's own name and the only
call shape its typical params suggest — default to it instead of requiring
the agent spell out the one obvious operation.
Tests: a missing operation now routes to _stitch() and fails on ITS OWN
precondition ("No clips provided"), never on operation; an explicit unknown
operation is still rejected (76 server tests + 4 engine tests total).
Affected Files:
* tools/video/video_stitch.py
* tests/tools/test_video_stitch_operation_default.py (new)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third confirmed live occurrence of this defect class (after video_compose and
video_stitch): audio_mixer.execute() indexed inputs["operation"] with no
fallback. An agent driving the compose stage called it with mix-shaped params
(video_path, audio_tracks, output_format) but omitted "operation", raising a
bare KeyError('operation') and burning the stage's entire turn budget.
_full_mix's own docstring explicitly states it is "the preferred operation for
the compose-director skill" — the only one of audio_mixer's five operations
with a stated default use case. Default to it instead of requiring the agent
spell out the one documented-preferred operation.
Tests: a missing operation now routes to _full_mix() and fails on ITS OWN
precondition ("No tracks provided for full_mix"), never on operation; an
explicit unknown operation is still rejected (76 server tests + 6 engine
tests total across the three operation-default fixes).
Affected Files:
* tools/audio/audio_mixer.py
* tests/tools/test_audio_mixer_operation_default.py (new)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds a complete web production interface on top of the existing OpenMontage pipeline engine, taking the project from a CLI/script-only tool to a full-stack application with real-time AI visibility and human-in-the-loop approval gates.
What changed
FastAPI sidecar (
server/)app/main.py— FastAPI entrypoint with CORS, media static files served at/media/(generated videos/images playable in browser)app/store.py— thread-safe in-memory JobStore with asyncio approval gates and cost trackingapp/runner/stage_runner.py— 8-stage cinematic pipeline driver usingasyncio.to_thread()to prevent blocking; Brand Kit injection per stage; tool cost accumulation withcost_updatedSSE eventsapp/runner/tool_bridge.py— bridge between agent tool calls and existing PythonBaseToolsubclasses; cost accumulator for real-time CNY cost trackingapp/routers/jobs.py— 7 endpoints: create/list/get job, approve stage, save artifact, retry failed jobapp/routers/events.py— SSE stream with seq-based reconnect (lastEventIdreplay)app/routers/brands.py— Brand Kit CRUD with JSON file store (brand_kits/<slug>/kit.json)app/routers/health.py— liveness checkNext.js 16 App Router (
web/)/login— passphrase gate with session cookie/dashboard— real-time job list (8s polling), status badges, brand name/dashboard/new— content type picker + Brand Kit quick-apply chips + wizard form/dashboard/jobs/[jobId]— Stage Stepper (8 stages + progress bar), live SSE event log, approval panel with inline JSON editing, retry button for failed jobs, live cost display (¥N.NNNN)/dashboard/brands— full Brand Kit CRUD (create / edit / delete with color swatches)/dashboard/settings— system health check, current tech stack, M5-3 evolution roadmapKey bug fixes
max_tokens=4096truncated agent tool-call arguments causing stuck jobs → raised to 8192 with structured error feedback to agentasyncioevent loop → wrapped withasyncio.to_thread()KeyErrorinexecute_toolsilently crashed background tasks → added try/except with error event emissionE2E validation
Full 8-stage cinematic pipeline tested end-to-end: research → proposal (approval gate) → script (approval gate) → scene_plan → assets (real
maas_videoMP4s +maas_imagePNG) → edit → compose. All stages confirmed working with MaaS backend.Test plan
cd server && PYTHONPATH=$(pwd)/../:$(pwd) python -m uvicorn app.main:app --port 8000— server starts,/healthreturns{"status":"ok"}cd web && npm run build— builds without TypeScript errors/dashboard/new, watch Stage Stepper advance in real-time/media/)/dashboard/settingsshows server online + evolution roadmap🤖 Generated with Claude Code