@lemoncat7/dsh-partner 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/docs/THIRD_PARTY_LICENSES.md +13 -0
- package/docs/attachment-storage.md +18 -0
- package/docs/director-skills.md +24 -0
- package/docs/long-task-continuation.md +1 -1
- package/docs/releases/2.6.0.md +11 -0
- package/docs/releases/2.7.0.md +21 -0
- package/lib/agent-runtime.d.ts +3 -1
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +35 -11
- package/lib/agent-runtime.js.map +1 -1
- package/lib/api/features/attachments-api.d.ts.map +1 -1
- package/lib/api/features/attachments-api.js +31 -1
- package/lib/api/features/attachments-api.js.map +1 -1
- package/lib/api.d.ts +1 -1
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/attachments/grouped-storage.d.ts +5 -0
- package/lib/attachments/grouped-storage.d.ts.map +1 -0
- package/lib/attachments/grouped-storage.js +47 -0
- package/lib/attachments/grouped-storage.js.map +1 -0
- package/lib/attachments/service.d.ts +17 -0
- package/lib/attachments/service.d.ts.map +1 -1
- package/lib/attachments/service.js +100 -8
- package/lib/attachments/service.js.map +1 -1
- package/lib/attachments/shared-storage.d.ts +7 -0
- package/lib/attachments/shared-storage.d.ts.map +1 -0
- package/lib/attachments/shared-storage.js +44 -0
- package/lib/attachments/shared-storage.js.map +1 -0
- package/lib/attachments/storage-admin.d.ts +6 -0
- package/lib/attachments/storage-admin.d.ts.map +1 -0
- package/lib/attachments/storage-admin.js +43 -0
- package/lib/attachments/storage-admin.js.map +1 -0
- package/lib/attachments/storage-types.d.ts +33 -0
- package/lib/attachments/storage-types.d.ts.map +1 -0
- package/lib/attachments/storage-types.js +2 -0
- package/lib/attachments/storage-types.js.map +1 -0
- package/lib/attachments/tool.d.ts.map +1 -1
- package/lib/attachments/tool.js +16 -5
- package/lib/attachments/tool.js.map +1 -1
- package/lib/channels/delivery-policy.d.ts.map +1 -1
- package/lib/channels/delivery-policy.js +6 -4
- package/lib/channels/delivery-policy.js.map +1 -1
- package/lib/channels/direct/message-format.js +418 -281
- package/lib/channels/direct/message-format.js.map +4 -4
- package/lib/channels/manager.d.ts +6 -3
- package/lib/channels/manager.d.ts.map +1 -1
- package/lib/channels/manager.js +64 -44
- package/lib/channels/manager.js.map +1 -1
- package/lib/channels/weixin/api.d.ts.map +1 -1
- package/lib/channels/weixin/api.js +7 -2
- package/lib/channels/weixin/api.js.map +1 -1
- package/lib/channels/weixin/errors.d.ts +14 -0
- package/lib/channels/weixin/errors.d.ts.map +1 -0
- package/lib/channels/weixin/errors.js +29 -0
- package/lib/channels/weixin/errors.js.map +1 -0
- package/lib/channels/weixin/poll.d.ts +14 -0
- package/lib/channels/weixin/poll.d.ts.map +1 -0
- package/lib/channels/weixin/poll.js +50 -0
- package/lib/channels/weixin/poll.js.map +1 -0
- package/lib/client-controller.d.ts.map +1 -1
- package/lib/client-controller.js +3 -3
- package/lib/client-controller.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +996 -645
- package/lib/client.js.map +4 -4
- package/lib/collaboration/composition.d.ts.map +1 -1
- package/lib/collaboration/composition.js +16 -6
- package/lib/collaboration/composition.js.map +1 -1
- package/lib/collaboration/domain.d.ts +6 -0
- package/lib/collaboration/domain.d.ts.map +1 -1
- package/lib/collaboration/domain.js.map +1 -1
- package/lib/collaboration/service.d.ts.map +1 -1
- package/lib/collaboration/service.js +33 -3
- package/lib/collaboration/service.js.map +1 -1
- package/lib/concern-notification.d.ts.map +1 -1
- package/lib/concern-notification.js +2 -1
- package/lib/concern-notification.js.map +1 -1
- package/lib/concern-tool.js +1 -1
- package/lib/concern-tool.js.map +1 -1
- package/lib/execution/reply-progress.d.ts.map +1 -1
- package/lib/execution/reply-progress.js +4 -2
- package/lib/execution/reply-progress.js.map +1 -1
- package/lib/execution/service.d.ts +1 -1
- package/lib/execution/service.d.ts.map +1 -1
- package/lib/execution/service.js +4 -7
- package/lib/execution/service.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/memory-reflection.js +2 -2
- package/lib/memory-reflection.js.map +1 -1
- package/lib/message-source.d.ts +25 -0
- package/lib/message-source.d.ts.map +1 -0
- package/lib/message-source.js +8 -0
- package/lib/message-source.js.map +1 -0
- package/lib/observation-loop.js +4 -4
- package/lib/observation-loop.js.map +1 -1
- package/lib/pendant/reader.js +1 -1
- package/lib/pendant/reader.js.map +1 -1
- package/lib/pendant/settings-panel.js +1 -1
- package/lib/pendant/settings-panel.js.map +1 -1
- package/lib/persona/service.js +1 -1
- package/lib/persona/service.js.map +1 -1
- package/lib/requirements/attachments.d.ts +7 -0
- package/lib/requirements/attachments.d.ts.map +1 -0
- package/lib/requirements/attachments.js +26 -0
- package/lib/requirements/attachments.js.map +1 -0
- package/lib/requirements/domain.d.ts +1 -1
- package/lib/requirements/domain.d.ts.map +1 -1
- package/lib/requirements/service.d.ts.map +1 -1
- package/lib/requirements/service.js +4 -1
- package/lib/requirements/service.js.map +1 -1
- package/lib/scheduler/channel-origin.d.ts +7 -0
- package/lib/scheduler/channel-origin.d.ts.map +1 -0
- package/lib/scheduler/channel-origin.js +27 -0
- package/lib/scheduler/channel-origin.js.map +1 -0
- package/lib/scheduler/continuations.d.ts +2 -2
- package/lib/scheduler/continuations.d.ts.map +1 -1
- package/lib/scheduler/continuations.js +28 -8
- package/lib/scheduler/continuations.js.map +1 -1
- package/lib/scheduler/domain.d.ts +13 -0
- package/lib/scheduler/domain.d.ts.map +1 -1
- package/lib/scheduler/final-reply.d.ts +13 -0
- package/lib/scheduler/final-reply.d.ts.map +1 -0
- package/lib/scheduler/final-reply.js +21 -0
- package/lib/scheduler/final-reply.js.map +1 -0
- package/lib/skills/builtin-resources.d.ts +6 -0
- package/lib/skills/builtin-resources.d.ts.map +1 -0
- package/lib/skills/builtin-resources.js +47 -0
- package/lib/skills/builtin-resources.js.map +1 -0
- package/lib/skills/builtin.d.ts +2 -5
- package/lib/skills/builtin.d.ts.map +1 -1
- package/lib/skills/builtin.js +6 -0
- package/lib/skills/builtin.js.map +1 -1
- package/lib/skills/director-builtins.d.ts +8 -0
- package/lib/skills/director-builtins.d.ts.map +1 -0
- package/lib/skills/director-builtins.js +65 -0
- package/lib/skills/director-builtins.js.map +1 -0
- package/lib/skills/drama-builtin.d.ts +3 -0
- package/lib/skills/drama-builtin.d.ts.map +1 -0
- package/lib/skills/drama-builtin.js +49 -0
- package/lib/skills/drama-builtin.js.map +1 -0
- package/lib/skills/long-task-continuation.d.ts +2 -2
- package/lib/skills/long-task-continuation.d.ts.map +1 -1
- package/lib/skills/long-task-continuation.js +2 -2
- package/lib/skills/service.d.ts.map +1 -1
- package/lib/skills/service.js +8 -3
- package/lib/skills/service.js.map +1 -1
- package/lib/skills/viora-canvas-builtin.d.ts +3 -0
- package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
- package/lib/skills/viora-canvas-builtin.js +19 -0
- package/lib/skills/viora-canvas-builtin.js.map +1 -0
- package/lib/storage/migrate-layout.d.ts.map +1 -1
- package/lib/storage/migrate-layout.js +13 -4
- package/lib/storage/migrate-layout.js.map +1 -1
- package/lib/tasks/context.js +1 -0
- package/lib/tasks/context.js.map +1 -1
- package/lib/tasks/continuation.d.ts.map +1 -1
- package/lib/tasks/continuation.js +3 -0
- package/lib/tasks/continuation.js.map +1 -1
- package/lib/tasks/domain.d.ts +2 -0
- package/lib/tasks/domain.d.ts.map +1 -1
- package/lib/tasks/service.d.ts +2 -1
- package/lib/tasks/service.d.ts.map +1 -1
- package/lib/tasks/service.js +15 -1
- package/lib/tasks/service.js.map +1 -1
- package/lib/ui/attachment-storage.d.ts +2 -0
- package/lib/ui/attachment-storage.d.ts.map +1 -0
- package/lib/ui/attachment-storage.js +59 -0
- package/lib/ui/attachment-storage.js.map +1 -0
- package/lib/ui/capability-editor.js +1 -1
- package/lib/ui/capability-editor.js.map +1 -1
- package/lib/ui/companion-access-panel.js +1 -1
- package/lib/ui/companion-access-panel.js.map +1 -1
- package/lib/ui/companion-create.js +1 -1
- package/lib/ui/companion-create.js.map +1 -1
- package/lib/ui/companion-picker.js +1 -1
- package/lib/ui/companion-picker.js.map +1 -1
- package/lib/ui/identity-editor.js +1 -1
- package/lib/ui/identity-editor.js.map +1 -1
- package/lib/ui/mcp-capabilities.js +1 -1
- package/lib/ui/mcp-capabilities.js.map +1 -1
- package/lib/ui/mcp-panel.js +1 -1
- package/lib/ui/mcp-panel.js.map +1 -1
- package/lib/ui/memory/concern-board.js +2 -2
- package/lib/ui/memory/concern-board.js.map +1 -1
- package/lib/ui/memory/concern-deletion.d.ts +1 -1
- package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
- package/lib/ui/memory/concern-deletion.js +9 -4
- package/lib/ui/memory/concern-deletion.js.map +1 -1
- package/lib/ui/memory/concern-panel.js +1 -1
- package/lib/ui/memory/concern-panel.js.map +1 -1
- package/lib/ui/memory/memory-library.js +1 -1
- package/lib/ui/memory/memory-library.js.map +1 -1
- package/lib/ui/memory/memory-panel.js +1 -1
- package/lib/ui/memory/memory-panel.js.map +1 -1
- package/lib/ui/partner-components.js +1 -1
- package/lib/ui/partner-components.js.map +1 -1
- package/lib/ui/requirement-board.js +1 -1
- package/lib/ui/requirement-board.js.map +1 -1
- package/lib/ui/requirement-dialog.js +1 -1
- package/lib/ui/requirement-dialog.js.map +1 -1
- package/lib/ui/requirement-tasks-panel.js +1 -1
- package/lib/ui/requirement-tasks-panel.js.map +1 -1
- package/lib/ui/schedule-panel.js +1 -1
- package/lib/ui/schedule-panel.js.map +1 -1
- package/lib/ui/skills-panel.js +1 -1
- package/lib/ui/skills-panel.js.map +1 -1
- package/lib/ui/storage-settings.d.ts.map +1 -1
- package/lib/ui/storage-settings.js +2 -1
- package/lib/ui/storage-settings.js.map +1 -1
- package/lib/ui/task-board-stage.js +1 -1
- package/lib/ui/task-board-stage.js.map +1 -1
- package/lib/ui/workspace-components.js +1 -1
- package/lib/ui/workspace-components.js.map +1 -1
- package/lib/ui/workspace-navigation.d.ts +16 -16
- package/lib/ui/workspace-navigation.js +1 -1
- package/lib/ui/workspace-navigation.js.map +1 -1
- package/package.json +53 -41
- package/resources/skills/cinematic-director/manifest.json +205 -0
- package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
- package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
- package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
- package/resources/skills/cinematic-director/upstream/README.md +507 -0
- package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
- package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
- package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
- package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
- package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
- package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
- package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
- package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
- package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
- package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
- package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
- package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
- package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
- package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
- package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
- package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
- package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
- package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
- package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
- package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
- package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
- package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
- package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
- package/resources/skills/director-skills/manifest.json +541 -0
- package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
- package/resources/skills/director-skills/upstream/LICENSE +21 -0
- package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
- package/resources/skills/director-skills/upstream/README.md +251 -0
- package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
- package/resources/skills/director-skills/upstream/SKILL.md +114 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
- package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
- package/resources/skills/drama-production/manifest.json +933 -0
- package/resources/skills/drama-production/upstream/LICENSE +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
- package/resources/skills/viora-canvas-production/manifest.json +23 -0
- package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
- package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
- package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
- package/resources/skills/viora-canvas-production/references/production-ledger.md +25 -0
|
@@ -0,0 +1,2205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare and execute explicitly confirmed short-drama media jobs.
|
|
3
|
+
|
|
4
|
+
A configured adapter receives one bounded JSON job on stdin and returns local
|
|
5
|
+
output files on stdout. The adapter is launched without a shell and only after
|
|
6
|
+
a confirmation bound to the exact job and current project inputs. Optional
|
|
7
|
+
provider adapters can ship with this skill, but credentials and adapter config
|
|
8
|
+
remain outside creator projects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import contextlib
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Iterator, Mapping
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
from typing import Any, BinaryIO
|
|
28
|
+
|
|
29
|
+
MINIMUM_PYTHON = (3, 9)
|
|
30
|
+
if sys.version_info < MINIMUM_PYTHON:
|
|
31
|
+
raise SystemExit(
|
|
32
|
+
"short-drama-produce needs Python {}.{} or newer".format(*MINIMUM_PYTHON)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
PROJECT_FILE = "short-drama.json"
|
|
36
|
+
PRODUCTION_ROOT = Path(".short-drama/production")
|
|
37
|
+
JOB_SCHEMA = "1.0"
|
|
38
|
+
JOB_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}")
|
|
39
|
+
MAX_JOB_BYTES = 256 * 1024
|
|
40
|
+
MAX_RUN_RECORD_BYTES = 256 * 1024
|
|
41
|
+
MAX_ADAPTER_RESPONSE_BYTES = 1024 * 1024
|
|
42
|
+
MAX_OUTPUT_BYTES = 512 * 1024 * 1024
|
|
43
|
+
MAX_INPUT_BYTES = 50 * 1024 * 1024
|
|
44
|
+
MAX_TOTAL_INPUT_BYTES = 200 * 1024 * 1024
|
|
45
|
+
MAX_TIMEOUT_SECONDS = 3600
|
|
46
|
+
PUBLIC_ERROR_CATEGORIES = {
|
|
47
|
+
"authentication",
|
|
48
|
+
"configuration",
|
|
49
|
+
"contract",
|
|
50
|
+
"invalid_request",
|
|
51
|
+
"network",
|
|
52
|
+
"permission",
|
|
53
|
+
"provider_response",
|
|
54
|
+
"rate_limit",
|
|
55
|
+
"server",
|
|
56
|
+
"timeout",
|
|
57
|
+
}
|
|
58
|
+
PUBLIC_ERROR_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,199}")
|
|
59
|
+
ALLOWED_JOB_KEYS = {
|
|
60
|
+
"schema_version",
|
|
61
|
+
"job_id",
|
|
62
|
+
"modality",
|
|
63
|
+
"adapter",
|
|
64
|
+
"prompt",
|
|
65
|
+
"source",
|
|
66
|
+
"source_entry",
|
|
67
|
+
"references",
|
|
68
|
+
"reference_bindings",
|
|
69
|
+
"outputs",
|
|
70
|
+
"parameters",
|
|
71
|
+
"overwrite",
|
|
72
|
+
}
|
|
73
|
+
STORED_EXECUTION_KEYS = ALLOWED_JOB_KEYS | {"inputs"}
|
|
74
|
+
STORED_JOB_KEYS = STORED_EXECUTION_KEYS | {"fingerprint", "prepared_at"}
|
|
75
|
+
LEGACY_STORED_EXECUTION_KEYS = STORED_EXECUTION_KEYS - {
|
|
76
|
+
"source_entry",
|
|
77
|
+
"reference_bindings",
|
|
78
|
+
}
|
|
79
|
+
LEGACY_STORED_JOB_KEYS = LEGACY_STORED_EXECUTION_KEYS | {
|
|
80
|
+
"fingerprint",
|
|
81
|
+
"prepared_at",
|
|
82
|
+
}
|
|
83
|
+
SECRET_KEYS = {
|
|
84
|
+
"authorization",
|
|
85
|
+
"credential",
|
|
86
|
+
"credentials",
|
|
87
|
+
"password",
|
|
88
|
+
"secret",
|
|
89
|
+
"token",
|
|
90
|
+
"access_token",
|
|
91
|
+
"api_key",
|
|
92
|
+
"apikey",
|
|
93
|
+
}
|
|
94
|
+
MEDIA_EXTENSIONS = {
|
|
95
|
+
"image": {".png", ".jpg", ".jpeg", ".webp"},
|
|
96
|
+
"video": {".mp4", ".mov", ".webm"},
|
|
97
|
+
"tts": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
|
|
98
|
+
"music": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
|
|
99
|
+
}
|
|
100
|
+
MEDIA_TYPES = {
|
|
101
|
+
".png": "image/png",
|
|
102
|
+
".jpg": "image/jpeg",
|
|
103
|
+
".jpeg": "image/jpeg",
|
|
104
|
+
".webp": "image/webp",
|
|
105
|
+
".mp4": "video/mp4",
|
|
106
|
+
".mov": "video/quicktime",
|
|
107
|
+
".webm": "video/webm",
|
|
108
|
+
".wav": "audio/wav",
|
|
109
|
+
".mp3": "audio/mpeg",
|
|
110
|
+
".m4a": "audio/mp4",
|
|
111
|
+
".aac": "audio/aac",
|
|
112
|
+
".flac": "audio/flac",
|
|
113
|
+
".opus": "audio/ogg",
|
|
114
|
+
}
|
|
115
|
+
CREATOR_SOURCE_NAMES = {
|
|
116
|
+
"图片提示词.md": "image",
|
|
117
|
+
"视频提示词.md": "video",
|
|
118
|
+
# A shot's frozen keyframe is a producible image and lives nowhere else, so
|
|
119
|
+
# without this the start frame a video job wants to bind has no stage that
|
|
120
|
+
# can render it.
|
|
121
|
+
"分镜.md": "image",
|
|
122
|
+
}
|
|
123
|
+
# Which H2 IDs a creator source may select, and where that entry keeps its
|
|
124
|
+
# copyable body.
|
|
125
|
+
CREATOR_SOURCE_ENTRIES = {
|
|
126
|
+
"图片提示词.md": ("IMG-", r"可复制(?:通用)?提示词", "参考"),
|
|
127
|
+
"视频提示词.md": ("MOTION-", r"可复制(?:通用)?提示词", "输入参考图"),
|
|
128
|
+
"分镜.md": ("SHOT-", r"冻结关键帧提示词", "输入参考图"),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
REF_SLOT_RE = re.compile(r"REF-[A-Z0-9][A-Z0-9-]{0,79}")
|
|
132
|
+
# A slot head, not a bare substring: a real reference file may legitimately be
|
|
133
|
+
# named `plan-sheet.png`, and that is not a creator-supplied declaration.
|
|
134
|
+
PLAN_SLOT_HEAD_RE = re.compile(r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:")
|
|
135
|
+
PLAN_SLOT_RE = re.compile(
|
|
136
|
+
r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:[1-9]\d*)· "
|
|
137
|
+
r"(?:IMG|SHOT)-[A-Za-z0-9-]+《[^》\n]+》([^)\n]*)"
|
|
138
|
+
)
|
|
139
|
+
SOURCE_ENTRY_RE = re.compile(r"[A-Z][A-Z0-9-]{1,99}")
|
|
140
|
+
REFERENCE_SUFFIX_RE = r"(?:png|jpe?g|webp)"
|
|
141
|
+
# 用途 is the creator-facing question "what does this picture decide here"; the
|
|
142
|
+
# job's own `role` is its production translation. The segment is optional so a
|
|
143
|
+
# declaration written before that field existed still prepares, and so the
|
|
144
|
+
# creator-side diagnostic stays with creator_markdown_check.py.
|
|
145
|
+
REFERENCE_LINE_RE = re.compile(
|
|
146
|
+
rf"(REF-[A-Z0-9][A-Z0-9-]{{0,79}})(顺序:([1-9]\d*))· "
|
|
147
|
+
rf"([^;\n]+?\.{REFERENCE_SUFFIX_RE})《([^》\n]+)》"
|
|
148
|
+
r"((?:用途:[^;)\n]+;)?控制:([^;)\n]+);不得控制:([^)\n]+))",
|
|
149
|
+
re.IGNORECASE,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ConfirmationRequiredError(RuntimeError):
|
|
154
|
+
"""The exact current job has not been explicitly confirmed."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AdapterError(RuntimeError):
|
|
158
|
+
"""A configured media adapter failed or broke its output contract."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self, message: str, *, public_error: Mapping[str, Any] | None = None
|
|
162
|
+
) -> None:
|
|
163
|
+
super().__init__(message)
|
|
164
|
+
self.public_error = dict(public_error) if public_error is not None else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def utc_now() -> str:
|
|
168
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def sha256_bytes(content: bytes) -> str:
|
|
172
|
+
return hashlib.sha256(content).hexdigest()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def find_project(start: Path) -> Path:
|
|
176
|
+
candidate = start.expanduser().resolve()
|
|
177
|
+
if candidate.is_file():
|
|
178
|
+
candidate = candidate.parent
|
|
179
|
+
for directory in (candidate, *candidate.parents):
|
|
180
|
+
if (directory / PROJECT_FILE).is_file():
|
|
181
|
+
return directory
|
|
182
|
+
raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _relative_path(value: object, *, output: bool = False) -> str:
|
|
186
|
+
if not isinstance(value, str):
|
|
187
|
+
raise ValueError("project paths must be strings")
|
|
188
|
+
raw = value.replace("\\", "/")
|
|
189
|
+
pure = PurePosixPath(raw)
|
|
190
|
+
if not raw or pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
|
|
191
|
+
raise ValueError(f"unsafe project-relative path: {value}")
|
|
192
|
+
if pure.parts[0].casefold() == ".short-drama" or pure.name.casefold() == PROJECT_FILE:
|
|
193
|
+
raise ValueError(f"operational project path is not allowed: {value}")
|
|
194
|
+
if output:
|
|
195
|
+
parts = pure.parts
|
|
196
|
+
top_level_production = len(parts) >= 2 and parts[0].casefold() == "production"
|
|
197
|
+
episode_production = (
|
|
198
|
+
len(parts) >= 4
|
|
199
|
+
and parts[0] in {"剧集", "episodes"}
|
|
200
|
+
and re.fullmatch(r"EP\d{3,}", parts[1], re.IGNORECASE) is not None
|
|
201
|
+
and parts[2] in {"制作成果", "production"}
|
|
202
|
+
)
|
|
203
|
+
if not top_level_production and not episode_production:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"media outputs must use top-level production/ or "
|
|
206
|
+
"剧集|episodes/<EP>/制作成果|production/"
|
|
207
|
+
)
|
|
208
|
+
return pure.as_posix()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _project_file(root: Path, relative: str, *, create_parent: bool = False) -> Path:
|
|
212
|
+
target = root / relative
|
|
213
|
+
current = root
|
|
214
|
+
for part in PurePosixPath(relative).parts[:-1]:
|
|
215
|
+
current /= part
|
|
216
|
+
if current.exists() and (current.is_symlink() or not current.is_dir()):
|
|
217
|
+
raise ValueError(f"unsafe project directory: {part}")
|
|
218
|
+
if create_parent and not current.exists():
|
|
219
|
+
current.mkdir()
|
|
220
|
+
if target.exists() and (target.is_symlink() or not target.is_file()):
|
|
221
|
+
raise ValueError(f"unsafe project file: {relative}")
|
|
222
|
+
if not target.parent.resolve().is_relative_to(root):
|
|
223
|
+
raise ValueError(f"path escapes project root: {relative}")
|
|
224
|
+
return target
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _is_link_or_reparse(details: os.stat_result) -> bool:
|
|
228
|
+
attributes = getattr(details, "st_file_attributes", 0)
|
|
229
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
230
|
+
return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@contextlib.contextmanager
|
|
234
|
+
def _open_project_input(root: Path, relative: str) -> Iterator[BinaryIO]:
|
|
235
|
+
"""Open one regular project input without following path components on POSIX."""
|
|
236
|
+
parts = PurePosixPath(relative).parts
|
|
237
|
+
if not parts:
|
|
238
|
+
raise ValueError("job input path is empty")
|
|
239
|
+
if os.name != "nt" and os.open in os.supports_dir_fd:
|
|
240
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
241
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
242
|
+
directory_fd = os.open(root, directory_flags)
|
|
243
|
+
file_fd: int | None = None
|
|
244
|
+
try:
|
|
245
|
+
for part in parts[:-1]:
|
|
246
|
+
next_fd = os.open(
|
|
247
|
+
part,
|
|
248
|
+
directory_flags | nofollow,
|
|
249
|
+
dir_fd=directory_fd,
|
|
250
|
+
)
|
|
251
|
+
os.close(directory_fd)
|
|
252
|
+
directory_fd = next_fd
|
|
253
|
+
file_fd = os.open(
|
|
254
|
+
parts[-1], os.O_RDONLY | nofollow, dir_fd=directory_fd
|
|
255
|
+
)
|
|
256
|
+
details = os.fstat(file_fd)
|
|
257
|
+
if not stat.S_ISREG(details.st_mode):
|
|
258
|
+
raise ValueError(f"job input is not a regular file: {relative}")
|
|
259
|
+
with os.fdopen(file_fd, "rb", closefd=True) as handle:
|
|
260
|
+
file_fd = None
|
|
261
|
+
yield handle
|
|
262
|
+
except FileNotFoundError as exc:
|
|
263
|
+
raise FileNotFoundError(f"job input is missing: {relative}") from exc
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
raise ValueError(f"unsafe job input path: {relative}") from exc
|
|
266
|
+
finally:
|
|
267
|
+
if file_fd is not None:
|
|
268
|
+
os.close(file_fd)
|
|
269
|
+
os.close(directory_fd)
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Windows lacks portable openat/O_NOFOLLOW support. Reject reparse/symlink
|
|
273
|
+
# components, pin the final file handle, and verify its identity before use.
|
|
274
|
+
path = root
|
|
275
|
+
for part in parts:
|
|
276
|
+
path /= part
|
|
277
|
+
try:
|
|
278
|
+
details = path.lstat()
|
|
279
|
+
except FileNotFoundError as exc:
|
|
280
|
+
raise FileNotFoundError(f"job input is missing: {relative}") from exc
|
|
281
|
+
if _is_link_or_reparse(details):
|
|
282
|
+
raise ValueError(f"unsafe job input path: {relative}")
|
|
283
|
+
if not path.resolve().is_relative_to(root):
|
|
284
|
+
raise ValueError(f"job input escapes project root: {relative}")
|
|
285
|
+
before = path.stat(follow_symlinks=False)
|
|
286
|
+
if not stat.S_ISREG(before.st_mode):
|
|
287
|
+
raise ValueError(f"job input is not a regular file: {relative}")
|
|
288
|
+
with path.open("rb") as handle:
|
|
289
|
+
opened = os.fstat(handle.fileno())
|
|
290
|
+
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
291
|
+
raise ValueError(f"job input changed while opening: {relative}")
|
|
292
|
+
yield handle
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _hash_project_file(root: Path, relative: str) -> str:
|
|
296
|
+
digest = hashlib.sha256()
|
|
297
|
+
size = 0
|
|
298
|
+
with _open_project_input(root, relative) as handle:
|
|
299
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
300
|
+
size += len(chunk)
|
|
301
|
+
if size > MAX_INPUT_BYTES:
|
|
302
|
+
raise ValueError(f"job input exceeds the size limit: {relative}")
|
|
303
|
+
digest.update(chunk)
|
|
304
|
+
return digest.hexdigest()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _snapshot_inputs(
|
|
308
|
+
root: Path, job: Mapping[str, Any], snapshot_root: Path
|
|
309
|
+
) -> None:
|
|
310
|
+
inputs = job.get("inputs")
|
|
311
|
+
if not isinstance(inputs, Mapping):
|
|
312
|
+
raise ConfirmationRequiredError("stored job inputs are invalid")
|
|
313
|
+
total = 0
|
|
314
|
+
for relative_value, expected_value in inputs.items():
|
|
315
|
+
relative = _relative_path(relative_value)
|
|
316
|
+
if not isinstance(expected_value, str) or re.fullmatch(
|
|
317
|
+
r"[0-9a-f]{64}", expected_value
|
|
318
|
+
) is None:
|
|
319
|
+
raise ConfirmationRequiredError("stored job input hash is invalid")
|
|
320
|
+
target = snapshot_root.joinpath(*PurePosixPath(relative).parts)
|
|
321
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
digest = hashlib.sha256()
|
|
323
|
+
size = 0
|
|
324
|
+
try:
|
|
325
|
+
with _open_project_input(root, relative) as incoming, target.open(
|
|
326
|
+
"xb"
|
|
327
|
+
) as outgoing:
|
|
328
|
+
for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
|
|
329
|
+
size += len(chunk)
|
|
330
|
+
total += len(chunk)
|
|
331
|
+
if size > MAX_INPUT_BYTES or total > MAX_TOTAL_INPUT_BYTES:
|
|
332
|
+
raise ConfirmationRequiredError(
|
|
333
|
+
"job inputs exceed the production size limit"
|
|
334
|
+
)
|
|
335
|
+
digest.update(chunk)
|
|
336
|
+
outgoing.write(chunk)
|
|
337
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
338
|
+
raise ConfirmationRequiredError(
|
|
339
|
+
"job inputs changed; prepare and confirm again"
|
|
340
|
+
) from exc
|
|
341
|
+
if digest.hexdigest() != expected_value:
|
|
342
|
+
raise ConfirmationRequiredError(
|
|
343
|
+
"job inputs changed; prepare and confirm again"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _canonical(document: object) -> bytes:
|
|
348
|
+
return json.dumps(
|
|
349
|
+
document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
350
|
+
).encode("utf-8")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _check_metadata_parts(parts: tuple[str, ...]) -> None:
|
|
354
|
+
if any(
|
|
355
|
+
not part
|
|
356
|
+
or part in {".", ".."}
|
|
357
|
+
or "/" in part
|
|
358
|
+
or "\\" in part
|
|
359
|
+
for part in parts
|
|
360
|
+
):
|
|
361
|
+
raise ValueError("production metadata path is invalid")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@contextlib.contextmanager
|
|
365
|
+
def _metadata_directory(
|
|
366
|
+
root: Path, parts: tuple[str, ...], *, create: bool
|
|
367
|
+
) -> Iterator[tuple[Path, int | None]]:
|
|
368
|
+
"""Pin one metadata directory without following project-controlled parents."""
|
|
369
|
+
root = root.resolve()
|
|
370
|
+
_check_metadata_parts(parts)
|
|
371
|
+
all_parts = (".short-drama", "production", *parts)
|
|
372
|
+
directory = root.joinpath(*all_parts)
|
|
373
|
+
if os.name != "nt" and os.open in os.supports_dir_fd:
|
|
374
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
375
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
376
|
+
directory_fd = os.open(root, flags)
|
|
377
|
+
try:
|
|
378
|
+
try:
|
|
379
|
+
for part in all_parts:
|
|
380
|
+
try:
|
|
381
|
+
next_fd = os.open(
|
|
382
|
+
part, flags | nofollow, dir_fd=directory_fd
|
|
383
|
+
)
|
|
384
|
+
except FileNotFoundError:
|
|
385
|
+
if not create:
|
|
386
|
+
raise
|
|
387
|
+
try:
|
|
388
|
+
os.mkdir(part, mode=0o700, dir_fd=directory_fd)
|
|
389
|
+
except FileExistsError:
|
|
390
|
+
pass
|
|
391
|
+
next_fd = os.open(
|
|
392
|
+
part, flags | nofollow, dir_fd=directory_fd
|
|
393
|
+
)
|
|
394
|
+
details = os.fstat(next_fd)
|
|
395
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
396
|
+
os.close(next_fd)
|
|
397
|
+
raise ValueError("production metadata directory is unsafe")
|
|
398
|
+
os.close(directory_fd)
|
|
399
|
+
directory_fd = next_fd
|
|
400
|
+
except FileNotFoundError:
|
|
401
|
+
raise
|
|
402
|
+
except OSError as exc:
|
|
403
|
+
raise ValueError("production metadata directory is unsafe") from exc
|
|
404
|
+
yield directory, directory_fd
|
|
405
|
+
finally:
|
|
406
|
+
os.close(directory_fd)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
current = root
|
|
410
|
+
for part in all_parts:
|
|
411
|
+
current /= part
|
|
412
|
+
try:
|
|
413
|
+
details = current.lstat()
|
|
414
|
+
except FileNotFoundError:
|
|
415
|
+
if not create:
|
|
416
|
+
raise
|
|
417
|
+
try:
|
|
418
|
+
current.mkdir(mode=0o700)
|
|
419
|
+
except FileExistsError:
|
|
420
|
+
pass
|
|
421
|
+
details = current.lstat()
|
|
422
|
+
if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
|
|
423
|
+
raise ValueError("production metadata directory is unsafe")
|
|
424
|
+
if not current.resolve().is_relative_to(root):
|
|
425
|
+
raise ValueError("production metadata directory escapes the project")
|
|
426
|
+
yield current, None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _metadata_atomic_json(
|
|
430
|
+
root: Path, directory_parts: tuple[str, ...], name: str, document: Mapping[str, Any]
|
|
431
|
+
) -> None:
|
|
432
|
+
_check_metadata_parts((name,))
|
|
433
|
+
content = _canonical(document) + b"\n"
|
|
434
|
+
temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
|
|
435
|
+
with _metadata_directory(root, directory_parts, create=True) as (
|
|
436
|
+
directory,
|
|
437
|
+
directory_fd,
|
|
438
|
+
):
|
|
439
|
+
if directory_fd is not None:
|
|
440
|
+
descriptor = -1
|
|
441
|
+
try:
|
|
442
|
+
descriptor = os.open(
|
|
443
|
+
temporary_name,
|
|
444
|
+
os.O_WRONLY
|
|
445
|
+
| os.O_CREAT
|
|
446
|
+
| os.O_EXCL
|
|
447
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
448
|
+
0o600,
|
|
449
|
+
dir_fd=directory_fd,
|
|
450
|
+
)
|
|
451
|
+
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
|
452
|
+
descriptor = -1
|
|
453
|
+
handle.write(content)
|
|
454
|
+
handle.flush()
|
|
455
|
+
os.fsync(handle.fileno())
|
|
456
|
+
os.replace(
|
|
457
|
+
temporary_name,
|
|
458
|
+
name,
|
|
459
|
+
src_dir_fd=directory_fd,
|
|
460
|
+
dst_dir_fd=directory_fd,
|
|
461
|
+
)
|
|
462
|
+
os.fsync(directory_fd)
|
|
463
|
+
finally:
|
|
464
|
+
if descriptor >= 0:
|
|
465
|
+
os.close(descriptor)
|
|
466
|
+
try:
|
|
467
|
+
os.unlink(temporary_name, dir_fd=directory_fd)
|
|
468
|
+
except FileNotFoundError:
|
|
469
|
+
pass
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
before = directory.stat(follow_symlinks=False)
|
|
473
|
+
temporary = directory / temporary_name
|
|
474
|
+
try:
|
|
475
|
+
with temporary.open("xb") as handle:
|
|
476
|
+
opened = os.fstat(handle.fileno())
|
|
477
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
478
|
+
raise ValueError("production metadata file is unsafe")
|
|
479
|
+
handle.write(content)
|
|
480
|
+
handle.flush()
|
|
481
|
+
os.fsync(handle.fileno())
|
|
482
|
+
after = directory.lstat()
|
|
483
|
+
if (
|
|
484
|
+
_is_link_or_reparse(after)
|
|
485
|
+
or not stat.S_ISDIR(after.st_mode)
|
|
486
|
+
or (after.st_dev, after.st_ino) != (before.st_dev, before.st_ino)
|
|
487
|
+
):
|
|
488
|
+
raise ValueError("production metadata directory changed")
|
|
489
|
+
os.replace(temporary, directory / name)
|
|
490
|
+
finally:
|
|
491
|
+
try:
|
|
492
|
+
temporary.unlink()
|
|
493
|
+
except FileNotFoundError:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _metadata_read_json(
|
|
498
|
+
root: Path, directory_parts: tuple[str, ...], name: str, *, maximum: int
|
|
499
|
+
) -> object:
|
|
500
|
+
_check_metadata_parts((name,))
|
|
501
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
502
|
+
directory,
|
|
503
|
+
directory_fd,
|
|
504
|
+
):
|
|
505
|
+
if directory_fd is not None:
|
|
506
|
+
descriptor = os.open(
|
|
507
|
+
name,
|
|
508
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
|
509
|
+
dir_fd=directory_fd,
|
|
510
|
+
)
|
|
511
|
+
with os.fdopen(descriptor, "rb", closefd=True) as handle:
|
|
512
|
+
details = os.fstat(handle.fileno())
|
|
513
|
+
if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
|
|
514
|
+
raise ValueError("production metadata file is unsafe")
|
|
515
|
+
raw = handle.read(maximum + 1)
|
|
516
|
+
else:
|
|
517
|
+
path = directory / name
|
|
518
|
+
before = path.lstat()
|
|
519
|
+
if (
|
|
520
|
+
_is_link_or_reparse(before)
|
|
521
|
+
or not stat.S_ISREG(before.st_mode)
|
|
522
|
+
or before.st_size > maximum
|
|
523
|
+
):
|
|
524
|
+
raise ValueError("production metadata file is unsafe")
|
|
525
|
+
with path.open("rb") as handle:
|
|
526
|
+
opened = os.fstat(handle.fileno())
|
|
527
|
+
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
528
|
+
raise ValueError("production metadata file changed while opening")
|
|
529
|
+
raw = handle.read(maximum + 1)
|
|
530
|
+
if len(raw) > maximum:
|
|
531
|
+
raise ValueError("production metadata file is too large")
|
|
532
|
+
return json.loads(raw.decode("utf-8"))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _metadata_unlink(
|
|
536
|
+
root: Path, directory_parts: tuple[str, ...], name: str
|
|
537
|
+
) -> None:
|
|
538
|
+
_check_metadata_parts((name,))
|
|
539
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
540
|
+
directory,
|
|
541
|
+
directory_fd,
|
|
542
|
+
):
|
|
543
|
+
if directory_fd is not None:
|
|
544
|
+
os.unlink(name, dir_fd=directory_fd)
|
|
545
|
+
else:
|
|
546
|
+
path = directory / name
|
|
547
|
+
details = path.lstat()
|
|
548
|
+
if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
|
|
549
|
+
raise ValueError("production metadata file is unsafe")
|
|
550
|
+
path.unlink()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _metadata_json_names(root: Path, directory_parts: tuple[str, ...]) -> list[str]:
|
|
554
|
+
try:
|
|
555
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
556
|
+
directory,
|
|
557
|
+
directory_fd,
|
|
558
|
+
):
|
|
559
|
+
names = os.listdir(directory_fd if directory_fd is not None else directory)
|
|
560
|
+
except FileNotFoundError:
|
|
561
|
+
return []
|
|
562
|
+
return sorted(
|
|
563
|
+
name
|
|
564
|
+
for name in names
|
|
565
|
+
if isinstance(name, str)
|
|
566
|
+
and name.endswith(".json")
|
|
567
|
+
and "/" not in name
|
|
568
|
+
and "\\" not in name
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@contextlib.contextmanager
|
|
573
|
+
def _project_lock(root: Path) -> Iterator[None]:
|
|
574
|
+
with _metadata_directory(root, (), create=True) as (directory, directory_fd):
|
|
575
|
+
if directory_fd is not None:
|
|
576
|
+
descriptor = os.open(
|
|
577
|
+
"lock",
|
|
578
|
+
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
|
579
|
+
0o600,
|
|
580
|
+
dir_fd=directory_fd,
|
|
581
|
+
)
|
|
582
|
+
details = os.fstat(descriptor)
|
|
583
|
+
if not stat.S_ISREG(details.st_mode):
|
|
584
|
+
os.close(descriptor)
|
|
585
|
+
raise ValueError("production lock is unsafe")
|
|
586
|
+
handle_context = os.fdopen(descriptor, "a+b", closefd=True)
|
|
587
|
+
else:
|
|
588
|
+
lock_path = directory / "lock"
|
|
589
|
+
before: os.stat_result | None
|
|
590
|
+
try:
|
|
591
|
+
before = lock_path.lstat()
|
|
592
|
+
except FileNotFoundError:
|
|
593
|
+
before = None
|
|
594
|
+
else:
|
|
595
|
+
if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
|
|
596
|
+
raise ValueError("production lock is unsafe")
|
|
597
|
+
handle_context = lock_path.open("a+b")
|
|
598
|
+
opened = os.fstat(handle_context.fileno())
|
|
599
|
+
after = lock_path.lstat()
|
|
600
|
+
if (
|
|
601
|
+
not stat.S_ISREG(opened.st_mode)
|
|
602
|
+
or _is_link_or_reparse(after)
|
|
603
|
+
or not stat.S_ISREG(after.st_mode)
|
|
604
|
+
or (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino)
|
|
605
|
+
or (
|
|
606
|
+
before is not None
|
|
607
|
+
and (before.st_dev, before.st_ino)
|
|
608
|
+
!= (opened.st_dev, opened.st_ino)
|
|
609
|
+
)
|
|
610
|
+
):
|
|
611
|
+
handle_context.close()
|
|
612
|
+
raise ValueError("production lock changed while opening")
|
|
613
|
+
with handle_context as handle:
|
|
614
|
+
if os.name == "nt":
|
|
615
|
+
import msvcrt
|
|
616
|
+
|
|
617
|
+
handle.seek(0, os.SEEK_END)
|
|
618
|
+
if handle.tell() == 0:
|
|
619
|
+
handle.write(b"0")
|
|
620
|
+
handle.flush()
|
|
621
|
+
handle.seek(0)
|
|
622
|
+
locking = getattr(msvcrt, "locking")
|
|
623
|
+
lock = getattr(msvcrt, "LK_LOCK")
|
|
624
|
+
unlock = getattr(msvcrt, "LK_UNLCK")
|
|
625
|
+
locking(handle.fileno(), lock, 1)
|
|
626
|
+
try:
|
|
627
|
+
yield
|
|
628
|
+
finally:
|
|
629
|
+
handle.seek(0)
|
|
630
|
+
locking(handle.fileno(), unlock, 1)
|
|
631
|
+
else:
|
|
632
|
+
import fcntl
|
|
633
|
+
|
|
634
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
635
|
+
try:
|
|
636
|
+
yield
|
|
637
|
+
finally:
|
|
638
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _job_key(job_id: str) -> str:
|
|
642
|
+
return sha256_bytes(job_id.encode("utf-8"))[:24]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _job_path(root: Path, job_id: str) -> Path:
|
|
646
|
+
return root / PRODUCTION_ROOT / "jobs" / f"{_job_key(job_id)}.json"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _confirmation_path(root: Path, job_id: str) -> Path:
|
|
650
|
+
return root / PRODUCTION_ROOT / "confirmations" / f"{_job_key(job_id)}.json"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _run_directory(root: Path, job_id: str) -> Path:
|
|
654
|
+
return root / PRODUCTION_ROOT / "runs" / _job_key(job_id)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _contains_secret_key(value: object) -> bool:
|
|
658
|
+
if isinstance(value, Mapping):
|
|
659
|
+
return any(
|
|
660
|
+
str(key).casefold() in SECRET_KEYS or _contains_secret_key(child)
|
|
661
|
+
for key, child in value.items()
|
|
662
|
+
)
|
|
663
|
+
if isinstance(value, list):
|
|
664
|
+
return any(_contains_secret_key(child) for child in value)
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _string_list(value: object, *, label: str, limit: int = 32) -> list[str]:
|
|
669
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
670
|
+
raise ValueError(f"{label} must be a string list")
|
|
671
|
+
if len(value) > limit:
|
|
672
|
+
raise ValueError(f"{label} has too many entries")
|
|
673
|
+
return list(value)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _scope_list(value: object, *, label: str) -> list[str]:
|
|
677
|
+
items = _string_list(value, label=label, limit=32)
|
|
678
|
+
if not items or any(not item.strip() or len(item) > 200 for item in items):
|
|
679
|
+
raise ValueError(f"{label} must contain non-empty bounded text")
|
|
680
|
+
normalized = [item.strip() for item in items]
|
|
681
|
+
folded = [item.casefold() for item in normalized]
|
|
682
|
+
if len(folded) != len(set(folded)):
|
|
683
|
+
raise ValueError(f"{label} must not contain duplicates")
|
|
684
|
+
return normalized
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _canonical_creator_source_modality(source: str | None) -> str | None:
|
|
688
|
+
if source is None:
|
|
689
|
+
return None
|
|
690
|
+
path = PurePosixPath(source)
|
|
691
|
+
if (
|
|
692
|
+
len(path.parts) != 3
|
|
693
|
+
or path.parts[0] not in {"剧集", "episodes"}
|
|
694
|
+
or not path.parts[1]
|
|
695
|
+
):
|
|
696
|
+
return None
|
|
697
|
+
return CREATOR_SOURCE_NAMES.get(path.name)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _normalize_reference_bindings(value: object) -> list[dict[str, Any]]:
|
|
701
|
+
if value is None:
|
|
702
|
+
return []
|
|
703
|
+
if not isinstance(value, list) or len(value) > 16:
|
|
704
|
+
raise ValueError("reference_bindings must be a list of at most sixteen entries")
|
|
705
|
+
normalized: list[dict[str, Any]] = []
|
|
706
|
+
for index, binding in enumerate(value, 1):
|
|
707
|
+
label = f"reference_bindings[{index}]"
|
|
708
|
+
if not isinstance(binding, Mapping) or set(binding) != {
|
|
709
|
+
"slot_id",
|
|
710
|
+
"order",
|
|
711
|
+
"path",
|
|
712
|
+
"label",
|
|
713
|
+
"role",
|
|
714
|
+
"may_control",
|
|
715
|
+
"must_not_control",
|
|
716
|
+
}:
|
|
717
|
+
raise ValueError(f"{label} fields are invalid")
|
|
718
|
+
slot_id = binding.get("slot_id")
|
|
719
|
+
order = binding.get("order")
|
|
720
|
+
raw_label = binding.get("label")
|
|
721
|
+
role = binding.get("role")
|
|
722
|
+
if not isinstance(slot_id, str) or REF_SLOT_RE.fullmatch(slot_id) is None:
|
|
723
|
+
raise ValueError(f"{label} slot_id is invalid")
|
|
724
|
+
if not isinstance(order, int) or isinstance(order, bool) or order < 1:
|
|
725
|
+
raise ValueError(f"{label} order is invalid")
|
|
726
|
+
if (
|
|
727
|
+
not isinstance(raw_label, str)
|
|
728
|
+
or not raw_label.strip()
|
|
729
|
+
or len(raw_label) > 200
|
|
730
|
+
or re.search(r"[\u3400-\u9fff]", raw_label) is None
|
|
731
|
+
):
|
|
732
|
+
raise ValueError(f"{label} label must contain Chinese text")
|
|
733
|
+
if not isinstance(role, str) or not role.strip() or len(role) > 80:
|
|
734
|
+
raise ValueError(f"{label} role is invalid")
|
|
735
|
+
may_control = _scope_list(
|
|
736
|
+
binding.get("may_control"), label=f"{label}.may_control"
|
|
737
|
+
)
|
|
738
|
+
must_not_control = _scope_list(
|
|
739
|
+
binding.get("must_not_control"), label=f"{label}.must_not_control"
|
|
740
|
+
)
|
|
741
|
+
if {item.casefold() for item in may_control} & {
|
|
742
|
+
item.casefold() for item in must_not_control
|
|
743
|
+
}:
|
|
744
|
+
raise ValueError(f"{label} control scopes must not overlap")
|
|
745
|
+
normalized.append(
|
|
746
|
+
{
|
|
747
|
+
"slot_id": slot_id,
|
|
748
|
+
"order": order,
|
|
749
|
+
"path": _relative_path(binding.get("path")),
|
|
750
|
+
"label": raw_label.strip(),
|
|
751
|
+
"role": role.strip(),
|
|
752
|
+
"may_control": may_control,
|
|
753
|
+
"must_not_control": must_not_control,
|
|
754
|
+
}
|
|
755
|
+
)
|
|
756
|
+
normalized.sort(key=lambda item: int(item["order"]))
|
|
757
|
+
slots = [str(item["slot_id"]) for item in normalized]
|
|
758
|
+
orders = [int(item["order"]) for item in normalized]
|
|
759
|
+
paths = [str(item["path"]) for item in normalized]
|
|
760
|
+
if len(slots) != len(set(slots)):
|
|
761
|
+
raise ValueError("reference binding slot_ids must be unique")
|
|
762
|
+
if orders != list(range(1, len(orders) + 1)):
|
|
763
|
+
raise ValueError("reference binding order must be unique and contiguous from 1")
|
|
764
|
+
if len(paths) != len(set(paths)):
|
|
765
|
+
raise ValueError("reference binding paths must be unique")
|
|
766
|
+
return normalized
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _markdown_section(document: str, source_entry: str) -> str:
|
|
770
|
+
heading = re.compile(
|
|
771
|
+
rf"^##\s+`?{re.escape(source_entry)}`?(?:\s+·.*)?\s*$", re.MULTILINE
|
|
772
|
+
)
|
|
773
|
+
matches = list(heading.finditer(document))
|
|
774
|
+
if not matches:
|
|
775
|
+
raise ValueError(f"source entry is missing from Markdown: {source_entry}")
|
|
776
|
+
if len(matches) != 1:
|
|
777
|
+
raise ValueError(f"source entry is duplicated in Markdown: {source_entry}")
|
|
778
|
+
match = matches[0]
|
|
779
|
+
next_heading = re.search(r"^##\s+", document[match.end() :], re.MULTILINE)
|
|
780
|
+
end = match.end() + next_heading.start() if next_heading is not None else len(document)
|
|
781
|
+
return document[match.start() : end]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _copyable_prompt(
|
|
785
|
+
section: str, heading: str = r"可复制(?:通用)?提示词"
|
|
786
|
+
) -> str:
|
|
787
|
+
markers = list(
|
|
788
|
+
re.finditer(rf"^###\s+{heading}\s*$", section, re.MULTILINE)
|
|
789
|
+
)
|
|
790
|
+
if not markers:
|
|
791
|
+
raise ValueError("source entry has no copyable prompt")
|
|
792
|
+
if len(markers) != 1:
|
|
793
|
+
raise ValueError("source entry has duplicate copyable prompts")
|
|
794
|
+
marker = markers[0]
|
|
795
|
+
body = section[marker.end() :]
|
|
796
|
+
following = re.search(r"^###\s+|^##\s+", body, re.MULTILINE)
|
|
797
|
+
if following is not None:
|
|
798
|
+
body = body[: following.start()]
|
|
799
|
+
body_lines = [line for line in body.splitlines() if line.strip()]
|
|
800
|
+
if any(not line.startswith(">") for line in body_lines):
|
|
801
|
+
raise ValueError("source entry copyable prompt contains unquoted content")
|
|
802
|
+
lines = [line[1:].lstrip() for line in body_lines]
|
|
803
|
+
if not lines or not "\n".join(lines).strip():
|
|
804
|
+
raise ValueError("source entry copyable prompt is empty")
|
|
805
|
+
return "\n".join(lines).strip()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _scope_items(value: str) -> list[str]:
|
|
809
|
+
return [item.strip() for item in re.split(r"[、,,]", value) if item.strip()]
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _contains_ref_token(value: str) -> bool:
|
|
813
|
+
return "ref-" in value.casefold()
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _contains_plan_token(value: str) -> bool:
|
|
817
|
+
return "plan-" in value.casefold()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _markdown_reference_bindings(
|
|
821
|
+
section: str, *, field_name: str, creator_supplied_ok: bool = False
|
|
822
|
+
) -> list[dict[str, Any]]:
|
|
823
|
+
lines = re.findall(
|
|
824
|
+
rf"^- {re.escape(field_name)}:(.+)$", section, re.MULTILINE
|
|
825
|
+
)
|
|
826
|
+
if not lines:
|
|
827
|
+
raise ValueError(f"source entry has no {field_name} declaration")
|
|
828
|
+
if len(lines) != 1:
|
|
829
|
+
raise ValueError(f"source entry has duplicate {field_name} declarations")
|
|
830
|
+
value = lines[0].strip()
|
|
831
|
+
if (
|
|
832
|
+
field_name == "输入参考图"
|
|
833
|
+
and not _contains_ref_token(value)
|
|
834
|
+
and re.fullmatch(r"无(?:([^)\n]+))?。?", value)
|
|
835
|
+
):
|
|
836
|
+
return []
|
|
837
|
+
if (
|
|
838
|
+
field_name == "参考"
|
|
839
|
+
and not _contains_ref_token(value)
|
|
840
|
+
and re.fullmatch(r"无(?:外部参考)?(?:;[^\n]*)?。?", value)
|
|
841
|
+
):
|
|
842
|
+
return []
|
|
843
|
+
# A `PLAN-...` slot is a picture the creator attaches in their own tool, so
|
|
844
|
+
# this suite has no file to send. The rendering job that draws a shot's own
|
|
845
|
+
# start frame is unaffected -- it works from the frozen keyframe text, the
|
|
846
|
+
# way it already does for 「待补参考图」 -- so only the job that would have
|
|
847
|
+
# sent these pictures as model inputs refuses. Matched as a slot head rather
|
|
848
|
+
# than a bare substring, or a real file named `plan-sheet.png` would refuse.
|
|
849
|
+
if PLAN_SLOT_HEAD_RE.search(value):
|
|
850
|
+
if not creator_supplied_ok:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
"source entry declares creator-supplied references (PLAN-...); "
|
|
853
|
+
"bind the real files as REF-... before producing"
|
|
854
|
+
)
|
|
855
|
+
value = PLAN_SLOT_RE.sub("", value).strip(";;。 ")
|
|
856
|
+
if not value:
|
|
857
|
+
return []
|
|
858
|
+
matches = list(REFERENCE_LINE_RE.finditer(value))
|
|
859
|
+
if not matches:
|
|
860
|
+
raise ValueError("source entry input-reference declaration is invalid")
|
|
861
|
+
cursor = 0
|
|
862
|
+
for index, match in enumerate(matches):
|
|
863
|
+
separator = value[cursor : match.start()]
|
|
864
|
+
if separator != ("" if index == 0 else ";"):
|
|
865
|
+
raise ValueError("source entry input-reference declaration is invalid")
|
|
866
|
+
cursor = match.end()
|
|
867
|
+
if value[cursor:] not in {"", "。"}:
|
|
868
|
+
raise ValueError("source entry input-reference declaration is invalid")
|
|
869
|
+
return [
|
|
870
|
+
{
|
|
871
|
+
"slot_id": match.group(1),
|
|
872
|
+
"order": int(match.group(2)),
|
|
873
|
+
"path": _relative_path(match.group(3)),
|
|
874
|
+
"label": match.group(4).strip(),
|
|
875
|
+
"may_control": _scope_items(match.group(5)),
|
|
876
|
+
"must_not_control": _scope_items(match.group(6)),
|
|
877
|
+
}
|
|
878
|
+
for match in matches
|
|
879
|
+
]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _verify_markdown_source(
|
|
883
|
+
root: Path,
|
|
884
|
+
*,
|
|
885
|
+
source: str,
|
|
886
|
+
source_entry: str,
|
|
887
|
+
prompt: str,
|
|
888
|
+
bindings: list[dict[str, Any]],
|
|
889
|
+
) -> None:
|
|
890
|
+
source_path = _project_file(root, source)
|
|
891
|
+
if source_path.suffix.casefold() != ".md":
|
|
892
|
+
raise ValueError("source_entry requires a Markdown source")
|
|
893
|
+
document = source_path.read_text(encoding="utf-8")
|
|
894
|
+
section = _markdown_section(document, source_entry)
|
|
895
|
+
_, heading, field_name = CREATOR_SOURCE_ENTRIES[source_path.name]
|
|
896
|
+
if _copyable_prompt(section, heading) != prompt.strip():
|
|
897
|
+
raise ValueError("job prompt does not match the selected source entry")
|
|
898
|
+
declared = _markdown_reference_bindings(
|
|
899
|
+
section,
|
|
900
|
+
field_name=field_name,
|
|
901
|
+
# 分镜.md is the entry that renders a shot's own start frame. Its
|
|
902
|
+
# reference field records creative intent, not this job's inputs.
|
|
903
|
+
creator_supplied_ok=source_path.name == "分镜.md",
|
|
904
|
+
)
|
|
905
|
+
comparable = [
|
|
906
|
+
{key: binding[key] for key in (
|
|
907
|
+
"slot_id",
|
|
908
|
+
"order",
|
|
909
|
+
"path",
|
|
910
|
+
"label",
|
|
911
|
+
"may_control",
|
|
912
|
+
"must_not_control",
|
|
913
|
+
)}
|
|
914
|
+
for binding in bindings
|
|
915
|
+
]
|
|
916
|
+
if declared != comparable:
|
|
917
|
+
raise ValueError("job reference bindings do not match the selected source entry")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _normalize_job(root: Path, raw: object) -> dict[str, Any]:
|
|
921
|
+
if not isinstance(raw, Mapping) or set(raw) - ALLOWED_JOB_KEYS:
|
|
922
|
+
raise ValueError("job contains unsupported fields")
|
|
923
|
+
if raw.get("schema_version", JOB_SCHEMA) != JOB_SCHEMA:
|
|
924
|
+
raise ValueError("unsupported job schema")
|
|
925
|
+
job_id = raw.get("job_id")
|
|
926
|
+
if not isinstance(job_id, str) or JOB_ID_RE.fullmatch(job_id) is None:
|
|
927
|
+
raise ValueError("job_id must be a portable 1-80 character identifier")
|
|
928
|
+
modality = raw.get("modality")
|
|
929
|
+
if modality not in MEDIA_EXTENSIONS:
|
|
930
|
+
raise ValueError("modality must be image, video, tts, or music")
|
|
931
|
+
adapter = raw.get("adapter")
|
|
932
|
+
if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
|
|
933
|
+
raise ValueError("adapter must be a portable profile name")
|
|
934
|
+
prompt = raw.get("prompt")
|
|
935
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
|
|
936
|
+
raise ValueError("prompt must be non-empty and at most 100000 characters")
|
|
937
|
+
source_raw = raw.get("source")
|
|
938
|
+
source = _relative_path(source_raw) if source_raw is not None else None
|
|
939
|
+
source_entry = raw.get("source_entry")
|
|
940
|
+
if source_entry is not None and (
|
|
941
|
+
not isinstance(source_entry, str)
|
|
942
|
+
or SOURCE_ENTRY_RE.fullmatch(source_entry) is None
|
|
943
|
+
):
|
|
944
|
+
raise ValueError("source_entry must be a visible uppercase Markdown entry ID")
|
|
945
|
+
if source_entry is not None and source is None:
|
|
946
|
+
raise ValueError("source_entry requires source")
|
|
947
|
+
creator_source_modality = _canonical_creator_source_modality(source)
|
|
948
|
+
if creator_source_modality == modality and source_entry is None:
|
|
949
|
+
raise ValueError("creator Markdown source requires source_entry")
|
|
950
|
+
if creator_source_modality is not None and modality != creator_source_modality and not (
|
|
951
|
+
creator_source_modality == "video"
|
|
952
|
+
and modality == "music"
|
|
953
|
+
and source_entry is None
|
|
954
|
+
):
|
|
955
|
+
raise ValueError("creator Markdown source does not match the job modality")
|
|
956
|
+
expected_entry_prefix = (
|
|
957
|
+
CREATOR_SOURCE_ENTRIES[PurePosixPath(source).name][0]
|
|
958
|
+
if source is not None
|
|
959
|
+
and PurePosixPath(source).name in CREATOR_SOURCE_ENTRIES
|
|
960
|
+
and creator_source_modality == modality
|
|
961
|
+
else None
|
|
962
|
+
)
|
|
963
|
+
if source_entry is not None and creator_source_modality != modality:
|
|
964
|
+
raise ValueError("source_entry requires the canonical creator Markdown path")
|
|
965
|
+
if source_entry is not None and (
|
|
966
|
+
expected_entry_prefix is None
|
|
967
|
+
or not source_entry.startswith(expected_entry_prefix)
|
|
968
|
+
):
|
|
969
|
+
raise ValueError("source_entry does not match the job modality")
|
|
970
|
+
references_supplied = "references" in raw
|
|
971
|
+
supplied_references = [
|
|
972
|
+
_relative_path(path)
|
|
973
|
+
for path in _string_list(raw.get("references", []), label="references", limit=16)
|
|
974
|
+
]
|
|
975
|
+
reference_bindings = _normalize_reference_bindings(raw.get("reference_bindings"))
|
|
976
|
+
binding_references = [str(binding["path"]) for binding in reference_bindings]
|
|
977
|
+
if (
|
|
978
|
+
references_supplied
|
|
979
|
+
and (reference_bindings or source_entry is not None)
|
|
980
|
+
and supplied_references != binding_references
|
|
981
|
+
):
|
|
982
|
+
raise ValueError("references must match reference_bindings order")
|
|
983
|
+
references = binding_references if reference_bindings else supplied_references
|
|
984
|
+
if source_entry is not None:
|
|
985
|
+
_verify_markdown_source(
|
|
986
|
+
root,
|
|
987
|
+
source=str(source),
|
|
988
|
+
source_entry=source_entry,
|
|
989
|
+
prompt=prompt,
|
|
990
|
+
bindings=reference_bindings,
|
|
991
|
+
)
|
|
992
|
+
outputs = [
|
|
993
|
+
_relative_path(path, output=True)
|
|
994
|
+
for path in _string_list(raw.get("outputs"), label="outputs", limit=16)
|
|
995
|
+
]
|
|
996
|
+
if not outputs or len(set(outputs)) != len(outputs):
|
|
997
|
+
raise ValueError("outputs must contain unique target paths")
|
|
998
|
+
for output_path in outputs:
|
|
999
|
+
if PurePosixPath(output_path).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
|
|
1000
|
+
raise ValueError(f"output extension does not match {modality}: {output_path}")
|
|
1001
|
+
_project_file(root, output_path)
|
|
1002
|
+
parameters = raw.get("parameters", {})
|
|
1003
|
+
if not isinstance(parameters, Mapping):
|
|
1004
|
+
raise ValueError("parameters must be an object")
|
|
1005
|
+
if _contains_secret_key(parameters):
|
|
1006
|
+
raise ValueError("job parameters must not contain credentials or secrets")
|
|
1007
|
+
if len(_canonical(parameters)) > 64 * 1024:
|
|
1008
|
+
raise ValueError("job parameters are too large")
|
|
1009
|
+
input_paths = ([source] if source is not None else []) + references
|
|
1010
|
+
if len(set(input_paths)) != len(input_paths):
|
|
1011
|
+
raise ValueError("source and references must be unique")
|
|
1012
|
+
input_hashes = {path: _hash_project_file(root, path) for path in input_paths}
|
|
1013
|
+
execution = {
|
|
1014
|
+
"schema_version": JOB_SCHEMA,
|
|
1015
|
+
"job_id": job_id,
|
|
1016
|
+
"modality": modality,
|
|
1017
|
+
"adapter": adapter,
|
|
1018
|
+
"prompt": prompt,
|
|
1019
|
+
"source": source,
|
|
1020
|
+
"source_entry": source_entry,
|
|
1021
|
+
"references": references,
|
|
1022
|
+
"reference_bindings": reference_bindings,
|
|
1023
|
+
"outputs": outputs,
|
|
1024
|
+
"parameters": dict(parameters),
|
|
1025
|
+
"overwrite": raw.get("overwrite", False),
|
|
1026
|
+
"inputs": input_hashes,
|
|
1027
|
+
}
|
|
1028
|
+
if not isinstance(execution["overwrite"], bool):
|
|
1029
|
+
raise ValueError("overwrite must be a boolean")
|
|
1030
|
+
execution["fingerprint"] = sha256_bytes(_canonical(execution))
|
|
1031
|
+
execution["prepared_at"] = utc_now()
|
|
1032
|
+
return execution
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def prepare_job(root: Path, job_file: Path) -> dict[str, Any]:
|
|
1036
|
+
root = find_project(root)
|
|
1037
|
+
if job_file.stat().st_size > MAX_JOB_BYTES:
|
|
1038
|
+
raise ValueError("job file is too large")
|
|
1039
|
+
raw = json.loads(job_file.read_text(encoding="utf-8"))
|
|
1040
|
+
job = _normalize_job(root, raw)
|
|
1041
|
+
with _project_lock(root):
|
|
1042
|
+
if _active_run(root, str(job["job_id"])) is not None:
|
|
1043
|
+
raise RuntimeError("this job is already running")
|
|
1044
|
+
job_name = f"{_job_key(str(job['job_id']))}.json"
|
|
1045
|
+
_metadata_atomic_json(root, ("jobs",), job_name, job)
|
|
1046
|
+
try:
|
|
1047
|
+
_metadata_unlink(root, ("confirmations",), job_name)
|
|
1048
|
+
except FileNotFoundError:
|
|
1049
|
+
pass
|
|
1050
|
+
return _preview(job)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _validate_stored_job(
|
|
1054
|
+
root: Path, document: object, *, expected_job_id: str | None = None
|
|
1055
|
+
) -> dict[str, Any]:
|
|
1056
|
+
root = root.resolve()
|
|
1057
|
+
if not isinstance(document, dict):
|
|
1058
|
+
raise ValueError("stored job fields are invalid")
|
|
1059
|
+
stored_keys = set(document)
|
|
1060
|
+
legacy = stored_keys == LEGACY_STORED_JOB_KEYS
|
|
1061
|
+
if not legacy and stored_keys != STORED_JOB_KEYS:
|
|
1062
|
+
raise ValueError("stored job fields are invalid")
|
|
1063
|
+
original = document
|
|
1064
|
+
document = dict(document)
|
|
1065
|
+
if legacy:
|
|
1066
|
+
document["source_entry"] = None
|
|
1067
|
+
document["reference_bindings"] = []
|
|
1068
|
+
job_id = document.get("job_id")
|
|
1069
|
+
if (
|
|
1070
|
+
not isinstance(job_id, str)
|
|
1071
|
+
or JOB_ID_RE.fullmatch(job_id) is None
|
|
1072
|
+
or (expected_job_id is not None and job_id != expected_job_id)
|
|
1073
|
+
):
|
|
1074
|
+
raise ValueError("stored job id is invalid")
|
|
1075
|
+
if document.get("schema_version") != JOB_SCHEMA:
|
|
1076
|
+
raise ValueError("stored job schema is invalid")
|
|
1077
|
+
modality = document.get("modality")
|
|
1078
|
+
if modality not in MEDIA_EXTENSIONS:
|
|
1079
|
+
raise ValueError("stored job modality is invalid")
|
|
1080
|
+
adapter = document.get("adapter")
|
|
1081
|
+
if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
|
|
1082
|
+
raise ValueError("stored job adapter is invalid")
|
|
1083
|
+
prompt = document.get("prompt")
|
|
1084
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
|
|
1085
|
+
raise ValueError("stored job prompt is invalid")
|
|
1086
|
+
source = document.get("source")
|
|
1087
|
+
if source is not None and _relative_path(source) != source:
|
|
1088
|
+
raise ValueError("stored job source is invalid")
|
|
1089
|
+
source_entry = document.get("source_entry")
|
|
1090
|
+
if source_entry is not None and (
|
|
1091
|
+
not isinstance(source_entry, str)
|
|
1092
|
+
or SOURCE_ENTRY_RE.fullmatch(source_entry) is None
|
|
1093
|
+
or source is None
|
|
1094
|
+
):
|
|
1095
|
+
raise ValueError("stored job source entry is invalid")
|
|
1096
|
+
expected_entry_prefix = {"image": "IMG-", "video": "MOTION-"}.get(
|
|
1097
|
+
str(modality)
|
|
1098
|
+
)
|
|
1099
|
+
if source_entry is not None and (
|
|
1100
|
+
expected_entry_prefix is None
|
|
1101
|
+
or not source_entry.startswith(expected_entry_prefix)
|
|
1102
|
+
):
|
|
1103
|
+
raise ValueError("stored job source entry has the wrong modality")
|
|
1104
|
+
creator_source_modality = _canonical_creator_source_modality(
|
|
1105
|
+
str(source) if source is not None else None
|
|
1106
|
+
)
|
|
1107
|
+
if not legacy and creator_source_modality == modality and source_entry is None:
|
|
1108
|
+
raise ValueError("stored creator Markdown job has no source entry")
|
|
1109
|
+
if creator_source_modality is not None and modality != creator_source_modality and not (
|
|
1110
|
+
creator_source_modality == "video"
|
|
1111
|
+
and modality == "music"
|
|
1112
|
+
and source_entry is None
|
|
1113
|
+
):
|
|
1114
|
+
raise ValueError("stored creator Markdown source has the wrong modality")
|
|
1115
|
+
if source_entry is not None and creator_source_modality != modality:
|
|
1116
|
+
raise ValueError("stored job creator source path is invalid")
|
|
1117
|
+
references = _string_list(
|
|
1118
|
+
document.get("references"), label="stored references", limit=16
|
|
1119
|
+
)
|
|
1120
|
+
if any(_relative_path(reference) != reference for reference in references):
|
|
1121
|
+
raise ValueError("stored job references are invalid")
|
|
1122
|
+
reference_bindings = _normalize_reference_bindings(
|
|
1123
|
+
document.get("reference_bindings")
|
|
1124
|
+
)
|
|
1125
|
+
binding_references = [binding["path"] for binding in reference_bindings]
|
|
1126
|
+
if (
|
|
1127
|
+
source_entry is not None or reference_bindings
|
|
1128
|
+
) and binding_references != references:
|
|
1129
|
+
raise ValueError("stored job references do not match reference bindings")
|
|
1130
|
+
outputs = _string_list(document.get("outputs"), label="stored outputs", limit=16)
|
|
1131
|
+
if not outputs or len(outputs) != len(set(outputs)):
|
|
1132
|
+
raise ValueError("stored job outputs are invalid")
|
|
1133
|
+
for output in outputs:
|
|
1134
|
+
if _relative_path(output, output=True) != output:
|
|
1135
|
+
raise ValueError("stored job output is invalid")
|
|
1136
|
+
if PurePosixPath(output).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
|
|
1137
|
+
raise ValueError("stored job output extension is invalid")
|
|
1138
|
+
_project_file(root, output)
|
|
1139
|
+
parameters = document.get("parameters")
|
|
1140
|
+
if (
|
|
1141
|
+
not isinstance(parameters, Mapping)
|
|
1142
|
+
or _contains_secret_key(parameters)
|
|
1143
|
+
or len(_canonical(parameters)) > 64 * 1024
|
|
1144
|
+
):
|
|
1145
|
+
raise ValueError("stored job parameters are invalid")
|
|
1146
|
+
if not isinstance(document.get("overwrite"), bool):
|
|
1147
|
+
raise ValueError("stored job overwrite flag is invalid")
|
|
1148
|
+
input_paths = ([source] if source is not None else []) + references
|
|
1149
|
+
if len(input_paths) != len(set(input_paths)):
|
|
1150
|
+
raise ValueError("stored job inputs are duplicated")
|
|
1151
|
+
inputs = document.get("inputs")
|
|
1152
|
+
if not isinstance(inputs, Mapping) or set(inputs) != set(input_paths):
|
|
1153
|
+
raise ValueError("stored job input hashes are invalid")
|
|
1154
|
+
if any(
|
|
1155
|
+
not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
1156
|
+
for digest in inputs.values()
|
|
1157
|
+
):
|
|
1158
|
+
raise ValueError("stored job input hash is invalid")
|
|
1159
|
+
fingerprint = document.get("fingerprint")
|
|
1160
|
+
execution_keys = (
|
|
1161
|
+
LEGACY_STORED_EXECUTION_KEYS if legacy else STORED_EXECUTION_KEYS
|
|
1162
|
+
)
|
|
1163
|
+
execution = {key: original[key] for key in execution_keys}
|
|
1164
|
+
if (
|
|
1165
|
+
not isinstance(fingerprint, str)
|
|
1166
|
+
or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None
|
|
1167
|
+
or sha256_bytes(_canonical(execution)) != fingerprint
|
|
1168
|
+
):
|
|
1169
|
+
raise ValueError("stored job fingerprint is invalid")
|
|
1170
|
+
_parse_run_timestamp(document.get("prepared_at"), label="prepared_at")
|
|
1171
|
+
return document
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _read_job(root: Path, job_id: str) -> dict[str, Any]:
|
|
1175
|
+
if JOB_ID_RE.fullmatch(job_id) is None:
|
|
1176
|
+
raise ValueError("invalid job_id")
|
|
1177
|
+
document = _metadata_read_json(
|
|
1178
|
+
root,
|
|
1179
|
+
("jobs",),
|
|
1180
|
+
f"{_job_key(job_id)}.json",
|
|
1181
|
+
maximum=MAX_JOB_BYTES,
|
|
1182
|
+
)
|
|
1183
|
+
return _validate_stored_job(root, document, expected_job_id=job_id)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _preview(job: Mapping[str, Any]) -> dict[str, Any]:
|
|
1187
|
+
confirmation = f"CONFIRM {job['job_id']} {str(job['fingerprint'])[:12]}"
|
|
1188
|
+
return {
|
|
1189
|
+
"job_id": job["job_id"],
|
|
1190
|
+
"modality": job["modality"],
|
|
1191
|
+
"adapter": job["adapter"],
|
|
1192
|
+
"count": len(job["outputs"]),
|
|
1193
|
+
"prompt": job["prompt"],
|
|
1194
|
+
"source": job["source"],
|
|
1195
|
+
"source_entry": job["source_entry"],
|
|
1196
|
+
"references": job["references"],
|
|
1197
|
+
"reference_bindings": job["reference_bindings"],
|
|
1198
|
+
"outputs": job["outputs"],
|
|
1199
|
+
"parameters": job["parameters"],
|
|
1200
|
+
"overwrite": job["overwrite"],
|
|
1201
|
+
"confirmation": confirmation,
|
|
1202
|
+
"state": "needs_confirmation",
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def confirm_job(root: Path, *, job_id: str, confirmation: str) -> dict[str, Any]:
|
|
1207
|
+
root = find_project(root)
|
|
1208
|
+
with _project_lock(root):
|
|
1209
|
+
if _active_run(root, job_id) is not None:
|
|
1210
|
+
raise RuntimeError("this job is already running")
|
|
1211
|
+
job = _read_job(root, job_id)
|
|
1212
|
+
expected = _preview(job)["confirmation"]
|
|
1213
|
+
if confirmation != expected:
|
|
1214
|
+
raise ConfirmationRequiredError("confirmation does not match the exact current job")
|
|
1215
|
+
receipt = {
|
|
1216
|
+
"schema_version": JOB_SCHEMA,
|
|
1217
|
+
"job_id": job_id,
|
|
1218
|
+
"fingerprint": job["fingerprint"],
|
|
1219
|
+
"confirmed_at": utc_now(),
|
|
1220
|
+
"consumed_at": None,
|
|
1221
|
+
"run_id": None,
|
|
1222
|
+
}
|
|
1223
|
+
_metadata_atomic_json(
|
|
1224
|
+
root, ("confirmations",), f"{_job_key(job_id)}.json", receipt
|
|
1225
|
+
)
|
|
1226
|
+
return {"job_id": job_id, "state": "confirmed"}
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _inputs_current(root: Path, job: Mapping[str, Any]) -> bool:
|
|
1230
|
+
inputs = job.get("inputs")
|
|
1231
|
+
if not isinstance(inputs, Mapping):
|
|
1232
|
+
return False
|
|
1233
|
+
try:
|
|
1234
|
+
return all(_hash_project_file(root, str(path)) == digest for path, digest in inputs.items())
|
|
1235
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
1236
|
+
return False
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _load_adapter(config_path: Path, profile: str, root: Path) -> tuple[list[str], int]:
|
|
1240
|
+
resolved = config_path.expanduser().resolve()
|
|
1241
|
+
if resolved.is_relative_to(root):
|
|
1242
|
+
raise ValueError("adapter config must live outside the project")
|
|
1243
|
+
document = json.loads(resolved.read_text(encoding="utf-8"))
|
|
1244
|
+
adapters = document.get("adapters") if isinstance(document, Mapping) else None
|
|
1245
|
+
selected = adapters.get(profile) if isinstance(adapters, Mapping) else None
|
|
1246
|
+
if not isinstance(selected, Mapping) or set(selected) - {"command", "timeout_seconds"}:
|
|
1247
|
+
raise ValueError(f"adapter profile is missing or invalid: {profile}")
|
|
1248
|
+
command = _string_list(selected.get("command"), label="adapter command", limit=32)
|
|
1249
|
+
if not command or any(not part for part in command):
|
|
1250
|
+
raise ValueError("adapter command must be a non-empty argv list")
|
|
1251
|
+
timeout = selected.get("timeout_seconds", 300)
|
|
1252
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= MAX_TIMEOUT_SECONDS:
|
|
1253
|
+
raise ValueError(f"adapter timeout must be 1-{MAX_TIMEOUT_SECONDS} seconds")
|
|
1254
|
+
return command, timeout
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _generic_adapter_error(
|
|
1258
|
+
profile: str,
|
|
1259
|
+
*,
|
|
1260
|
+
category: str,
|
|
1261
|
+
code: str,
|
|
1262
|
+
retryable: bool,
|
|
1263
|
+
) -> dict[str, Any]:
|
|
1264
|
+
return {
|
|
1265
|
+
"provider": profile,
|
|
1266
|
+
"category": category,
|
|
1267
|
+
"code": code,
|
|
1268
|
+
"retryable": retryable,
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _parse_public_adapter_error(
|
|
1273
|
+
raw: bytes, *, profile: str, returncode: int
|
|
1274
|
+
) -> dict[str, Any]:
|
|
1275
|
+
fallback = _generic_adapter_error(
|
|
1276
|
+
profile,
|
|
1277
|
+
category="provider_response",
|
|
1278
|
+
code=f"adapter_exit_{returncode}",
|
|
1279
|
+
retryable=False,
|
|
1280
|
+
)
|
|
1281
|
+
if len(raw) > MAX_ADAPTER_RESPONSE_BYTES:
|
|
1282
|
+
return fallback
|
|
1283
|
+
try:
|
|
1284
|
+
document = json.loads(raw.decode("utf-8"))
|
|
1285
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
1286
|
+
return fallback
|
|
1287
|
+
error = document.get("error") if isinstance(document, Mapping) else None
|
|
1288
|
+
if not isinstance(error, Mapping) or set(error) - {
|
|
1289
|
+
"provider",
|
|
1290
|
+
"category",
|
|
1291
|
+
"code",
|
|
1292
|
+
"http_status",
|
|
1293
|
+
"request_id",
|
|
1294
|
+
"retryable",
|
|
1295
|
+
}:
|
|
1296
|
+
return fallback
|
|
1297
|
+
provider = error.get("provider")
|
|
1298
|
+
category = error.get("category")
|
|
1299
|
+
code = error.get("code")
|
|
1300
|
+
retryable = error.get("retryable")
|
|
1301
|
+
if (
|
|
1302
|
+
provider != profile
|
|
1303
|
+
or category not in PUBLIC_ERROR_CATEGORIES
|
|
1304
|
+
or not isinstance(code, str)
|
|
1305
|
+
or PUBLIC_ERROR_TOKEN_RE.fullmatch(code) is None
|
|
1306
|
+
or not isinstance(retryable, bool)
|
|
1307
|
+
):
|
|
1308
|
+
return fallback
|
|
1309
|
+
result: dict[str, Any] = {
|
|
1310
|
+
"provider": provider,
|
|
1311
|
+
"category": category,
|
|
1312
|
+
"code": code,
|
|
1313
|
+
"retryable": retryable,
|
|
1314
|
+
}
|
|
1315
|
+
status = error.get("http_status")
|
|
1316
|
+
if status is not None:
|
|
1317
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 100 <= status <= 599:
|
|
1318
|
+
return fallback
|
|
1319
|
+
result["http_status"] = status
|
|
1320
|
+
request_id = error.get("request_id")
|
|
1321
|
+
if request_id is not None:
|
|
1322
|
+
if (
|
|
1323
|
+
not isinstance(request_id, str)
|
|
1324
|
+
or PUBLIC_ERROR_TOKEN_RE.fullmatch(request_id) is None
|
|
1325
|
+
):
|
|
1326
|
+
return fallback
|
|
1327
|
+
result["request_id"] = request_id
|
|
1328
|
+
return result
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _run_adapter(command: list[str], timeout: int, payload: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
|
1332
|
+
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
|
|
1333
|
+
try:
|
|
1334
|
+
completed = subprocess.run(
|
|
1335
|
+
command,
|
|
1336
|
+
input=_canonical(payload),
|
|
1337
|
+
stdout=stdout,
|
|
1338
|
+
stderr=stderr,
|
|
1339
|
+
cwd=root,
|
|
1340
|
+
timeout=timeout,
|
|
1341
|
+
check=False,
|
|
1342
|
+
)
|
|
1343
|
+
except subprocess.TimeoutExpired as exc:
|
|
1344
|
+
raise AdapterError(
|
|
1345
|
+
"adapter timed out; confirmation was consumed",
|
|
1346
|
+
public_error=_generic_adapter_error(
|
|
1347
|
+
str(payload["adapter"]),
|
|
1348
|
+
category="timeout",
|
|
1349
|
+
code="adapter_timeout",
|
|
1350
|
+
retryable=True,
|
|
1351
|
+
),
|
|
1352
|
+
) from exc
|
|
1353
|
+
except OSError as exc:
|
|
1354
|
+
raise AdapterError(
|
|
1355
|
+
"adapter could not be started; confirmation was consumed",
|
|
1356
|
+
public_error=_generic_adapter_error(
|
|
1357
|
+
str(payload["adapter"]),
|
|
1358
|
+
category="configuration",
|
|
1359
|
+
code="adapter_start_failed",
|
|
1360
|
+
retryable=False,
|
|
1361
|
+
),
|
|
1362
|
+
) from exc
|
|
1363
|
+
if completed.returncode != 0:
|
|
1364
|
+
size = stdout.tell()
|
|
1365
|
+
stdout.seek(0)
|
|
1366
|
+
raw_error = stdout.read(MAX_ADAPTER_RESPONSE_BYTES + 1)
|
|
1367
|
+
public_error = _parse_public_adapter_error(
|
|
1368
|
+
raw_error if size <= MAX_ADAPTER_RESPONSE_BYTES else b"",
|
|
1369
|
+
profile=str(payload["adapter"]),
|
|
1370
|
+
returncode=completed.returncode,
|
|
1371
|
+
)
|
|
1372
|
+
raise AdapterError(
|
|
1373
|
+
f"adapter exited with code {completed.returncode}; confirmation was consumed",
|
|
1374
|
+
public_error=public_error,
|
|
1375
|
+
)
|
|
1376
|
+
size = stdout.tell()
|
|
1377
|
+
if size > MAX_ADAPTER_RESPONSE_BYTES:
|
|
1378
|
+
raise AdapterError("adapter response is too large; confirmation was consumed")
|
|
1379
|
+
stdout.seek(0)
|
|
1380
|
+
try:
|
|
1381
|
+
response = json.loads(stdout.read().decode("utf-8"))
|
|
1382
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
1383
|
+
raise AdapterError("adapter returned invalid JSON; confirmation was consumed") from exc
|
|
1384
|
+
if not isinstance(response, dict):
|
|
1385
|
+
raise AdapterError("adapter response must be an object; confirmation was consumed")
|
|
1386
|
+
return response
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _validate_adapter_outputs(
|
|
1390
|
+
job: Mapping[str, Any], response: Mapping[str, Any], output_root: Path
|
|
1391
|
+
) -> list[tuple[str, Path]]:
|
|
1392
|
+
entries = response.get("outputs")
|
|
1393
|
+
if not isinstance(entries, list) or not all(isinstance(entry, Mapping) for entry in entries):
|
|
1394
|
+
raise AdapterError("adapter outputs are invalid; confirmation was consumed")
|
|
1395
|
+
result: list[tuple[str, Path]] = []
|
|
1396
|
+
for entry in entries:
|
|
1397
|
+
if set(entry) != {"target", "source"}:
|
|
1398
|
+
raise AdapterError("adapter output fields are invalid; confirmation was consumed")
|
|
1399
|
+
target = entry.get("target")
|
|
1400
|
+
source = entry.get("source")
|
|
1401
|
+
if not isinstance(target, str) or not isinstance(source, str):
|
|
1402
|
+
raise AdapterError("adapter output paths are invalid; confirmation was consumed")
|
|
1403
|
+
path = Path(source)
|
|
1404
|
+
if not path.is_absolute() or path.parent != output_root or path.name in {"", ".", ".."}:
|
|
1405
|
+
raise AdapterError(
|
|
1406
|
+
"adapter output must use the run staging directory; confirmation was consumed"
|
|
1407
|
+
)
|
|
1408
|
+
result.append((target, path))
|
|
1409
|
+
expected = list(job["outputs"])
|
|
1410
|
+
if [target for target, _ in result] != expected:
|
|
1411
|
+
raise AdapterError("adapter outputs do not match the confirmed targets; confirmation was consumed")
|
|
1412
|
+
return result
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _copy_output(
|
|
1416
|
+
source: Path, target: Path, *, overwrite: bool
|
|
1417
|
+
) -> tuple[str, int]:
|
|
1418
|
+
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
1419
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1420
|
+
digest = hashlib.sha256()
|
|
1421
|
+
size = 0
|
|
1422
|
+
descriptor = -1
|
|
1423
|
+
try:
|
|
1424
|
+
try:
|
|
1425
|
+
before = source.lstat()
|
|
1426
|
+
except OSError as exc:
|
|
1427
|
+
raise AdapterError(
|
|
1428
|
+
"adapter output file is missing; confirmation was consumed"
|
|
1429
|
+
) from exc
|
|
1430
|
+
if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
|
|
1431
|
+
raise AdapterError("adapter output file is unsafe; confirmation was consumed")
|
|
1432
|
+
descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
1433
|
+
opened = os.fstat(descriptor)
|
|
1434
|
+
if (
|
|
1435
|
+
not stat.S_ISREG(opened.st_mode)
|
|
1436
|
+
or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)
|
|
1437
|
+
):
|
|
1438
|
+
raise AdapterError("adapter output changed while opening; confirmation was consumed")
|
|
1439
|
+
if opened.st_size > MAX_OUTPUT_BYTES:
|
|
1440
|
+
raise AdapterError("adapter output file is too large; confirmation was consumed")
|
|
1441
|
+
with os.fdopen(descriptor, "rb", closefd=True) as incoming, temporary.open(
|
|
1442
|
+
"xb"
|
|
1443
|
+
) as outgoing:
|
|
1444
|
+
descriptor = -1
|
|
1445
|
+
for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
|
|
1446
|
+
size += len(chunk)
|
|
1447
|
+
if size > MAX_OUTPUT_BYTES:
|
|
1448
|
+
raise AdapterError("adapter output exceeded the size limit")
|
|
1449
|
+
digest.update(chunk)
|
|
1450
|
+
outgoing.write(chunk)
|
|
1451
|
+
outgoing.flush()
|
|
1452
|
+
os.fsync(outgoing.fileno())
|
|
1453
|
+
if overwrite:
|
|
1454
|
+
os.replace(temporary, target)
|
|
1455
|
+
else:
|
|
1456
|
+
try:
|
|
1457
|
+
os.link(temporary, target, follow_symlinks=False)
|
|
1458
|
+
except FileExistsError as exc:
|
|
1459
|
+
raise FileExistsError(
|
|
1460
|
+
f"output appeared while production was running: {target.name}"
|
|
1461
|
+
) from exc
|
|
1462
|
+
temporary.unlink()
|
|
1463
|
+
finally:
|
|
1464
|
+
if descriptor >= 0:
|
|
1465
|
+
os.close(descriptor)
|
|
1466
|
+
try:
|
|
1467
|
+
temporary.unlink()
|
|
1468
|
+
except FileNotFoundError:
|
|
1469
|
+
pass
|
|
1470
|
+
return digest.hexdigest(), size
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _latest_run(root: Path, job_id: str) -> dict[str, Any] | None:
|
|
1474
|
+
history = _read_run_history(root, job_id)
|
|
1475
|
+
return history[-1] if history else None
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _parse_run_timestamp(value: object, *, label: str) -> datetime:
|
|
1479
|
+
if not isinstance(value, str) or not value:
|
|
1480
|
+
raise ValueError(f"production run {label} is invalid")
|
|
1481
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
1482
|
+
try:
|
|
1483
|
+
parsed = datetime.fromisoformat(normalized)
|
|
1484
|
+
except ValueError as exc:
|
|
1485
|
+
raise ValueError(f"production run {label} is invalid") from exc
|
|
1486
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
1487
|
+
raise ValueError(f"production run {label} is invalid")
|
|
1488
|
+
return parsed.astimezone(timezone.utc)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _completed_run_order(run: Mapping[str, Any]) -> tuple[datetime, str]:
|
|
1492
|
+
return (
|
|
1493
|
+
_parse_run_timestamp(run.get("finished_at"), label="finished_at"),
|
|
1494
|
+
str(run["run_id"]),
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _read_run_history(root: Path, job_id: str) -> list[dict[str, Any]]:
|
|
1499
|
+
directory_parts = ("runs", _job_key(job_id))
|
|
1500
|
+
history: list[dict[str, Any]] = []
|
|
1501
|
+
for name in _metadata_json_names(root, directory_parts):
|
|
1502
|
+
document = _metadata_read_json(
|
|
1503
|
+
root, directory_parts, name, maximum=MAX_RUN_RECORD_BYTES
|
|
1504
|
+
)
|
|
1505
|
+
if (
|
|
1506
|
+
not isinstance(document, dict)
|
|
1507
|
+
or document.get("job_id") != job_id
|
|
1508
|
+
or not isinstance(document.get("run_id"), str)
|
|
1509
|
+
or document.get("status") not in {"running", "succeeded", "failed"}
|
|
1510
|
+
or not isinstance(document.get("fingerprint"), str)
|
|
1511
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(document.get("fingerprint"))) is None
|
|
1512
|
+
):
|
|
1513
|
+
raise ValueError("production run record is invalid")
|
|
1514
|
+
started_at = _parse_run_timestamp(
|
|
1515
|
+
document.get("started_at"), label="started_at"
|
|
1516
|
+
)
|
|
1517
|
+
finished_at = document.get("finished_at")
|
|
1518
|
+
if document["status"] == "running":
|
|
1519
|
+
if finished_at is not None:
|
|
1520
|
+
raise ValueError("running production run has finished_at")
|
|
1521
|
+
else:
|
|
1522
|
+
completed_at = _parse_run_timestamp(finished_at, label="finished_at")
|
|
1523
|
+
if completed_at < started_at:
|
|
1524
|
+
raise ValueError("production run finished before it started")
|
|
1525
|
+
history.append(document)
|
|
1526
|
+
# Completed runs are ordered by completion because that is when their
|
|
1527
|
+
# terminal state and output claim become authoritative. Any unresolved
|
|
1528
|
+
# running attempt sorts last so status cannot hide it behind a later start.
|
|
1529
|
+
history.sort(
|
|
1530
|
+
key=lambda run: (
|
|
1531
|
+
run["status"] == "running",
|
|
1532
|
+
(
|
|
1533
|
+
_parse_run_timestamp(run.get("started_at"), label="started_at")
|
|
1534
|
+
if run["status"] == "running"
|
|
1535
|
+
else _completed_run_order(run)[0]
|
|
1536
|
+
),
|
|
1537
|
+
str(run["run_id"]),
|
|
1538
|
+
)
|
|
1539
|
+
)
|
|
1540
|
+
return history
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _active_run(root: Path, job_id: str) -> dict[str, Any] | None:
|
|
1544
|
+
running = [
|
|
1545
|
+
run for run in _read_run_history(root, job_id) if run["status"] == "running"
|
|
1546
|
+
]
|
|
1547
|
+
return running[-1] if running else None
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _read_provider_handle(root: Path, job_id: str, run_id: str) -> str | None:
|
|
1551
|
+
"""The provider job id an adapter recorded before it started polling.
|
|
1552
|
+
|
|
1553
|
+
A video task is billed the moment it is submitted, not when it is collected.
|
|
1554
|
+
If the adapter is then interrupted — killed, disconnected, the machine
|
|
1555
|
+
sleeps — the run record would otherwise say `running` with no way back to a
|
|
1556
|
+
task that is alive and already paid for, and the only move left is to submit
|
|
1557
|
+
(and pay) again. The adapter writes the id here as soon as it has one, into
|
|
1558
|
+
project metadata rather than the attempt's temporary directory, so it
|
|
1559
|
+
outlives the process that created it.
|
|
1560
|
+
"""
|
|
1561
|
+
|
|
1562
|
+
try:
|
|
1563
|
+
document = _metadata_read_json(
|
|
1564
|
+
root, ("handles", _job_key(job_id)), f"{run_id}.json",
|
|
1565
|
+
maximum=MAX_RUN_RECORD_BYTES,
|
|
1566
|
+
)
|
|
1567
|
+
except (FileNotFoundError, ValueError, json.JSONDecodeError):
|
|
1568
|
+
return None
|
|
1569
|
+
if not isinstance(document, Mapping):
|
|
1570
|
+
return None
|
|
1571
|
+
value = document.get("provider_job_id")
|
|
1572
|
+
if isinstance(value, str) and 0 < len(value) <= 200:
|
|
1573
|
+
return value
|
|
1574
|
+
return None
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def _handle_metadata_path(root: Path, job_id: str, run_id: str) -> Path:
|
|
1578
|
+
return root / PRODUCTION_ROOT / "handles" / _job_key(job_id) / f"{run_id}.json"
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _write_run(root: Path, job_id: str, run: Mapping[str, Any]) -> None:
|
|
1582
|
+
_metadata_atomic_json(
|
|
1583
|
+
root,
|
|
1584
|
+
("runs", _job_key(job_id)),
|
|
1585
|
+
f"{run['run_id']}.json",
|
|
1586
|
+
run,
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def run_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
|
|
1591
|
+
root = find_project(root)
|
|
1592
|
+
with tempfile.TemporaryDirectory(prefix="short-drama-inputs-") as directory:
|
|
1593
|
+
attempt_root = Path(directory)
|
|
1594
|
+
snapshot_root = attempt_root / "inputs"
|
|
1595
|
+
output_root = attempt_root / "outputs"
|
|
1596
|
+
snapshot_root.mkdir()
|
|
1597
|
+
output_root.mkdir()
|
|
1598
|
+
with _project_lock(root):
|
|
1599
|
+
if _active_run(root, job_id) is not None:
|
|
1600
|
+
raise RuntimeError("this job is already running")
|
|
1601
|
+
job = _read_job(root, job_id)
|
|
1602
|
+
command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
|
|
1603
|
+
try:
|
|
1604
|
+
receipt = _metadata_read_json(
|
|
1605
|
+
root,
|
|
1606
|
+
("confirmations",),
|
|
1607
|
+
f"{_job_key(job_id)}.json",
|
|
1608
|
+
maximum=MAX_RUN_RECORD_BYTES,
|
|
1609
|
+
)
|
|
1610
|
+
except FileNotFoundError as exc:
|
|
1611
|
+
raise ConfirmationRequiredError("job needs explicit confirmation") from exc
|
|
1612
|
+
if (
|
|
1613
|
+
not isinstance(receipt, dict)
|
|
1614
|
+
or receipt.get("fingerprint") != job.get("fingerprint")
|
|
1615
|
+
or receipt.get("consumed_at") is not None
|
|
1616
|
+
):
|
|
1617
|
+
raise ConfirmationRequiredError("job needs a new explicit confirmation")
|
|
1618
|
+
for output in job["outputs"]:
|
|
1619
|
+
target = _project_file(root, output)
|
|
1620
|
+
if target.exists() and not job["overwrite"]:
|
|
1621
|
+
raise FileExistsError(f"output exists and overwrite is false: {output}")
|
|
1622
|
+
# Pin every confirmed input into a private immutable snapshot before
|
|
1623
|
+
# consuming confirmation. Provider adapters never reopen live project paths.
|
|
1624
|
+
_snapshot_inputs(root, job, snapshot_root)
|
|
1625
|
+
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
|
1626
|
+
receipt["consumed_at"] = utc_now()
|
|
1627
|
+
receipt["run_id"] = run_id
|
|
1628
|
+
_metadata_atomic_json(
|
|
1629
|
+
root,
|
|
1630
|
+
("confirmations",),
|
|
1631
|
+
f"{_job_key(job_id)}.json",
|
|
1632
|
+
receipt,
|
|
1633
|
+
)
|
|
1634
|
+
run = {
|
|
1635
|
+
"schema_version": JOB_SCHEMA,
|
|
1636
|
+
"run_id": run_id,
|
|
1637
|
+
"job_id": job_id,
|
|
1638
|
+
"fingerprint": job["fingerprint"],
|
|
1639
|
+
"modality": job["modality"],
|
|
1640
|
+
"adapter": job["adapter"],
|
|
1641
|
+
"status": "running",
|
|
1642
|
+
"started_at": utc_now(),
|
|
1643
|
+
"finished_at": None,
|
|
1644
|
+
"outputs": [],
|
|
1645
|
+
}
|
|
1646
|
+
_write_run(root, job_id, run)
|
|
1647
|
+
|
|
1648
|
+
payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
|
|
1649
|
+
payload.update(
|
|
1650
|
+
{
|
|
1651
|
+
"run_id": run_id,
|
|
1652
|
+
"project_root": str(snapshot_root),
|
|
1653
|
+
"output_root": str(output_root),
|
|
1654
|
+
# Where to record the provider job id the instant it exists, so
|
|
1655
|
+
# an interrupted attempt can be collected instead of re-billed.
|
|
1656
|
+
"handle_path": str(_handle_metadata_path(root, job_id, run_id)),
|
|
1657
|
+
}
|
|
1658
|
+
)
|
|
1659
|
+
try:
|
|
1660
|
+
response = _run_adapter(command, timeout, payload, root)
|
|
1661
|
+
adapter_outputs = _validate_adapter_outputs(job, response, output_root)
|
|
1662
|
+
written: list[dict[str, Any]] = []
|
|
1663
|
+
with _project_lock(root):
|
|
1664
|
+
for target_name, source in adapter_outputs:
|
|
1665
|
+
target = _project_file(root, target_name, create_parent=True)
|
|
1666
|
+
digest, size = _copy_output(
|
|
1667
|
+
source, target, overwrite=bool(job["overwrite"])
|
|
1668
|
+
)
|
|
1669
|
+
written.append(
|
|
1670
|
+
{
|
|
1671
|
+
"path": target_name,
|
|
1672
|
+
"media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
|
|
1673
|
+
"bytes": size,
|
|
1674
|
+
"sha256": digest,
|
|
1675
|
+
}
|
|
1676
|
+
)
|
|
1677
|
+
if _run_status(root, job_id, run_id) == "succeeded":
|
|
1678
|
+
# `collect` reached the same provider task first and its
|
|
1679
|
+
# outputs are already in the project. Leave that record
|
|
1680
|
+
# alone rather than replacing a collected attempt.
|
|
1681
|
+
raise RuntimeError(
|
|
1682
|
+
"这次尝试已经被 collect 取回并标为成功;本次运行不覆盖它"
|
|
1683
|
+
)
|
|
1684
|
+
run["status"] = "succeeded"
|
|
1685
|
+
run["finished_at"] = utc_now()
|
|
1686
|
+
run["outputs"] = written
|
|
1687
|
+
provider_job_id = response.get("provider_job_id") or _read_provider_handle(
|
|
1688
|
+
root, job_id, run_id
|
|
1689
|
+
)
|
|
1690
|
+
if isinstance(provider_job_id, str) and 0 < len(provider_job_id) <= 200:
|
|
1691
|
+
run["provider_job_id"] = provider_job_id
|
|
1692
|
+
_write_run(root, job_id, run)
|
|
1693
|
+
except Exception as exc:
|
|
1694
|
+
with _project_lock(root):
|
|
1695
|
+
run["status"] = "failed"
|
|
1696
|
+
run["finished_at"] = utc_now()
|
|
1697
|
+
if isinstance(exc, AdapterError) and exc.public_error is not None:
|
|
1698
|
+
run["error"] = exc.public_error
|
|
1699
|
+
# A timeout or a killed adapter does not cancel the provider's
|
|
1700
|
+
# task. Carry the handle onto the failed record so `audit` can
|
|
1701
|
+
# say "collect this" instead of the creator paying twice.
|
|
1702
|
+
orphan = _read_provider_handle(root, job_id, run_id)
|
|
1703
|
+
if orphan is not None:
|
|
1704
|
+
run["provider_job_id"] = orphan
|
|
1705
|
+
_write_run(root, job_id, run)
|
|
1706
|
+
raise
|
|
1707
|
+
return {
|
|
1708
|
+
"job_id": job_id,
|
|
1709
|
+
"run_id": run_id,
|
|
1710
|
+
"state": "succeeded",
|
|
1711
|
+
"outputs": run["outputs"],
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def _run_status(root: Path, job_id: str, run_id: str) -> str | None:
|
|
1716
|
+
"""The status one attempt currently has on disk, re-read rather than cached."""
|
|
1717
|
+
|
|
1718
|
+
for run in _read_run_history(root, job_id):
|
|
1719
|
+
if str(run.get("run_id")) == run_id:
|
|
1720
|
+
status = run.get("status")
|
|
1721
|
+
return status if isinstance(status, str) else None
|
|
1722
|
+
return None
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def collect_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
|
|
1726
|
+
"""Collect an already-submitted task's result without submitting a new one.
|
|
1727
|
+
|
|
1728
|
+
This is deliberately outside the confirmation gate. The gate exists to stop
|
|
1729
|
+
an unintended charge; collecting costs nothing, because the charge already
|
|
1730
|
+
happened when the task was submitted. Requiring a fresh confirmation here
|
|
1731
|
+
would mean the cheapest way out of an interrupted attempt is to pay again,
|
|
1732
|
+
which is exactly the outcome the gate is meant to prevent.
|
|
1733
|
+
"""
|
|
1734
|
+
|
|
1735
|
+
root = find_project(root)
|
|
1736
|
+
with tempfile.TemporaryDirectory(prefix="short-drama-collect-") as directory:
|
|
1737
|
+
output_root = Path(directory) / "outputs"
|
|
1738
|
+
output_root.mkdir(parents=True)
|
|
1739
|
+
with _project_lock(root):
|
|
1740
|
+
job = _read_job(root, job_id)
|
|
1741
|
+
command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
|
|
1742
|
+
target_run = None
|
|
1743
|
+
for run in reversed(_read_run_history(root, job_id)):
|
|
1744
|
+
if run.get("status") == "succeeded":
|
|
1745
|
+
continue
|
|
1746
|
+
handle = run.get("provider_job_id") or _read_provider_handle(
|
|
1747
|
+
root, job_id, str(run["run_id"])
|
|
1748
|
+
)
|
|
1749
|
+
if handle:
|
|
1750
|
+
target_run = (run, handle)
|
|
1751
|
+
break
|
|
1752
|
+
if target_run is None:
|
|
1753
|
+
raise RuntimeError(
|
|
1754
|
+
"no unfinished attempt of this job carries a provider job id; "
|
|
1755
|
+
"there is nothing to collect"
|
|
1756
|
+
)
|
|
1757
|
+
run, provider_job_id = target_run
|
|
1758
|
+
|
|
1759
|
+
payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
|
|
1760
|
+
payload.update(
|
|
1761
|
+
{
|
|
1762
|
+
"run_id": str(run["run_id"]),
|
|
1763
|
+
"output_root": str(output_root),
|
|
1764
|
+
"collect_provider_job_id": provider_job_id,
|
|
1765
|
+
}
|
|
1766
|
+
)
|
|
1767
|
+
response = _run_adapter(command, timeout, payload, root)
|
|
1768
|
+
adapter_outputs = _validate_adapter_outputs(job, response, output_root)
|
|
1769
|
+
written: list[dict[str, Any]] = []
|
|
1770
|
+
with _project_lock(root):
|
|
1771
|
+
# A live `run_job` on the same provider task is indistinguishable
|
|
1772
|
+
# from a dead one — both sit at `running`. Re-read before writing,
|
|
1773
|
+
# or two writers race onto the same bytes and one record is lost.
|
|
1774
|
+
if _run_status(root, job_id, str(run["run_id"])) == "succeeded":
|
|
1775
|
+
raise RuntimeError(
|
|
1776
|
+
"这次尝试在取回期间已经自己完成了,产物已经落进项目;"
|
|
1777
|
+
"没有覆盖它——先看 status,确认无误就不需要再做别的"
|
|
1778
|
+
)
|
|
1779
|
+
for target_name, source in adapter_outputs:
|
|
1780
|
+
target = _project_file(root, target_name, create_parent=True)
|
|
1781
|
+
digest, size = _copy_output(
|
|
1782
|
+
source, target, overwrite=bool(job["overwrite"])
|
|
1783
|
+
)
|
|
1784
|
+
written.append(
|
|
1785
|
+
{
|
|
1786
|
+
"path": target_name,
|
|
1787
|
+
"media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
|
|
1788
|
+
"bytes": size,
|
|
1789
|
+
"sha256": digest,
|
|
1790
|
+
}
|
|
1791
|
+
)
|
|
1792
|
+
collected = dict(run)
|
|
1793
|
+
collected["status"] = "succeeded"
|
|
1794
|
+
collected["finished_at"] = utc_now()
|
|
1795
|
+
collected["outputs"] = written
|
|
1796
|
+
collected["provider_job_id"] = provider_job_id
|
|
1797
|
+
collected["collected"] = True
|
|
1798
|
+
collected.pop("error", None)
|
|
1799
|
+
_write_run(root, job_id, collected)
|
|
1800
|
+
return {
|
|
1801
|
+
"job_id": job_id,
|
|
1802
|
+
"run_id": str(run["run_id"]),
|
|
1803
|
+
"state": "succeeded",
|
|
1804
|
+
"collected": True,
|
|
1805
|
+
"provider_job_id": provider_job_id,
|
|
1806
|
+
"outputs": written,
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def job_status(root: Path, *, job_id: str) -> dict[str, Any]:
|
|
1811
|
+
root = find_project(root)
|
|
1812
|
+
job = _read_job(root, job_id)
|
|
1813
|
+
latest = _latest_run(root, job_id)
|
|
1814
|
+
if not _inputs_current(root, job):
|
|
1815
|
+
state = "needs_reconfirmation"
|
|
1816
|
+
else:
|
|
1817
|
+
try:
|
|
1818
|
+
receipt = _metadata_read_json(
|
|
1819
|
+
root,
|
|
1820
|
+
("confirmations",),
|
|
1821
|
+
f"{_job_key(job_id)}.json",
|
|
1822
|
+
maximum=MAX_RUN_RECORD_BYTES,
|
|
1823
|
+
)
|
|
1824
|
+
except FileNotFoundError:
|
|
1825
|
+
receipt = None
|
|
1826
|
+
if latest and latest.get("status") == "running":
|
|
1827
|
+
state = "running"
|
|
1828
|
+
elif latest and latest.get("status") in {"succeeded", "failed"}:
|
|
1829
|
+
state = str(latest["status"])
|
|
1830
|
+
elif isinstance(receipt, Mapping) and receipt.get("consumed_at") is None:
|
|
1831
|
+
state = "confirmed"
|
|
1832
|
+
else:
|
|
1833
|
+
state = "needs_confirmation"
|
|
1834
|
+
return {
|
|
1835
|
+
"job_id": job_id,
|
|
1836
|
+
"modality": job["modality"],
|
|
1837
|
+
"adapter": job["adapter"],
|
|
1838
|
+
"outputs": job["outputs"],
|
|
1839
|
+
"state": state,
|
|
1840
|
+
"latest_run": latest,
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _hash_production_output(root: Path, relative: str) -> tuple[str, int]:
|
|
1845
|
+
digest = hashlib.sha256()
|
|
1846
|
+
size = 0
|
|
1847
|
+
with _open_project_input(root, relative) as handle:
|
|
1848
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1849
|
+
size += len(chunk)
|
|
1850
|
+
if size > MAX_OUTPUT_BYTES:
|
|
1851
|
+
raise ValueError("production output exceeds the size limit")
|
|
1852
|
+
digest.update(chunk)
|
|
1853
|
+
return digest.hexdigest(), size
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def _validated_succeeded_outputs(
|
|
1857
|
+
job: Mapping[str, Any], run: Mapping[str, Any]
|
|
1858
|
+
) -> list[tuple[str, str, int]]:
|
|
1859
|
+
expected = job.get("outputs")
|
|
1860
|
+
entries = run.get("outputs")
|
|
1861
|
+
if (
|
|
1862
|
+
not isinstance(expected, list)
|
|
1863
|
+
or not isinstance(entries, list)
|
|
1864
|
+
or len(entries) != len(expected)
|
|
1865
|
+
):
|
|
1866
|
+
raise ValueError("succeeded outputs do not match the current job")
|
|
1867
|
+
validated: list[tuple[str, str, int]] = []
|
|
1868
|
+
# ``zip(strict=True)`` needs 3.10; the suite's floor is 3.9, so the length
|
|
1869
|
+
# agreement is asserted directly rather than by the zip flag.
|
|
1870
|
+
if len(expected) != len(entries):
|
|
1871
|
+
raise ValueError("expected %d outputs, got %d" % (len(expected), len(entries)))
|
|
1872
|
+
for expected_path, output in zip(expected, entries):
|
|
1873
|
+
if not isinstance(output, Mapping) or set(output) != {
|
|
1874
|
+
"path",
|
|
1875
|
+
"media_type",
|
|
1876
|
+
"bytes",
|
|
1877
|
+
"sha256",
|
|
1878
|
+
}:
|
|
1879
|
+
raise ValueError("succeeded output record fields are invalid")
|
|
1880
|
+
output_path = output.get("path")
|
|
1881
|
+
digest = output.get("sha256")
|
|
1882
|
+
size = output.get("bytes")
|
|
1883
|
+
media_type = output.get("media_type")
|
|
1884
|
+
if (
|
|
1885
|
+
not isinstance(expected_path, str)
|
|
1886
|
+
or output_path != expected_path
|
|
1887
|
+
or not isinstance(digest, str)
|
|
1888
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
1889
|
+
or not isinstance(size, int)
|
|
1890
|
+
or isinstance(size, bool)
|
|
1891
|
+
or size < 0
|
|
1892
|
+
or media_type
|
|
1893
|
+
!= MEDIA_TYPES.get(PurePosixPath(expected_path).suffix.casefold())
|
|
1894
|
+
):
|
|
1895
|
+
raise ValueError("succeeded output record is invalid")
|
|
1896
|
+
validated.append((output_path, digest, size))
|
|
1897
|
+
return validated
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def audit_project(root: Path) -> dict[str, Any]:
|
|
1901
|
+
"""Reconcile local attempt history and current output bytes, not media quality."""
|
|
1902
|
+
root = find_project(root)
|
|
1903
|
+
jobs: list[dict[str, Any]] = []
|
|
1904
|
+
problems: list[dict[str, Any]] = []
|
|
1905
|
+
for name in _metadata_json_names(root, ("jobs",)):
|
|
1906
|
+
try:
|
|
1907
|
+
document = _metadata_read_json(
|
|
1908
|
+
root, ("jobs",), name, maximum=MAX_JOB_BYTES
|
|
1909
|
+
)
|
|
1910
|
+
job = _validate_stored_job(root, document)
|
|
1911
|
+
job_id = job["job_id"]
|
|
1912
|
+
if not isinstance(job_id, str) or name != f"{_job_key(job_id)}.json":
|
|
1913
|
+
raise ValueError("stored job is invalid")
|
|
1914
|
+
jobs.append(job)
|
|
1915
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1916
|
+
problems.append(
|
|
1917
|
+
{
|
|
1918
|
+
"code": "invalid_job_record",
|
|
1919
|
+
"record": name,
|
|
1920
|
+
"action": "repair_production_metadata",
|
|
1921
|
+
}
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
attempts_total = 0
|
|
1925
|
+
attempts_succeeded = 0
|
|
1926
|
+
attempts_failed = 0
|
|
1927
|
+
attempts_running = 0
|
|
1928
|
+
attempts_superseded = 0
|
|
1929
|
+
repeated_content = 0
|
|
1930
|
+
recovered_jobs = 0
|
|
1931
|
+
running_jobs = 0
|
|
1932
|
+
terminal_failed_jobs = 0
|
|
1933
|
+
retryable_terminal_failed_jobs = 0
|
|
1934
|
+
current_output_claims: dict[
|
|
1935
|
+
str, tuple[tuple[datetime, str], str, int, str, str]
|
|
1936
|
+
] = {}
|
|
1937
|
+
|
|
1938
|
+
for job in jobs:
|
|
1939
|
+
job_id = str(job["job_id"])
|
|
1940
|
+
try:
|
|
1941
|
+
history = _read_run_history(root, job_id)
|
|
1942
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1943
|
+
problems.append(
|
|
1944
|
+
{
|
|
1945
|
+
"code": "invalid_run_history",
|
|
1946
|
+
"job_id": job_id,
|
|
1947
|
+
"action": "repair_production_metadata",
|
|
1948
|
+
}
|
|
1949
|
+
)
|
|
1950
|
+
continue
|
|
1951
|
+
attempts_total += len(history)
|
|
1952
|
+
completed = [run for run in history if run["status"] != "running"]
|
|
1953
|
+
running = [run for run in history if run["status"] == "running"]
|
|
1954
|
+
current = [
|
|
1955
|
+
run for run in history if run["fingerprint"] == job["fingerprint"]
|
|
1956
|
+
]
|
|
1957
|
+
current_completed = [run for run in current if run["status"] != "running"]
|
|
1958
|
+
current_running = [run for run in current if run["status"] == "running"]
|
|
1959
|
+
attempts_succeeded += sum(run["status"] == "succeeded" for run in completed)
|
|
1960
|
+
attempts_failed += sum(run["status"] == "failed" for run in completed)
|
|
1961
|
+
attempts_running += len(running)
|
|
1962
|
+
attempts_superseded += len(history) - len(current)
|
|
1963
|
+
for run in current:
|
|
1964
|
+
if run.get("status") == "succeeded":
|
|
1965
|
+
continue
|
|
1966
|
+
handle = run.get("provider_job_id") or _read_provider_handle(
|
|
1967
|
+
root, job_id, str(run["run_id"])
|
|
1968
|
+
)
|
|
1969
|
+
if not handle:
|
|
1970
|
+
continue
|
|
1971
|
+
problems.append(
|
|
1972
|
+
{
|
|
1973
|
+
"code": "orphaned_provider_job",
|
|
1974
|
+
"job_id": job_id,
|
|
1975
|
+
"run_id": run["run_id"],
|
|
1976
|
+
"provider_job_id": handle,
|
|
1977
|
+
"action": "collect_before_retry",
|
|
1978
|
+
# A live attempt and a dead one both sit at `running`, so
|
|
1979
|
+
# this finding cannot tell them apart. Collecting is still
|
|
1980
|
+
# the right first move — it is the only one that costs
|
|
1981
|
+
# nothing — and it refuses safely if the attempt finished.
|
|
1982
|
+
"note": "status 为 running 时它可能仍在正常轮询;collect 会先确认再写",
|
|
1983
|
+
}
|
|
1984
|
+
)
|
|
1985
|
+
if running:
|
|
1986
|
+
running_jobs += 1
|
|
1987
|
+
for run in running:
|
|
1988
|
+
problems.append(
|
|
1989
|
+
{
|
|
1990
|
+
"code": "running_attempt",
|
|
1991
|
+
"job_id": job_id,
|
|
1992
|
+
"run_id": str(run["run_id"]),
|
|
1993
|
+
"action": "wait_or_investigate_running_attempt",
|
|
1994
|
+
}
|
|
1995
|
+
)
|
|
1996
|
+
fingerprints = [
|
|
1997
|
+
str(run.get("fingerprint"))
|
|
1998
|
+
for run in history
|
|
1999
|
+
if isinstance(run.get("fingerprint"), str)
|
|
2000
|
+
]
|
|
2001
|
+
repeated_content += len(fingerprints) - len(set(fingerprints))
|
|
2002
|
+
succeeded_indexes = [
|
|
2003
|
+
index
|
|
2004
|
+
for index, run in enumerate(current_completed)
|
|
2005
|
+
if run["status"] == "succeeded"
|
|
2006
|
+
]
|
|
2007
|
+
failed_indexes = [
|
|
2008
|
+
index
|
|
2009
|
+
for index, run in enumerate(current_completed)
|
|
2010
|
+
if run["status"] == "failed"
|
|
2011
|
+
]
|
|
2012
|
+
if succeeded_indexes and failed_indexes and min(failed_indexes) < max(succeeded_indexes):
|
|
2013
|
+
recovered_jobs += 1
|
|
2014
|
+
if (
|
|
2015
|
+
not current_running
|
|
2016
|
+
and current_completed
|
|
2017
|
+
and current_completed[-1]["status"] == "failed"
|
|
2018
|
+
):
|
|
2019
|
+
terminal_failed_jobs += 1
|
|
2020
|
+
error = current_completed[-1].get("error")
|
|
2021
|
+
retryable = isinstance(error, Mapping) and error.get("retryable") is True
|
|
2022
|
+
if retryable:
|
|
2023
|
+
retryable_terminal_failed_jobs += 1
|
|
2024
|
+
problems.append(
|
|
2025
|
+
{
|
|
2026
|
+
"code": (
|
|
2027
|
+
"terminal_retryable_failure" if retryable else "terminal_failure"
|
|
2028
|
+
),
|
|
2029
|
+
"job_id": job_id,
|
|
2030
|
+
"run_id": current_completed[-1]["run_id"],
|
|
2031
|
+
"action": (
|
|
2032
|
+
"inspect_then_reconfirm_retry" if retryable else "inspect_failure"
|
|
2033
|
+
),
|
|
2034
|
+
}
|
|
2035
|
+
)
|
|
2036
|
+
for run in current:
|
|
2037
|
+
if run["status"] != "succeeded":
|
|
2038
|
+
continue
|
|
2039
|
+
try:
|
|
2040
|
+
outputs = _validated_succeeded_outputs(job, run)
|
|
2041
|
+
except ValueError:
|
|
2042
|
+
problems.append(
|
|
2043
|
+
{
|
|
2044
|
+
"code": "invalid_succeeded_output_record",
|
|
2045
|
+
"job_id": job_id,
|
|
2046
|
+
"run_id": str(run["run_id"]),
|
|
2047
|
+
"action": "repair_production_metadata",
|
|
2048
|
+
}
|
|
2049
|
+
)
|
|
2050
|
+
continue
|
|
2051
|
+
for output_path, digest, size in outputs:
|
|
2052
|
+
run_id = str(run["run_id"])
|
|
2053
|
+
claim_order = _completed_run_order(run)
|
|
2054
|
+
claim = current_output_claims.get(output_path)
|
|
2055
|
+
if claim is None or claim_order > claim[0]:
|
|
2056
|
+
current_output_claims[output_path] = (
|
|
2057
|
+
claim_order,
|
|
2058
|
+
digest,
|
|
2059
|
+
size,
|
|
2060
|
+
job_id,
|
|
2061
|
+
run_id,
|
|
2062
|
+
)
|
|
2063
|
+
|
|
2064
|
+
if not _inputs_current(root, job):
|
|
2065
|
+
problems.append(
|
|
2066
|
+
{
|
|
2067
|
+
"code": "job_inputs_changed",
|
|
2068
|
+
"job_id": job_id,
|
|
2069
|
+
"action": "prepare_and_confirm_again",
|
|
2070
|
+
}
|
|
2071
|
+
)
|
|
2072
|
+
|
|
2073
|
+
output_verified = 0
|
|
2074
|
+
output_missing = 0
|
|
2075
|
+
output_modified = 0
|
|
2076
|
+
for relative, (
|
|
2077
|
+
_claim_order,
|
|
2078
|
+
expected_digest,
|
|
2079
|
+
expected_size,
|
|
2080
|
+
job_id,
|
|
2081
|
+
run_id,
|
|
2082
|
+
) in sorted(current_output_claims.items()):
|
|
2083
|
+
try:
|
|
2084
|
+
safe_relative = _relative_path(relative, output=True)
|
|
2085
|
+
digest, size = _hash_production_output(root, safe_relative)
|
|
2086
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
2087
|
+
output_missing += 1
|
|
2088
|
+
problems.append(
|
|
2089
|
+
{
|
|
2090
|
+
"code": "output_missing_or_unsafe",
|
|
2091
|
+
"job_id": job_id,
|
|
2092
|
+
"run_id": run_id,
|
|
2093
|
+
"path": relative,
|
|
2094
|
+
"action": "restore_or_reproduce_output",
|
|
2095
|
+
}
|
|
2096
|
+
)
|
|
2097
|
+
continue
|
|
2098
|
+
if digest != expected_digest or size != expected_size:
|
|
2099
|
+
output_modified += 1
|
|
2100
|
+
problems.append(
|
|
2101
|
+
{
|
|
2102
|
+
"code": "output_digest_mismatch",
|
|
2103
|
+
"job_id": job_id,
|
|
2104
|
+
"run_id": run_id,
|
|
2105
|
+
"path": relative,
|
|
2106
|
+
"action": "review_current_bytes_or_reproduce_output",
|
|
2107
|
+
}
|
|
2108
|
+
)
|
|
2109
|
+
else:
|
|
2110
|
+
output_verified += 1
|
|
2111
|
+
|
|
2112
|
+
return {
|
|
2113
|
+
"status": "attention" if problems else "pass",
|
|
2114
|
+
"scope": "operational_evidence_only",
|
|
2115
|
+
"quality_verdict": "not_assessed",
|
|
2116
|
+
"jobs": {
|
|
2117
|
+
"total": len(jobs),
|
|
2118
|
+
"running": running_jobs,
|
|
2119
|
+
"recovered": recovered_jobs,
|
|
2120
|
+
"terminal_failed": terminal_failed_jobs,
|
|
2121
|
+
"retryable_terminal_failed": retryable_terminal_failed_jobs,
|
|
2122
|
+
},
|
|
2123
|
+
"attempts": {
|
|
2124
|
+
"total": attempts_total,
|
|
2125
|
+
"succeeded": attempts_succeeded,
|
|
2126
|
+
"failed": attempts_failed,
|
|
2127
|
+
"running": attempts_running,
|
|
2128
|
+
"superseded": attempts_superseded,
|
|
2129
|
+
"repeated_content": repeated_content,
|
|
2130
|
+
},
|
|
2131
|
+
"outputs": {
|
|
2132
|
+
"claimed_current": len(current_output_claims),
|
|
2133
|
+
"verified": output_verified,
|
|
2134
|
+
"missing": output_missing,
|
|
2135
|
+
"modified": output_modified,
|
|
2136
|
+
},
|
|
2137
|
+
"problems": problems,
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2142
|
+
parser = argparse.ArgumentParser(description="Run confirmed short-drama media jobs.")
|
|
2143
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
2144
|
+
prepare = commands.add_parser("prepare", help="Validate and preview a media job.")
|
|
2145
|
+
prepare.add_argument("project")
|
|
2146
|
+
prepare.add_argument("--job", required=True)
|
|
2147
|
+
confirm = commands.add_parser("confirm", help="Confirm the exact prepared job.")
|
|
2148
|
+
confirm.add_argument("project")
|
|
2149
|
+
confirm.add_argument("--job-id", required=True)
|
|
2150
|
+
confirm.add_argument("--confirmation", required=True)
|
|
2151
|
+
run = commands.add_parser("run", help="Execute a confirmed job through an adapter.")
|
|
2152
|
+
run.add_argument("project")
|
|
2153
|
+
run.add_argument("--job-id", required=True)
|
|
2154
|
+
run.add_argument("--adapter-config", required=True)
|
|
2155
|
+
collect = commands.add_parser(
|
|
2156
|
+
"collect",
|
|
2157
|
+
help="Fetch the result of an already-submitted provider task without paying again.",
|
|
2158
|
+
)
|
|
2159
|
+
collect.add_argument("project")
|
|
2160
|
+
collect.add_argument("--job-id", required=True)
|
|
2161
|
+
collect.add_argument("--adapter-config", required=True)
|
|
2162
|
+
status = commands.add_parser("status", help="Show one media job state.")
|
|
2163
|
+
status.add_argument("project")
|
|
2164
|
+
status.add_argument("--job-id", required=True)
|
|
2165
|
+
audit = commands.add_parser(
|
|
2166
|
+
"audit", help="Reconcile attempt history and current output bytes."
|
|
2167
|
+
)
|
|
2168
|
+
audit.add_argument("project")
|
|
2169
|
+
return parser
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2173
|
+
args = build_parser().parse_args(argv)
|
|
2174
|
+
try:
|
|
2175
|
+
if args.command == "prepare":
|
|
2176
|
+
result = prepare_job(Path(args.project), Path(args.job))
|
|
2177
|
+
elif args.command == "confirm":
|
|
2178
|
+
result = confirm_job(
|
|
2179
|
+
Path(args.project), job_id=args.job_id, confirmation=args.confirmation
|
|
2180
|
+
)
|
|
2181
|
+
elif args.command == "run":
|
|
2182
|
+
result = run_job(
|
|
2183
|
+
Path(args.project),
|
|
2184
|
+
job_id=args.job_id,
|
|
2185
|
+
adapter_config=Path(args.adapter_config),
|
|
2186
|
+
)
|
|
2187
|
+
elif args.command == "collect":
|
|
2188
|
+
result = collect_job(
|
|
2189
|
+
Path(args.project),
|
|
2190
|
+
job_id=args.job_id,
|
|
2191
|
+
adapter_config=Path(args.adapter_config),
|
|
2192
|
+
)
|
|
2193
|
+
elif args.command == "status":
|
|
2194
|
+
result = job_status(Path(args.project), job_id=args.job_id)
|
|
2195
|
+
else:
|
|
2196
|
+
result = audit_project(Path(args.project))
|
|
2197
|
+
print(json.dumps(result, ensure_ascii=True, sort_keys=True))
|
|
2198
|
+
return 0
|
|
2199
|
+
except Exception as exc:
|
|
2200
|
+
print(str(exc), file=sys.stderr)
|
|
2201
|
+
return 2
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
if __name__ == "__main__":
|
|
2205
|
+
raise SystemExit(main())
|