@oh-story/dsh 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1201 -0
- package/lib/client.js.map +7 -0
- package/lib/drama/LICENSE.upstream +21 -0
- package/lib/drama/manifest.json +1012 -0
- package/lib/drama/skills/short-drama/SKILL.md +153 -0
- package/lib/drama/skills/short-drama/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
- package/lib/drama/skills/short-drama/assets/production-observation.example.jsonl +2 -0
- package/lib/drama/skills/short-drama/assets/project-template/short-drama.json +36 -0
- package/lib/drama/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
- package/lib/drama/skills/short-drama/references/audience-reveal.md +74 -0
- package/lib/drama/skills/short-drama/references/contract-and-ownership.md +94 -0
- package/lib/drama/skills/short-drama/references/creator-workflow.md +82 -0
- package/lib/drama/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
- package/lib/drama/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/lib/drama/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/lib/drama/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
- package/lib/drama/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
- package/lib/drama/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/lib/drama/skills/short-drama/references/knowhow-index.md +80 -0
- package/lib/drama/skills/short-drama/references/lifecycle-commands.md +130 -0
- package/lib/drama/skills/short-drama/references/look-development.md +52 -0
- package/lib/drama/skills/short-drama/references/pickup-and-alternate.md +58 -0
- package/lib/drama/skills/short-drama/references/production-form-profiles.md +126 -0
- package/lib/drama/skills/short-drama/references/reference-roles.md +142 -0
- package/lib/drama/skills/short-drama/references/runtime-preflight.md +27 -0
- package/lib/drama/skills/short-drama/scripts/project_tool.py +1963 -0
- package/lib/drama/skills/short-drama/scripts/selftest.py +287 -0
- package/lib/drama/skills/short-drama-assets/SKILL.md +187 -0
- package/lib/drama/skills/short-drama-assets/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
- package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
- package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
- package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
- package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
- package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
- package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
- package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
- package/lib/drama/skills/short-drama-assets/references/asset-review-checklist.md +89 -0
- package/lib/drama/skills/short-drama-assets/references/character-and-look.md +164 -0
- package/lib/drama/skills/short-drama-assets/references/continuity-delta.md +123 -0
- package/lib/drama/skills/short-drama-assets/references/identity-vs-variant.md +146 -0
- package/lib/drama/skills/short-drama-assets/references/location-and-view.md +84 -0
- package/lib/drama/skills/short-drama-assets/references/occurrence-extraction.md +111 -0
- package/lib/drama/skills/short-drama-assets/references/prop-and-state.md +107 -0
- package/lib/drama/skills/short-drama-assets/references/stage-contract.md +136 -0
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +171 -0
- package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +267 -0
- package/lib/drama/skills/short-drama-assets/scripts/selftest.py +89 -0
- package/lib/drama/skills/short-drama-develop/SKILL.md +157 -0
- package/lib/drama/skills/short-drama-develop/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
- package/lib/drama/skills/short-drama-develop/assets/creative-brief.md +120 -0
- package/lib/drama/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
- package/lib/drama/skills/short-drama-develop/assets/story-engine.md +181 -0
- package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +219 -0
- package/lib/drama/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
- package/lib/drama/skills/short-drama-develop/references/director-brief-craft.md +113 -0
- package/lib/drama/skills/short-drama-develop/references/episode-design.md +509 -0
- package/lib/drama/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards.md +92 -0
- package/lib/drama/skills/short-drama-develop/references/mechanism-loop.md +282 -0
- package/lib/drama/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
- package/lib/drama/skills/short-drama-develop/references/premise-devices.md +211 -0
- package/lib/drama/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
- package/lib/drama/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
- package/lib/drama/skills/short-drama-develop/references/stage-contract.md +88 -0
- package/lib/drama/skills/short-drama-develop/references/story-craft.md +359 -0
- package/lib/drama/skills/short-drama-develop/scripts/episode_intake.py +699 -0
- package/lib/drama/skills/short-drama-develop/scripts/selftest.py +47 -0
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +181 -0
- package/lib/drama/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +37 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +31 -0
- package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
- package/lib/drama/skills/short-drama-image-prompts/references/character-and-look.md +154 -0
- package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +231 -0
- package/lib/drama/skills/short-drama-image-prompts/references/edit-and-revision.md +122 -0
- package/lib/drama/skills/short-drama-image-prompts/references/location-plate.md +152 -0
- package/lib/drama/skills/short-drama-image-prompts/references/look-and-state-variant.md +96 -0
- package/lib/drama/skills/short-drama-image-prompts/references/lookdev-frame.md +38 -0
- package/lib/drama/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
- package/lib/drama/skills/short-drama-image-prompts/references/prop-plate.md +140 -0
- package/lib/drama/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
- package/lib/drama/skills/short-drama-image-prompts/references/stage-contract.md +89 -0
- package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
- package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +93 -0
- package/lib/drama/skills/short-drama-novel-analyze/SKILL.md +245 -0
- package/lib/drama/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +141 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
- package/lib/drama/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
- package/lib/drama/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +103 -0
- package/lib/drama/skills/short-drama-produce/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +140 -0
- package/lib/drama/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
- package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +40 -0
- package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +60 -0
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +1635 -0
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +964 -0
- package/lib/drama/skills/short-drama-produce/scripts/selftest.py +134 -0
- package/lib/drama/skills/short-drama-review/SKILL.md +178 -0
- package/lib/drama/skills/short-drama-review/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +2 -0
- package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
- package/lib/drama/skills/short-drama-review/assets/verdict-template.json +32 -0
- package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
- package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +34 -0
- package/lib/drama/skills/short-drama-review/references/anti-template-repair.md +114 -0
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +270 -0
- package/lib/drama/skills/short-drama-review/references/project-calibration.md +85 -0
- package/lib/drama/skills/short-drama-review/references/review-method.md +82 -0
- package/lib/drama/skills/short-drama-review/references/rubric-assets-prompts.md +115 -0
- package/lib/drama/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
- package/lib/drama/skills/short-drama-review/references/rubric-story-script.md +164 -0
- package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +166 -0
- package/lib/drama/skills/short-drama-review/references/stage-contract.md +87 -0
- package/lib/drama/skills/short-drama-review/scripts/review_check.py +325 -0
- package/lib/drama/skills/short-drama-review/scripts/selftest.py +87 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +264 -0
- package/lib/drama/skills/short-drama-storyboard/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
- package/lib/drama/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
- package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +32 -0
- package/lib/drama/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
- package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
- package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
- package/lib/drama/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
- package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +241 -0
- package/lib/drama/skills/short-drama-storyboard/references/coverage-audition.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +169 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +264 -0
- package/lib/drama/skills/short-drama-storyboard/references/review-and-fixtures.md +70 -0
- package/lib/drama/skills/short-drama-storyboard/references/scene-visual-plan.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +157 -0
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +192 -0
- package/lib/drama/skills/short-drama-storyboard/references/shot-revision-identity.md +29 -0
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +110 -0
- package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +191 -0
- package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +245 -0
- package/lib/drama/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +212 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
- package/lib/drama/skills/short-drama-video-prompts/assets/video-prompts.md +46 -0
- package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +240 -0
- package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +255 -0
- package/lib/drama/skills/short-drama-video-prompts/references/generability.md +93 -0
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +304 -0
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +191 -0
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +312 -0
- package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +168 -0
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +110 -0
- package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
- package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
- package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
- package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
- package/lib/drama/skills/short-drama-write/SKILL.md +284 -0
- package/lib/drama/skills/short-drama-write/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-write/assets/beats.jsonl +2 -0
- package/lib/drama/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
- package/lib/drama/skills/short-drama-write/assets/episode-card.json +55 -0
- package/lib/drama/skills/short-drama-write/assets/screenplay.md +36 -0
- package/lib/drama/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
- package/lib/drama/skills/short-drama-write/references/dialogue-craft.md +519 -0
- package/lib/drama/skills/short-drama-write/references/production-format-dialect.md +273 -0
- package/lib/drama/skills/short-drama-write/references/scene-handoff-capsule.md +51 -0
- package/lib/drama/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
- package/lib/drama/skills/short-drama-write/references/screenplay-format.md +260 -0
- package/lib/drama/skills/short-drama-write/references/script-craft.md +558 -0
- package/lib/drama/skills/short-drama-write/references/stage-contract.md +94 -0
- package/lib/drama/skills/short-drama-write/references/substitutable-realization.md +107 -0
- package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +309 -0
- package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
- package/lib/drama/skills/short-drama-write/scripts/selftest.py +229 -0
- package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
- package/lib/index.js +756 -0
- package/lib/index.js.map +7 -0
- package/lib/oh-story/LICENSE.upstream +21 -0
- package/lib/oh-story/manifest.json +1351 -0
- package/lib/oh-story/roles/chapter-extractor.md +314 -0
- package/lib/oh-story/roles/character-designer.md +169 -0
- package/lib/oh-story/roles/consistency-checker.md +190 -0
- package/lib/oh-story/roles/narrative-writer.md +294 -0
- package/lib/oh-story/roles/story-architect.md +195 -0
- package/lib/oh-story/roles/story-explorer.md +364 -0
- package/lib/oh-story/roles/story-researcher.md +304 -0
- package/lib/oh-story/skills/browser-cdp/SKILL.md +175 -0
- package/lib/oh-story/skills/story/SKILL.md +116 -0
- package/lib/oh-story/skills/story/VERSION +1 -0
- package/lib/oh-story/skills/story-cover/SKILL.md +327 -0
- package/lib/oh-story/skills/story-cover/references/cover-styles.md +210 -0
- package/lib/oh-story/skills/story-deslop/SKILL.md +445 -0
- package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +582 -0
- package/lib/oh-story/skills/story-deslop/references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +1371 -0
- package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +330 -0
- package/lib/oh-story/skills/story-deslop/scripts/check-outline-copy.js +241 -0
- package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +410 -0
- package/lib/oh-story/skills/story-import/SKILL.md +634 -0
- package/lib/oh-story/skills/story-import/references/character-state-reverse.md +82 -0
- package/lib/oh-story/skills/story-import/references/format-and-structure.md +169 -0
- package/lib/oh-story/skills/story-import/references/length-routing.md +134 -0
- package/lib/oh-story/skills/story-import/references/state-tracking.md +108 -0
- package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +442 -0
- package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +175 -0
- package/lib/oh-story/skills/story-import/references/tracking-transaction.md +161 -0
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +1139 -0
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +329 -0
- package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +270 -0
- package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +530 -0
- package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +559 -0
- package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +80 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +149 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +133 -0
- package/lib/oh-story/skills/story-long-scan/SKILL.md +342 -0
- package/lib/oh-story/skills/story-long-scan/references/genre-trends.md +197 -0
- package/lib/oh-story/skills/story-long-scan/references/publishing-guide.md +114 -0
- package/lib/oh-story/skills/story-long-scan/references/reader-profiling.md +105 -0
- package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +207 -0
- package/lib/oh-story/skills/story-long-scan/references/topic-decision.md +78 -0
- package/lib/oh-story/skills/story-long-write/SKILL.md +327 -0
- package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +582 -0
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +302 -0
- package/lib/oh-story/skills/story-long-write/references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-long-write/references/character-basics.md +445 -0
- package/lib/oh-story/skills/story-long-write/references/character-design-methods.md +428 -0
- package/lib/oh-story/skills/story-long-write/references/character-relations.md +385 -0
- package/lib/oh-story/skills/story-long-write/references/commercial-core-methods.md +502 -0
- package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +62 -0
- package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +282 -0
- package/lib/oh-story/skills/story-long-write/references/emotional-arc-design.md +426 -0
- package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +209 -0
- package/lib/oh-story/skills/story-long-write/references/female-audience-writing.md +188 -0
- package/lib/oh-story/skills/story-long-write/references/format-and-structure.md +169 -0
- package/lib/oh-story/skills/story-long-write/references/genre-catalog.md +480 -0
- package/lib/oh-story/skills/story-long-write/references/genre-core-mechanics.md +415 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//344/270/234/346/226/271/344/273/231/344/276/240.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//344/274/240/347/273/237/347/216/204/345/271/273.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/216/206/345/217/262/345/217/244/344/273/243.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/216/206/345/217/262/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/214/347/224/267/344/270/273.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/244/350/250/200/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/244/351/243/216/344/270/226/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/245/263/351/242/221/346/202/254/347/226/221.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/245/263/351/242/221/347/247/215/347/224/260.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/256/253/346/226/227/345/256/205/346/226/227.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/271/264/344/273/243.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/277/253/347/251/277.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/202/254/347/226/221/347/201/265/345/274/202.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/202/254/347/226/221/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/210/230/347/245/236/350/265/230/345/251/277.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/212/227/346/210/230/350/260/215/346/210/230.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/230/237/345/205/211/347/222/200/347/222/250.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/260/221/345/233/275/350/250/200/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/270/270/346/210/217/344/275/223/350/202/262.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/204/345/271/273/350/204/221/346/264/236.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/204/345/271/273/350/250/200/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/260/350/250/200/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/247/221/345/271/273/346/234/253/344/270/226.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/201/214/345/234/272/345/251/232/346/201/213.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/245/277/346/226/271/345/245/207/345/271/273.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/261/252/351/227/250/346/200/273/350/243/201.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/344/277/256/347/234/237.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/346/227/245/345/270/270.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/347/247/215/347/224/260.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/350/204/221/346/264/236.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/351/253/230/346/255/246.md +53 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/235/222/346/230/245/347/224/234/345/256/240.md +51 -0
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +91 -0
- package/lib/oh-story/skills/story-long-write/references/genre-readers.md +235 -0
- package/lib/oh-story/skills/story-long-write/references/genre-writing-formulas.md +444 -0
- package/lib/oh-story/skills/story-long-write/references/hooks-chapter.md +225 -0
- package/lib/oh-story/skills/story-long-write/references/hooks-paragraph.md +146 -0
- package/lib/oh-story/skills/story-long-write/references/hooks-suspense.md +352 -0
- package/lib/oh-story/skills/story-long-write/references/opening-design.md +349 -0
- package/lib/oh-story/skills/story-long-write/references/outline-conflict.md +457 -0
- package/lib/oh-story/skills/story-long-write/references/outline-methods.md +287 -0
- package/lib/oh-story/skills/story-long-write/references/outline-rhythm.md +389 -0
- package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +326 -0
- package/lib/oh-story/skills/story-long-write/references/plot-core-methods.md +522 -0
- package/lib/oh-story/skills/story-long-write/references/plot-emotion-system.md +354 -0
- package/lib/oh-story/skills/story-long-write/references/plot-frameworks.md +711 -0
- package/lib/oh-story/skills/story-long-write/references/plot-special-topics.md +622 -0
- package/lib/oh-story/skills/story-long-write/references/quality-checklist.md +184 -0
- package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +109 -0
- package/lib/oh-story/skills/story-long-write/references/reversal-toolkit.md +349 -0
- package/lib/oh-story/skills/story-long-write/references/state-tracking.md +108 -0
- package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +409 -0
- package/lib/oh-story/skills/story-long-write/references/style-craft.md +347 -0
- package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +510 -0
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +161 -0
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +140 -0
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +171 -0
- package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +99 -0
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +284 -0
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +421 -0
- package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +1371 -0
- package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +330 -0
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +241 -0
- package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +410 -0
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +1139 -0
- package/lib/oh-story/skills/story-review/SKILL.md +483 -0
- package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +582 -0
- package/lib/oh-story/skills/story-review/references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-review/references/character-relations.md +385 -0
- package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +282 -0
- package/lib/oh-story/skills/story-review/references/plot-core-methods.md +522 -0
- package/lib/oh-story/skills/story-review/references/quality-checklist.md +250 -0
- package/lib/oh-story/skills/story-review/references/quality-rubric.md +55 -0
- package/lib/oh-story/skills/story-review/references/rubrics/fanqie.md +18 -0
- package/lib/oh-story/skills/story-review/references/rubrics/qidian.md +19 -0
- package/lib/oh-story/skills/story-review/references/rubrics/zhihu.md +20 -0
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +161 -0
- package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +1371 -0
- package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +330 -0
- package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +410 -0
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +1139 -0
- package/lib/oh-story/skills/story-setup/SKILL.md +495 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +582 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/character-basics.md +445 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/character-design-methods.md +428 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/character-relations.md +385 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +282 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/emotional-arc-design.md +426 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/emotional-methods.md +179 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +169 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-catalog.md +480 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-core-mechanics.md +415 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//344/270/234/346/226/271/344/273/231/344/276/240.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//344/274/240/347/273/237/347/216/204/345/271/273.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/216/206/345/217/262/345/217/244/344/273/243.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/216/206/345/217/262/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/214/347/224/267/344/270/273.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/244/350/250/200/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/244/351/243/216/344/270/226/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/245/263/351/242/221/346/202/254/347/226/221.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/245/263/351/242/221/347/247/215/347/224/260.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/256/253/346/226/227/345/256/205/346/226/227.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/271/264/344/273/243.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/277/253/347/251/277.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/202/254/347/226/221/347/201/265/345/274/202.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/202/254/347/226/221/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/210/230/347/245/236/350/265/230/345/251/277.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/212/227/346/210/230/350/260/215/346/210/230.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/230/237/345/205/211/347/222/200/347/222/250.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/260/221/345/233/275/350/250/200/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/270/270/346/210/217/344/275/223/350/202/262.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/204/345/271/273/350/204/221/346/264/236.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/204/345/271/273/350/250/200/346/203/205.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/260/350/250/200/350/204/221/346/264/236.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/247/221/345/271/273/346/234/253/344/270/226.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/201/214/345/234/272/345/251/232/346/201/213.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/245/277/346/226/271/345/245/207/345/271/273.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/261/252/351/227/250/346/200/273/350/243/201.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/344/277/256/347/234/237.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/346/227/245/345/270/270.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/347/247/215/347/224/260.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/350/204/221/346/264/236.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/351/253/230/346/255/246.md +53 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/235/222/346/230/245/347/224/234/345/256/240.md +51 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +91 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-readers.md +235 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-writing-formulas.md +444 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/hooks-chapter.md +225 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/hooks-suspense.md +352 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +349 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-conflict.md +457 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-methods.md +287 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-rhythm.md +389 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/plot-core-methods.md +522 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/quality-checklist.md +247 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/reversal-toolkit.md +349 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +409 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +510 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +421 -0
- package/lib/oh-story/skills/story-short-analyze/SKILL.md +278 -0
- package/lib/oh-story/skills/story-short-analyze/references/anti-ai-writing.md +582 -0
- package/lib/oh-story/skills/story-short-analyze/references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-short-analyze/references/character-basics.md +453 -0
- package/lib/oh-story/skills/story-short-analyze/references/character-design-methods.md +438 -0
- package/lib/oh-story/skills/story-short-analyze/references/character-relations.md +395 -0
- package/lib/oh-story/skills/story-short-analyze/references/deconstruction-examples.md +79 -0
- package/lib/oh-story/skills/story-short-analyze/references/genre-catalog.md +490 -0
- package/lib/oh-story/skills/story-short-analyze/references/genre-core-mechanics.md +425 -0
- package/lib/oh-story/skills/story-short-analyze/references/genre-readers.md +245 -0
- package/lib/oh-story/skills/story-short-analyze/references/genre-writing-formulas.md +453 -0
- package/lib/oh-story/skills/story-short-analyze/references/genre-writing-techniques.md +475 -0
- package/lib/oh-story/skills/story-short-analyze/references/hooks-chapter.md +225 -0
- package/lib/oh-story/skills/story-short-analyze/references/hooks-paragraph.md +146 -0
- package/lib/oh-story/skills/story-short-analyze/references/hooks-suspense.md +352 -0
- package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +685 -0
- package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +165 -0
- package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +561 -0
- package/lib/oh-story/skills/story-short-analyze/references/quality-checklist.md +242 -0
- package/lib/oh-story/skills/story-short-analyze/references/real-market-data.md +90 -0
- package/lib/oh-story/skills/story-short-analyze/references/zhihu-style.md +54 -0
- package/lib/oh-story/skills/story-short-scan/SKILL.md +212 -0
- package/lib/oh-story/skills/story-short-scan/references/real-market-data.md +90 -0
- package/lib/oh-story/skills/story-short-write/SKILL.md +433 -0
- package/lib/oh-story/skills/story-short-write/references/banned-words.md +147 -0
- package/lib/oh-story/skills/story-short-write/references/cross-book-recall.md +62 -0
- package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +282 -0
- package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +179 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +159 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +156 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +163 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +167 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +178 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +166 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +159 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/262/231/351/233/225/350/204/221/346/264/236.md +151 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +153 -0
- package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +210 -0
- package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +444 -0
- package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +488 -0
- package/lib/oh-story/skills/story-short-write/references/hooks-chapter.md +225 -0
- package/lib/oh-story/skills/story-short-write/references/hooks-paragraph.md +146 -0
- package/lib/oh-story/skills/story-short-write/references/hooks-suspense.md +352 -0
- package/lib/oh-story/skills/story-short-write/references/output-contract.md +165 -0
- package/lib/oh-story/skills/story-short-write/references/quality-checklist.md +222 -0
- package/lib/oh-story/skills/story-short-write/references/reversal-toolkit.md +349 -0
- package/lib/oh-story/skills/story-short-write/references/short-craft.md +185 -0
- package/lib/oh-story/skills/story-short-write/references/short-deslop.md +133 -0
- package/lib/oh-story/skills/story-short-write/references/short-format.md +167 -0
- package/lib/oh-story/skills/story-short-write/references/submission-craft.md +35 -0
- package/lib/oh-story/skills/story-short-write/references/villain-and-reveal.md +158 -0
- package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +153 -0
- package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +1371 -0
- package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +330 -0
- package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +241 -0
- package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +410 -0
- package/lib/types/client/file-activity.d.ts +30 -0
- package/lib/types/client/file-activity.d.ts.map +1 -0
- package/lib/types/client/file-tree.d.ts +20 -0
- package/lib/types/client/file-tree.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/jsonl-preview.d.ts +15 -0
- package/lib/types/client/jsonl-preview.d.ts.map +1 -0
- package/lib/types/client/markdown-preview.d.ts +38 -0
- package/lib/types/client/markdown-preview.d.ts.map +1 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/native-hooks.d.ts +16 -0
- package/lib/types/native-hooks.d.ts.map +1 -0
- package/lib/types/role-provider.d.ts +6 -0
- package/lib/types/role-provider.d.ts.map +1 -0
- package/lib/types/role-tool.d.ts +10 -0
- package/lib/types/role-tool.d.ts.map +1 -0
- package/lib/types/skill-provider.d.ts +15 -0
- package/lib/types/skill-provider.d.ts.map +1 -0
- package/lib/types/workspace-request-trust.d.ts +13 -0
- package/lib/types/workspace-request-trust.d.ts.map +1 -0
- package/lib/types/workspace-route.d.ts +10 -0
- package/lib/types/workspace-route.d.ts.map +1 -0
- package/lib/types/workspace-security.d.ts +9 -0
- package/lib/types/workspace-security.d.ts.map +1 -0
- package/package.json +107 -0
|
@@ -0,0 +1,1635 @@
|
|
|
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
|
+
"references",
|
|
67
|
+
"outputs",
|
|
68
|
+
"parameters",
|
|
69
|
+
"overwrite",
|
|
70
|
+
}
|
|
71
|
+
STORED_EXECUTION_KEYS = ALLOWED_JOB_KEYS | {"inputs"}
|
|
72
|
+
STORED_JOB_KEYS = STORED_EXECUTION_KEYS | {"fingerprint", "prepared_at"}
|
|
73
|
+
SECRET_KEYS = {
|
|
74
|
+
"authorization",
|
|
75
|
+
"credential",
|
|
76
|
+
"credentials",
|
|
77
|
+
"password",
|
|
78
|
+
"secret",
|
|
79
|
+
"token",
|
|
80
|
+
"access_token",
|
|
81
|
+
"api_key",
|
|
82
|
+
"apikey",
|
|
83
|
+
}
|
|
84
|
+
MEDIA_EXTENSIONS = {
|
|
85
|
+
"image": {".png", ".jpg", ".jpeg", ".webp"},
|
|
86
|
+
"video": {".mp4", ".mov", ".webm"},
|
|
87
|
+
"tts": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
|
|
88
|
+
"music": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
|
|
89
|
+
}
|
|
90
|
+
MEDIA_TYPES = {
|
|
91
|
+
".png": "image/png",
|
|
92
|
+
".jpg": "image/jpeg",
|
|
93
|
+
".jpeg": "image/jpeg",
|
|
94
|
+
".webp": "image/webp",
|
|
95
|
+
".mp4": "video/mp4",
|
|
96
|
+
".mov": "video/quicktime",
|
|
97
|
+
".webm": "video/webm",
|
|
98
|
+
".wav": "audio/wav",
|
|
99
|
+
".mp3": "audio/mpeg",
|
|
100
|
+
".m4a": "audio/mp4",
|
|
101
|
+
".aac": "audio/aac",
|
|
102
|
+
".flac": "audio/flac",
|
|
103
|
+
".opus": "audio/ogg",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConfirmationRequiredError(RuntimeError):
|
|
108
|
+
"""The exact current job has not been explicitly confirmed."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AdapterError(RuntimeError):
|
|
112
|
+
"""A configured media adapter failed or broke its output contract."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self, message: str, *, public_error: Mapping[str, Any] | None = None
|
|
116
|
+
) -> None:
|
|
117
|
+
super().__init__(message)
|
|
118
|
+
self.public_error = dict(public_error) if public_error is not None else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def utc_now() -> str:
|
|
122
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sha256_bytes(content: bytes) -> str:
|
|
126
|
+
return hashlib.sha256(content).hexdigest()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_project(start: Path) -> Path:
|
|
130
|
+
candidate = start.expanduser().resolve()
|
|
131
|
+
if candidate.is_file():
|
|
132
|
+
candidate = candidate.parent
|
|
133
|
+
for directory in (candidate, *candidate.parents):
|
|
134
|
+
if (directory / PROJECT_FILE).is_file():
|
|
135
|
+
return directory
|
|
136
|
+
raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _relative_path(value: object, *, output: bool = False) -> str:
|
|
140
|
+
if not isinstance(value, str):
|
|
141
|
+
raise ValueError("project paths must be strings")
|
|
142
|
+
raw = value.replace("\\", "/")
|
|
143
|
+
pure = PurePosixPath(raw)
|
|
144
|
+
if not raw or pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
|
|
145
|
+
raise ValueError(f"unsafe project-relative path: {value}")
|
|
146
|
+
if pure.parts[0].casefold() == ".short-drama" or pure.name.casefold() == PROJECT_FILE:
|
|
147
|
+
raise ValueError(f"operational project path is not allowed: {value}")
|
|
148
|
+
if output:
|
|
149
|
+
parts = pure.parts
|
|
150
|
+
top_level_production = len(parts) >= 2 and parts[0].casefold() == "production"
|
|
151
|
+
episode_production = (
|
|
152
|
+
len(parts) >= 4
|
|
153
|
+
and parts[0] in {"剧集", "episodes"}
|
|
154
|
+
and re.fullmatch(r"EP\d{3,}", parts[1], re.IGNORECASE) is not None
|
|
155
|
+
and parts[2] in {"制作成果", "production"}
|
|
156
|
+
)
|
|
157
|
+
if not top_level_production and not episode_production:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"media outputs must use top-level production/ or "
|
|
160
|
+
"剧集|episodes/<EP>/制作成果|production/"
|
|
161
|
+
)
|
|
162
|
+
return pure.as_posix()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _project_file(root: Path, relative: str, *, create_parent: bool = False) -> Path:
|
|
166
|
+
target = root / relative
|
|
167
|
+
current = root
|
|
168
|
+
for part in PurePosixPath(relative).parts[:-1]:
|
|
169
|
+
current /= part
|
|
170
|
+
if current.exists() and (current.is_symlink() or not current.is_dir()):
|
|
171
|
+
raise ValueError(f"unsafe project directory: {part}")
|
|
172
|
+
if create_parent and not current.exists():
|
|
173
|
+
current.mkdir()
|
|
174
|
+
if target.exists() and (target.is_symlink() or not target.is_file()):
|
|
175
|
+
raise ValueError(f"unsafe project file: {relative}")
|
|
176
|
+
if not target.parent.resolve().is_relative_to(root):
|
|
177
|
+
raise ValueError(f"path escapes project root: {relative}")
|
|
178
|
+
return target
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_link_or_reparse(details: os.stat_result) -> bool:
|
|
182
|
+
attributes = getattr(details, "st_file_attributes", 0)
|
|
183
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
184
|
+
return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@contextlib.contextmanager
|
|
188
|
+
def _open_project_input(root: Path, relative: str) -> Iterator[BinaryIO]:
|
|
189
|
+
"""Open one regular project input without following path components on POSIX."""
|
|
190
|
+
parts = PurePosixPath(relative).parts
|
|
191
|
+
if not parts:
|
|
192
|
+
raise ValueError("job input path is empty")
|
|
193
|
+
if os.name != "nt" and os.open in os.supports_dir_fd:
|
|
194
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
195
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
196
|
+
directory_fd = os.open(root, directory_flags)
|
|
197
|
+
file_fd: int | None = None
|
|
198
|
+
try:
|
|
199
|
+
for part in parts[:-1]:
|
|
200
|
+
next_fd = os.open(
|
|
201
|
+
part,
|
|
202
|
+
directory_flags | nofollow,
|
|
203
|
+
dir_fd=directory_fd,
|
|
204
|
+
)
|
|
205
|
+
os.close(directory_fd)
|
|
206
|
+
directory_fd = next_fd
|
|
207
|
+
file_fd = os.open(
|
|
208
|
+
parts[-1], os.O_RDONLY | nofollow, dir_fd=directory_fd
|
|
209
|
+
)
|
|
210
|
+
details = os.fstat(file_fd)
|
|
211
|
+
if not stat.S_ISREG(details.st_mode):
|
|
212
|
+
raise ValueError(f"job input is not a regular file: {relative}")
|
|
213
|
+
with os.fdopen(file_fd, "rb", closefd=True) as handle:
|
|
214
|
+
file_fd = None
|
|
215
|
+
yield handle
|
|
216
|
+
except FileNotFoundError as exc:
|
|
217
|
+
raise FileNotFoundError(f"job input is missing: {relative}") from exc
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
raise ValueError(f"unsafe job input path: {relative}") from exc
|
|
220
|
+
finally:
|
|
221
|
+
if file_fd is not None:
|
|
222
|
+
os.close(file_fd)
|
|
223
|
+
os.close(directory_fd)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Windows lacks portable openat/O_NOFOLLOW support. Reject reparse/symlink
|
|
227
|
+
# components, pin the final file handle, and verify its identity before use.
|
|
228
|
+
path = root
|
|
229
|
+
for part in parts:
|
|
230
|
+
path /= part
|
|
231
|
+
try:
|
|
232
|
+
details = path.lstat()
|
|
233
|
+
except FileNotFoundError as exc:
|
|
234
|
+
raise FileNotFoundError(f"job input is missing: {relative}") from exc
|
|
235
|
+
if _is_link_or_reparse(details):
|
|
236
|
+
raise ValueError(f"unsafe job input path: {relative}")
|
|
237
|
+
if not path.resolve().is_relative_to(root):
|
|
238
|
+
raise ValueError(f"job input escapes project root: {relative}")
|
|
239
|
+
before = path.stat(follow_symlinks=False)
|
|
240
|
+
if not stat.S_ISREG(before.st_mode):
|
|
241
|
+
raise ValueError(f"job input is not a regular file: {relative}")
|
|
242
|
+
with path.open("rb") as handle:
|
|
243
|
+
opened = os.fstat(handle.fileno())
|
|
244
|
+
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
245
|
+
raise ValueError(f"job input changed while opening: {relative}")
|
|
246
|
+
yield handle
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _hash_project_file(root: Path, relative: str) -> str:
|
|
250
|
+
digest = hashlib.sha256()
|
|
251
|
+
size = 0
|
|
252
|
+
with _open_project_input(root, relative) as handle:
|
|
253
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
254
|
+
size += len(chunk)
|
|
255
|
+
if size > MAX_INPUT_BYTES:
|
|
256
|
+
raise ValueError(f"job input exceeds the size limit: {relative}")
|
|
257
|
+
digest.update(chunk)
|
|
258
|
+
return digest.hexdigest()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _snapshot_inputs(
|
|
262
|
+
root: Path, job: Mapping[str, Any], snapshot_root: Path
|
|
263
|
+
) -> None:
|
|
264
|
+
inputs = job.get("inputs")
|
|
265
|
+
if not isinstance(inputs, Mapping):
|
|
266
|
+
raise ConfirmationRequiredError("stored job inputs are invalid")
|
|
267
|
+
total = 0
|
|
268
|
+
for relative_value, expected_value in inputs.items():
|
|
269
|
+
relative = _relative_path(relative_value)
|
|
270
|
+
if not isinstance(expected_value, str) or re.fullmatch(
|
|
271
|
+
r"[0-9a-f]{64}", expected_value
|
|
272
|
+
) is None:
|
|
273
|
+
raise ConfirmationRequiredError("stored job input hash is invalid")
|
|
274
|
+
target = snapshot_root.joinpath(*PurePosixPath(relative).parts)
|
|
275
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
digest = hashlib.sha256()
|
|
277
|
+
size = 0
|
|
278
|
+
try:
|
|
279
|
+
with _open_project_input(root, relative) as incoming, target.open(
|
|
280
|
+
"xb"
|
|
281
|
+
) as outgoing:
|
|
282
|
+
for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
|
|
283
|
+
size += len(chunk)
|
|
284
|
+
total += len(chunk)
|
|
285
|
+
if size > MAX_INPUT_BYTES or total > MAX_TOTAL_INPUT_BYTES:
|
|
286
|
+
raise ConfirmationRequiredError(
|
|
287
|
+
"job inputs exceed the production size limit"
|
|
288
|
+
)
|
|
289
|
+
digest.update(chunk)
|
|
290
|
+
outgoing.write(chunk)
|
|
291
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
292
|
+
raise ConfirmationRequiredError(
|
|
293
|
+
"job inputs changed; prepare and confirm again"
|
|
294
|
+
) from exc
|
|
295
|
+
if digest.hexdigest() != expected_value:
|
|
296
|
+
raise ConfirmationRequiredError(
|
|
297
|
+
"job inputs changed; prepare and confirm again"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _canonical(document: object) -> bytes:
|
|
302
|
+
return json.dumps(
|
|
303
|
+
document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
304
|
+
).encode("utf-8")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _check_metadata_parts(parts: tuple[str, ...]) -> None:
|
|
308
|
+
if any(
|
|
309
|
+
not part
|
|
310
|
+
or part in {".", ".."}
|
|
311
|
+
or "/" in part
|
|
312
|
+
or "\\" in part
|
|
313
|
+
for part in parts
|
|
314
|
+
):
|
|
315
|
+
raise ValueError("production metadata path is invalid")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@contextlib.contextmanager
|
|
319
|
+
def _metadata_directory(
|
|
320
|
+
root: Path, parts: tuple[str, ...], *, create: bool
|
|
321
|
+
) -> Iterator[tuple[Path, int | None]]:
|
|
322
|
+
"""Pin one metadata directory without following project-controlled parents."""
|
|
323
|
+
root = root.resolve()
|
|
324
|
+
_check_metadata_parts(parts)
|
|
325
|
+
all_parts = (".short-drama", "production", *parts)
|
|
326
|
+
directory = root.joinpath(*all_parts)
|
|
327
|
+
if os.name != "nt" and os.open in os.supports_dir_fd:
|
|
328
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
329
|
+
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
330
|
+
directory_fd = os.open(root, flags)
|
|
331
|
+
try:
|
|
332
|
+
try:
|
|
333
|
+
for part in all_parts:
|
|
334
|
+
try:
|
|
335
|
+
next_fd = os.open(
|
|
336
|
+
part, flags | nofollow, dir_fd=directory_fd
|
|
337
|
+
)
|
|
338
|
+
except FileNotFoundError:
|
|
339
|
+
if not create:
|
|
340
|
+
raise
|
|
341
|
+
try:
|
|
342
|
+
os.mkdir(part, mode=0o700, dir_fd=directory_fd)
|
|
343
|
+
except FileExistsError:
|
|
344
|
+
pass
|
|
345
|
+
next_fd = os.open(
|
|
346
|
+
part, flags | nofollow, dir_fd=directory_fd
|
|
347
|
+
)
|
|
348
|
+
details = os.fstat(next_fd)
|
|
349
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
350
|
+
os.close(next_fd)
|
|
351
|
+
raise ValueError("production metadata directory is unsafe")
|
|
352
|
+
os.close(directory_fd)
|
|
353
|
+
directory_fd = next_fd
|
|
354
|
+
except FileNotFoundError:
|
|
355
|
+
raise
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
raise ValueError("production metadata directory is unsafe") from exc
|
|
358
|
+
yield directory, directory_fd
|
|
359
|
+
finally:
|
|
360
|
+
os.close(directory_fd)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
current = root
|
|
364
|
+
for part in all_parts:
|
|
365
|
+
current /= part
|
|
366
|
+
try:
|
|
367
|
+
details = current.lstat()
|
|
368
|
+
except FileNotFoundError:
|
|
369
|
+
if not create:
|
|
370
|
+
raise
|
|
371
|
+
try:
|
|
372
|
+
current.mkdir(mode=0o700)
|
|
373
|
+
except FileExistsError:
|
|
374
|
+
pass
|
|
375
|
+
details = current.lstat()
|
|
376
|
+
if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
|
|
377
|
+
raise ValueError("production metadata directory is unsafe")
|
|
378
|
+
if not current.resolve().is_relative_to(root):
|
|
379
|
+
raise ValueError("production metadata directory escapes the project")
|
|
380
|
+
yield current, None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _metadata_atomic_json(
|
|
384
|
+
root: Path, directory_parts: tuple[str, ...], name: str, document: Mapping[str, Any]
|
|
385
|
+
) -> None:
|
|
386
|
+
_check_metadata_parts((name,))
|
|
387
|
+
content = _canonical(document) + b"\n"
|
|
388
|
+
temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
|
|
389
|
+
with _metadata_directory(root, directory_parts, create=True) as (
|
|
390
|
+
directory,
|
|
391
|
+
directory_fd,
|
|
392
|
+
):
|
|
393
|
+
if directory_fd is not None:
|
|
394
|
+
descriptor = -1
|
|
395
|
+
try:
|
|
396
|
+
descriptor = os.open(
|
|
397
|
+
temporary_name,
|
|
398
|
+
os.O_WRONLY
|
|
399
|
+
| os.O_CREAT
|
|
400
|
+
| os.O_EXCL
|
|
401
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
402
|
+
0o600,
|
|
403
|
+
dir_fd=directory_fd,
|
|
404
|
+
)
|
|
405
|
+
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
|
406
|
+
descriptor = -1
|
|
407
|
+
handle.write(content)
|
|
408
|
+
handle.flush()
|
|
409
|
+
os.fsync(handle.fileno())
|
|
410
|
+
os.replace(
|
|
411
|
+
temporary_name,
|
|
412
|
+
name,
|
|
413
|
+
src_dir_fd=directory_fd,
|
|
414
|
+
dst_dir_fd=directory_fd,
|
|
415
|
+
)
|
|
416
|
+
os.fsync(directory_fd)
|
|
417
|
+
finally:
|
|
418
|
+
if descriptor >= 0:
|
|
419
|
+
os.close(descriptor)
|
|
420
|
+
try:
|
|
421
|
+
os.unlink(temporary_name, dir_fd=directory_fd)
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
pass
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
before = directory.stat(follow_symlinks=False)
|
|
427
|
+
temporary = directory / temporary_name
|
|
428
|
+
try:
|
|
429
|
+
with temporary.open("xb") as handle:
|
|
430
|
+
opened = os.fstat(handle.fileno())
|
|
431
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
432
|
+
raise ValueError("production metadata file is unsafe")
|
|
433
|
+
handle.write(content)
|
|
434
|
+
handle.flush()
|
|
435
|
+
os.fsync(handle.fileno())
|
|
436
|
+
after = directory.lstat()
|
|
437
|
+
if (
|
|
438
|
+
_is_link_or_reparse(after)
|
|
439
|
+
or not stat.S_ISDIR(after.st_mode)
|
|
440
|
+
or (after.st_dev, after.st_ino) != (before.st_dev, before.st_ino)
|
|
441
|
+
):
|
|
442
|
+
raise ValueError("production metadata directory changed")
|
|
443
|
+
os.replace(temporary, directory / name)
|
|
444
|
+
finally:
|
|
445
|
+
try:
|
|
446
|
+
temporary.unlink()
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _metadata_read_json(
|
|
452
|
+
root: Path, directory_parts: tuple[str, ...], name: str, *, maximum: int
|
|
453
|
+
) -> object:
|
|
454
|
+
_check_metadata_parts((name,))
|
|
455
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
456
|
+
directory,
|
|
457
|
+
directory_fd,
|
|
458
|
+
):
|
|
459
|
+
if directory_fd is not None:
|
|
460
|
+
descriptor = os.open(
|
|
461
|
+
name,
|
|
462
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
|
463
|
+
dir_fd=directory_fd,
|
|
464
|
+
)
|
|
465
|
+
with os.fdopen(descriptor, "rb", closefd=True) as handle:
|
|
466
|
+
details = os.fstat(handle.fileno())
|
|
467
|
+
if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
|
|
468
|
+
raise ValueError("production metadata file is unsafe")
|
|
469
|
+
raw = handle.read(maximum + 1)
|
|
470
|
+
else:
|
|
471
|
+
path = directory / name
|
|
472
|
+
before = path.lstat()
|
|
473
|
+
if (
|
|
474
|
+
_is_link_or_reparse(before)
|
|
475
|
+
or not stat.S_ISREG(before.st_mode)
|
|
476
|
+
or before.st_size > maximum
|
|
477
|
+
):
|
|
478
|
+
raise ValueError("production metadata file is unsafe")
|
|
479
|
+
with path.open("rb") as handle:
|
|
480
|
+
opened = os.fstat(handle.fileno())
|
|
481
|
+
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
482
|
+
raise ValueError("production metadata file changed while opening")
|
|
483
|
+
raw = handle.read(maximum + 1)
|
|
484
|
+
if len(raw) > maximum:
|
|
485
|
+
raise ValueError("production metadata file is too large")
|
|
486
|
+
return json.loads(raw.decode("utf-8"))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _metadata_unlink(
|
|
490
|
+
root: Path, directory_parts: tuple[str, ...], name: str
|
|
491
|
+
) -> None:
|
|
492
|
+
_check_metadata_parts((name,))
|
|
493
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
494
|
+
directory,
|
|
495
|
+
directory_fd,
|
|
496
|
+
):
|
|
497
|
+
if directory_fd is not None:
|
|
498
|
+
os.unlink(name, dir_fd=directory_fd)
|
|
499
|
+
else:
|
|
500
|
+
path = directory / name
|
|
501
|
+
details = path.lstat()
|
|
502
|
+
if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
|
|
503
|
+
raise ValueError("production metadata file is unsafe")
|
|
504
|
+
path.unlink()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _metadata_json_names(root: Path, directory_parts: tuple[str, ...]) -> list[str]:
|
|
508
|
+
try:
|
|
509
|
+
with _metadata_directory(root, directory_parts, create=False) as (
|
|
510
|
+
directory,
|
|
511
|
+
directory_fd,
|
|
512
|
+
):
|
|
513
|
+
names = os.listdir(directory_fd if directory_fd is not None else directory)
|
|
514
|
+
except FileNotFoundError:
|
|
515
|
+
return []
|
|
516
|
+
return sorted(
|
|
517
|
+
name
|
|
518
|
+
for name in names
|
|
519
|
+
if isinstance(name, str)
|
|
520
|
+
and name.endswith(".json")
|
|
521
|
+
and "/" not in name
|
|
522
|
+
and "\\" not in name
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@contextlib.contextmanager
|
|
527
|
+
def _project_lock(root: Path) -> Iterator[None]:
|
|
528
|
+
with _metadata_directory(root, (), create=True) as (directory, directory_fd):
|
|
529
|
+
if directory_fd is not None:
|
|
530
|
+
descriptor = os.open(
|
|
531
|
+
"lock",
|
|
532
|
+
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
|
533
|
+
0o600,
|
|
534
|
+
dir_fd=directory_fd,
|
|
535
|
+
)
|
|
536
|
+
details = os.fstat(descriptor)
|
|
537
|
+
if not stat.S_ISREG(details.st_mode):
|
|
538
|
+
os.close(descriptor)
|
|
539
|
+
raise ValueError("production lock is unsafe")
|
|
540
|
+
handle_context = os.fdopen(descriptor, "a+b", closefd=True)
|
|
541
|
+
else:
|
|
542
|
+
lock_path = directory / "lock"
|
|
543
|
+
before: os.stat_result | None
|
|
544
|
+
try:
|
|
545
|
+
before = lock_path.lstat()
|
|
546
|
+
except FileNotFoundError:
|
|
547
|
+
before = None
|
|
548
|
+
else:
|
|
549
|
+
if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
|
|
550
|
+
raise ValueError("production lock is unsafe")
|
|
551
|
+
handle_context = lock_path.open("a+b")
|
|
552
|
+
opened = os.fstat(handle_context.fileno())
|
|
553
|
+
after = lock_path.lstat()
|
|
554
|
+
if (
|
|
555
|
+
not stat.S_ISREG(opened.st_mode)
|
|
556
|
+
or _is_link_or_reparse(after)
|
|
557
|
+
or not stat.S_ISREG(after.st_mode)
|
|
558
|
+
or (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino)
|
|
559
|
+
or (
|
|
560
|
+
before is not None
|
|
561
|
+
and (before.st_dev, before.st_ino)
|
|
562
|
+
!= (opened.st_dev, opened.st_ino)
|
|
563
|
+
)
|
|
564
|
+
):
|
|
565
|
+
handle_context.close()
|
|
566
|
+
raise ValueError("production lock changed while opening")
|
|
567
|
+
with handle_context as handle:
|
|
568
|
+
if os.name == "nt":
|
|
569
|
+
import msvcrt
|
|
570
|
+
|
|
571
|
+
handle.seek(0, os.SEEK_END)
|
|
572
|
+
if handle.tell() == 0:
|
|
573
|
+
handle.write(b"0")
|
|
574
|
+
handle.flush()
|
|
575
|
+
handle.seek(0)
|
|
576
|
+
locking = getattr(msvcrt, "locking")
|
|
577
|
+
lock = getattr(msvcrt, "LK_LOCK")
|
|
578
|
+
unlock = getattr(msvcrt, "LK_UNLCK")
|
|
579
|
+
locking(handle.fileno(), lock, 1)
|
|
580
|
+
try:
|
|
581
|
+
yield
|
|
582
|
+
finally:
|
|
583
|
+
handle.seek(0)
|
|
584
|
+
locking(handle.fileno(), unlock, 1)
|
|
585
|
+
else:
|
|
586
|
+
import fcntl
|
|
587
|
+
|
|
588
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
589
|
+
try:
|
|
590
|
+
yield
|
|
591
|
+
finally:
|
|
592
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _job_key(job_id: str) -> str:
|
|
596
|
+
return sha256_bytes(job_id.encode("utf-8"))[:24]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _job_path(root: Path, job_id: str) -> Path:
|
|
600
|
+
return root / PRODUCTION_ROOT / "jobs" / f"{_job_key(job_id)}.json"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _confirmation_path(root: Path, job_id: str) -> Path:
|
|
604
|
+
return root / PRODUCTION_ROOT / "confirmations" / f"{_job_key(job_id)}.json"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _run_directory(root: Path, job_id: str) -> Path:
|
|
608
|
+
return root / PRODUCTION_ROOT / "runs" / _job_key(job_id)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _contains_secret_key(value: object) -> bool:
|
|
612
|
+
if isinstance(value, Mapping):
|
|
613
|
+
return any(
|
|
614
|
+
str(key).casefold() in SECRET_KEYS or _contains_secret_key(child)
|
|
615
|
+
for key, child in value.items()
|
|
616
|
+
)
|
|
617
|
+
if isinstance(value, list):
|
|
618
|
+
return any(_contains_secret_key(child) for child in value)
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _string_list(value: object, *, label: str, limit: int = 32) -> list[str]:
|
|
623
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
624
|
+
raise ValueError(f"{label} must be a string list")
|
|
625
|
+
if len(value) > limit:
|
|
626
|
+
raise ValueError(f"{label} has too many entries")
|
|
627
|
+
return list(value)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _normalize_job(root: Path, raw: object) -> dict[str, Any]:
|
|
631
|
+
if not isinstance(raw, Mapping) or set(raw) - ALLOWED_JOB_KEYS:
|
|
632
|
+
raise ValueError("job contains unsupported fields")
|
|
633
|
+
if raw.get("schema_version", JOB_SCHEMA) != JOB_SCHEMA:
|
|
634
|
+
raise ValueError("unsupported job schema")
|
|
635
|
+
job_id = raw.get("job_id")
|
|
636
|
+
if not isinstance(job_id, str) or JOB_ID_RE.fullmatch(job_id) is None:
|
|
637
|
+
raise ValueError("job_id must be a portable 1-80 character identifier")
|
|
638
|
+
modality = raw.get("modality")
|
|
639
|
+
if modality not in MEDIA_EXTENSIONS:
|
|
640
|
+
raise ValueError("modality must be image, video, tts, or music")
|
|
641
|
+
adapter = raw.get("adapter")
|
|
642
|
+
if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
|
|
643
|
+
raise ValueError("adapter must be a portable profile name")
|
|
644
|
+
prompt = raw.get("prompt")
|
|
645
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
|
|
646
|
+
raise ValueError("prompt must be non-empty and at most 100000 characters")
|
|
647
|
+
source_raw = raw.get("source")
|
|
648
|
+
source = _relative_path(source_raw) if source_raw is not None else None
|
|
649
|
+
references = [
|
|
650
|
+
_relative_path(path)
|
|
651
|
+
for path in _string_list(raw.get("references", []), label="references", limit=16)
|
|
652
|
+
]
|
|
653
|
+
outputs = [
|
|
654
|
+
_relative_path(path, output=True)
|
|
655
|
+
for path in _string_list(raw.get("outputs"), label="outputs", limit=16)
|
|
656
|
+
]
|
|
657
|
+
if not outputs or len(set(outputs)) != len(outputs):
|
|
658
|
+
raise ValueError("outputs must contain unique target paths")
|
|
659
|
+
for output_path in outputs:
|
|
660
|
+
if PurePosixPath(output_path).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
|
|
661
|
+
raise ValueError(f"output extension does not match {modality}: {output_path}")
|
|
662
|
+
_project_file(root, output_path)
|
|
663
|
+
parameters = raw.get("parameters", {})
|
|
664
|
+
if not isinstance(parameters, Mapping):
|
|
665
|
+
raise ValueError("parameters must be an object")
|
|
666
|
+
if _contains_secret_key(parameters):
|
|
667
|
+
raise ValueError("job parameters must not contain credentials or secrets")
|
|
668
|
+
if len(_canonical(parameters)) > 64 * 1024:
|
|
669
|
+
raise ValueError("job parameters are too large")
|
|
670
|
+
input_paths = ([source] if source is not None else []) + references
|
|
671
|
+
if len(set(input_paths)) != len(input_paths):
|
|
672
|
+
raise ValueError("source and references must be unique")
|
|
673
|
+
input_hashes = {path: _hash_project_file(root, path) for path in input_paths}
|
|
674
|
+
execution = {
|
|
675
|
+
"schema_version": JOB_SCHEMA,
|
|
676
|
+
"job_id": job_id,
|
|
677
|
+
"modality": modality,
|
|
678
|
+
"adapter": adapter,
|
|
679
|
+
"prompt": prompt,
|
|
680
|
+
"source": source,
|
|
681
|
+
"references": references,
|
|
682
|
+
"outputs": outputs,
|
|
683
|
+
"parameters": dict(parameters),
|
|
684
|
+
"overwrite": raw.get("overwrite", False),
|
|
685
|
+
"inputs": input_hashes,
|
|
686
|
+
}
|
|
687
|
+
if not isinstance(execution["overwrite"], bool):
|
|
688
|
+
raise ValueError("overwrite must be a boolean")
|
|
689
|
+
execution["fingerprint"] = sha256_bytes(_canonical(execution))
|
|
690
|
+
execution["prepared_at"] = utc_now()
|
|
691
|
+
return execution
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def prepare_job(root: Path, job_file: Path) -> dict[str, Any]:
|
|
695
|
+
root = find_project(root)
|
|
696
|
+
if job_file.stat().st_size > MAX_JOB_BYTES:
|
|
697
|
+
raise ValueError("job file is too large")
|
|
698
|
+
raw = json.loads(job_file.read_text(encoding="utf-8"))
|
|
699
|
+
job = _normalize_job(root, raw)
|
|
700
|
+
with _project_lock(root):
|
|
701
|
+
if _active_run(root, str(job["job_id"])) is not None:
|
|
702
|
+
raise RuntimeError("this job is already running")
|
|
703
|
+
job_name = f"{_job_key(str(job['job_id']))}.json"
|
|
704
|
+
_metadata_atomic_json(root, ("jobs",), job_name, job)
|
|
705
|
+
try:
|
|
706
|
+
_metadata_unlink(root, ("confirmations",), job_name)
|
|
707
|
+
except FileNotFoundError:
|
|
708
|
+
pass
|
|
709
|
+
return _preview(job)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _validate_stored_job(
|
|
713
|
+
root: Path, document: object, *, expected_job_id: str | None = None
|
|
714
|
+
) -> dict[str, Any]:
|
|
715
|
+
root = root.resolve()
|
|
716
|
+
if not isinstance(document, dict) or set(document) != STORED_JOB_KEYS:
|
|
717
|
+
raise ValueError("stored job fields are invalid")
|
|
718
|
+
job_id = document.get("job_id")
|
|
719
|
+
if (
|
|
720
|
+
not isinstance(job_id, str)
|
|
721
|
+
or JOB_ID_RE.fullmatch(job_id) is None
|
|
722
|
+
or (expected_job_id is not None and job_id != expected_job_id)
|
|
723
|
+
):
|
|
724
|
+
raise ValueError("stored job id is invalid")
|
|
725
|
+
if document.get("schema_version") != JOB_SCHEMA:
|
|
726
|
+
raise ValueError("stored job schema is invalid")
|
|
727
|
+
modality = document.get("modality")
|
|
728
|
+
if modality not in MEDIA_EXTENSIONS:
|
|
729
|
+
raise ValueError("stored job modality is invalid")
|
|
730
|
+
adapter = document.get("adapter")
|
|
731
|
+
if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
|
|
732
|
+
raise ValueError("stored job adapter is invalid")
|
|
733
|
+
prompt = document.get("prompt")
|
|
734
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
|
|
735
|
+
raise ValueError("stored job prompt is invalid")
|
|
736
|
+
source = document.get("source")
|
|
737
|
+
if source is not None and _relative_path(source) != source:
|
|
738
|
+
raise ValueError("stored job source is invalid")
|
|
739
|
+
references = _string_list(
|
|
740
|
+
document.get("references"), label="stored references", limit=16
|
|
741
|
+
)
|
|
742
|
+
if any(_relative_path(reference) != reference for reference in references):
|
|
743
|
+
raise ValueError("stored job references are invalid")
|
|
744
|
+
outputs = _string_list(document.get("outputs"), label="stored outputs", limit=16)
|
|
745
|
+
if not outputs or len(outputs) != len(set(outputs)):
|
|
746
|
+
raise ValueError("stored job outputs are invalid")
|
|
747
|
+
for output in outputs:
|
|
748
|
+
if _relative_path(output, output=True) != output:
|
|
749
|
+
raise ValueError("stored job output is invalid")
|
|
750
|
+
if PurePosixPath(output).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
|
|
751
|
+
raise ValueError("stored job output extension is invalid")
|
|
752
|
+
_project_file(root, output)
|
|
753
|
+
parameters = document.get("parameters")
|
|
754
|
+
if (
|
|
755
|
+
not isinstance(parameters, Mapping)
|
|
756
|
+
or _contains_secret_key(parameters)
|
|
757
|
+
or len(_canonical(parameters)) > 64 * 1024
|
|
758
|
+
):
|
|
759
|
+
raise ValueError("stored job parameters are invalid")
|
|
760
|
+
if not isinstance(document.get("overwrite"), bool):
|
|
761
|
+
raise ValueError("stored job overwrite flag is invalid")
|
|
762
|
+
input_paths = ([source] if source is not None else []) + references
|
|
763
|
+
if len(input_paths) != len(set(input_paths)):
|
|
764
|
+
raise ValueError("stored job inputs are duplicated")
|
|
765
|
+
inputs = document.get("inputs")
|
|
766
|
+
if not isinstance(inputs, Mapping) or set(inputs) != set(input_paths):
|
|
767
|
+
raise ValueError("stored job input hashes are invalid")
|
|
768
|
+
if any(
|
|
769
|
+
not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
770
|
+
for digest in inputs.values()
|
|
771
|
+
):
|
|
772
|
+
raise ValueError("stored job input hash is invalid")
|
|
773
|
+
fingerprint = document.get("fingerprint")
|
|
774
|
+
execution = {key: document[key] for key in STORED_EXECUTION_KEYS}
|
|
775
|
+
if (
|
|
776
|
+
not isinstance(fingerprint, str)
|
|
777
|
+
or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None
|
|
778
|
+
or sha256_bytes(_canonical(execution)) != fingerprint
|
|
779
|
+
):
|
|
780
|
+
raise ValueError("stored job fingerprint is invalid")
|
|
781
|
+
_parse_run_timestamp(document.get("prepared_at"), label="prepared_at")
|
|
782
|
+
return document
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _read_job(root: Path, job_id: str) -> dict[str, Any]:
|
|
786
|
+
if JOB_ID_RE.fullmatch(job_id) is None:
|
|
787
|
+
raise ValueError("invalid job_id")
|
|
788
|
+
document = _metadata_read_json(
|
|
789
|
+
root,
|
|
790
|
+
("jobs",),
|
|
791
|
+
f"{_job_key(job_id)}.json",
|
|
792
|
+
maximum=MAX_JOB_BYTES,
|
|
793
|
+
)
|
|
794
|
+
return _validate_stored_job(root, document, expected_job_id=job_id)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _preview(job: Mapping[str, Any]) -> dict[str, Any]:
|
|
798
|
+
confirmation = f"CONFIRM {job['job_id']} {str(job['fingerprint'])[:12]}"
|
|
799
|
+
return {
|
|
800
|
+
"job_id": job["job_id"],
|
|
801
|
+
"modality": job["modality"],
|
|
802
|
+
"adapter": job["adapter"],
|
|
803
|
+
"count": len(job["outputs"]),
|
|
804
|
+
"prompt": job["prompt"],
|
|
805
|
+
"source": job["source"],
|
|
806
|
+
"references": job["references"],
|
|
807
|
+
"outputs": job["outputs"],
|
|
808
|
+
"parameters": job["parameters"],
|
|
809
|
+
"overwrite": job["overwrite"],
|
|
810
|
+
"confirmation": confirmation,
|
|
811
|
+
"state": "needs_confirmation",
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def confirm_job(root: Path, *, job_id: str, confirmation: str) -> dict[str, Any]:
|
|
816
|
+
root = find_project(root)
|
|
817
|
+
with _project_lock(root):
|
|
818
|
+
if _active_run(root, job_id) is not None:
|
|
819
|
+
raise RuntimeError("this job is already running")
|
|
820
|
+
job = _read_job(root, job_id)
|
|
821
|
+
expected = _preview(job)["confirmation"]
|
|
822
|
+
if confirmation != expected:
|
|
823
|
+
raise ConfirmationRequiredError("confirmation does not match the exact current job")
|
|
824
|
+
receipt = {
|
|
825
|
+
"schema_version": JOB_SCHEMA,
|
|
826
|
+
"job_id": job_id,
|
|
827
|
+
"fingerprint": job["fingerprint"],
|
|
828
|
+
"confirmed_at": utc_now(),
|
|
829
|
+
"consumed_at": None,
|
|
830
|
+
"run_id": None,
|
|
831
|
+
}
|
|
832
|
+
_metadata_atomic_json(
|
|
833
|
+
root, ("confirmations",), f"{_job_key(job_id)}.json", receipt
|
|
834
|
+
)
|
|
835
|
+
return {"job_id": job_id, "state": "confirmed"}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _inputs_current(root: Path, job: Mapping[str, Any]) -> bool:
|
|
839
|
+
inputs = job.get("inputs")
|
|
840
|
+
if not isinstance(inputs, Mapping):
|
|
841
|
+
return False
|
|
842
|
+
try:
|
|
843
|
+
return all(_hash_project_file(root, str(path)) == digest for path, digest in inputs.items())
|
|
844
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
845
|
+
return False
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _load_adapter(config_path: Path, profile: str, root: Path) -> tuple[list[str], int]:
|
|
849
|
+
resolved = config_path.expanduser().resolve()
|
|
850
|
+
if resolved.is_relative_to(root):
|
|
851
|
+
raise ValueError("adapter config must live outside the project")
|
|
852
|
+
document = json.loads(resolved.read_text(encoding="utf-8"))
|
|
853
|
+
adapters = document.get("adapters") if isinstance(document, Mapping) else None
|
|
854
|
+
selected = adapters.get(profile) if isinstance(adapters, Mapping) else None
|
|
855
|
+
if not isinstance(selected, Mapping) or set(selected) - {"command", "timeout_seconds"}:
|
|
856
|
+
raise ValueError(f"adapter profile is missing or invalid: {profile}")
|
|
857
|
+
command = _string_list(selected.get("command"), label="adapter command", limit=32)
|
|
858
|
+
if not command or any(not part for part in command):
|
|
859
|
+
raise ValueError("adapter command must be a non-empty argv list")
|
|
860
|
+
timeout = selected.get("timeout_seconds", 300)
|
|
861
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= MAX_TIMEOUT_SECONDS:
|
|
862
|
+
raise ValueError(f"adapter timeout must be 1-{MAX_TIMEOUT_SECONDS} seconds")
|
|
863
|
+
return command, timeout
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _generic_adapter_error(
|
|
867
|
+
profile: str,
|
|
868
|
+
*,
|
|
869
|
+
category: str,
|
|
870
|
+
code: str,
|
|
871
|
+
retryable: bool,
|
|
872
|
+
) -> dict[str, Any]:
|
|
873
|
+
return {
|
|
874
|
+
"provider": profile,
|
|
875
|
+
"category": category,
|
|
876
|
+
"code": code,
|
|
877
|
+
"retryable": retryable,
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _parse_public_adapter_error(
|
|
882
|
+
raw: bytes, *, profile: str, returncode: int
|
|
883
|
+
) -> dict[str, Any]:
|
|
884
|
+
fallback = _generic_adapter_error(
|
|
885
|
+
profile,
|
|
886
|
+
category="provider_response",
|
|
887
|
+
code=f"adapter_exit_{returncode}",
|
|
888
|
+
retryable=False,
|
|
889
|
+
)
|
|
890
|
+
if len(raw) > MAX_ADAPTER_RESPONSE_BYTES:
|
|
891
|
+
return fallback
|
|
892
|
+
try:
|
|
893
|
+
document = json.loads(raw.decode("utf-8"))
|
|
894
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
895
|
+
return fallback
|
|
896
|
+
error = document.get("error") if isinstance(document, Mapping) else None
|
|
897
|
+
if not isinstance(error, Mapping) or set(error) - {
|
|
898
|
+
"provider",
|
|
899
|
+
"category",
|
|
900
|
+
"code",
|
|
901
|
+
"http_status",
|
|
902
|
+
"request_id",
|
|
903
|
+
"retryable",
|
|
904
|
+
}:
|
|
905
|
+
return fallback
|
|
906
|
+
provider = error.get("provider")
|
|
907
|
+
category = error.get("category")
|
|
908
|
+
code = error.get("code")
|
|
909
|
+
retryable = error.get("retryable")
|
|
910
|
+
if (
|
|
911
|
+
provider != profile
|
|
912
|
+
or category not in PUBLIC_ERROR_CATEGORIES
|
|
913
|
+
or not isinstance(code, str)
|
|
914
|
+
or PUBLIC_ERROR_TOKEN_RE.fullmatch(code) is None
|
|
915
|
+
or not isinstance(retryable, bool)
|
|
916
|
+
):
|
|
917
|
+
return fallback
|
|
918
|
+
result: dict[str, Any] = {
|
|
919
|
+
"provider": provider,
|
|
920
|
+
"category": category,
|
|
921
|
+
"code": code,
|
|
922
|
+
"retryable": retryable,
|
|
923
|
+
}
|
|
924
|
+
status = error.get("http_status")
|
|
925
|
+
if status is not None:
|
|
926
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 100 <= status <= 599:
|
|
927
|
+
return fallback
|
|
928
|
+
result["http_status"] = status
|
|
929
|
+
request_id = error.get("request_id")
|
|
930
|
+
if request_id is not None:
|
|
931
|
+
if (
|
|
932
|
+
not isinstance(request_id, str)
|
|
933
|
+
or PUBLIC_ERROR_TOKEN_RE.fullmatch(request_id) is None
|
|
934
|
+
):
|
|
935
|
+
return fallback
|
|
936
|
+
result["request_id"] = request_id
|
|
937
|
+
return result
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _run_adapter(command: list[str], timeout: int, payload: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
|
941
|
+
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
|
|
942
|
+
try:
|
|
943
|
+
completed = subprocess.run(
|
|
944
|
+
command,
|
|
945
|
+
input=_canonical(payload),
|
|
946
|
+
stdout=stdout,
|
|
947
|
+
stderr=stderr,
|
|
948
|
+
cwd=root,
|
|
949
|
+
timeout=timeout,
|
|
950
|
+
check=False,
|
|
951
|
+
)
|
|
952
|
+
except subprocess.TimeoutExpired as exc:
|
|
953
|
+
raise AdapterError(
|
|
954
|
+
"adapter timed out; confirmation was consumed",
|
|
955
|
+
public_error=_generic_adapter_error(
|
|
956
|
+
str(payload["adapter"]),
|
|
957
|
+
category="timeout",
|
|
958
|
+
code="adapter_timeout",
|
|
959
|
+
retryable=True,
|
|
960
|
+
),
|
|
961
|
+
) from exc
|
|
962
|
+
except OSError as exc:
|
|
963
|
+
raise AdapterError(
|
|
964
|
+
"adapter could not be started; confirmation was consumed",
|
|
965
|
+
public_error=_generic_adapter_error(
|
|
966
|
+
str(payload["adapter"]),
|
|
967
|
+
category="configuration",
|
|
968
|
+
code="adapter_start_failed",
|
|
969
|
+
retryable=False,
|
|
970
|
+
),
|
|
971
|
+
) from exc
|
|
972
|
+
if completed.returncode != 0:
|
|
973
|
+
size = stdout.tell()
|
|
974
|
+
stdout.seek(0)
|
|
975
|
+
raw_error = stdout.read(MAX_ADAPTER_RESPONSE_BYTES + 1)
|
|
976
|
+
public_error = _parse_public_adapter_error(
|
|
977
|
+
raw_error if size <= MAX_ADAPTER_RESPONSE_BYTES else b"",
|
|
978
|
+
profile=str(payload["adapter"]),
|
|
979
|
+
returncode=completed.returncode,
|
|
980
|
+
)
|
|
981
|
+
raise AdapterError(
|
|
982
|
+
f"adapter exited with code {completed.returncode}; confirmation was consumed",
|
|
983
|
+
public_error=public_error,
|
|
984
|
+
)
|
|
985
|
+
size = stdout.tell()
|
|
986
|
+
if size > MAX_ADAPTER_RESPONSE_BYTES:
|
|
987
|
+
raise AdapterError("adapter response is too large; confirmation was consumed")
|
|
988
|
+
stdout.seek(0)
|
|
989
|
+
try:
|
|
990
|
+
response = json.loads(stdout.read().decode("utf-8"))
|
|
991
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
992
|
+
raise AdapterError("adapter returned invalid JSON; confirmation was consumed") from exc
|
|
993
|
+
if not isinstance(response, dict):
|
|
994
|
+
raise AdapterError("adapter response must be an object; confirmation was consumed")
|
|
995
|
+
return response
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _validate_adapter_outputs(
|
|
999
|
+
job: Mapping[str, Any], response: Mapping[str, Any], output_root: Path
|
|
1000
|
+
) -> list[tuple[str, Path]]:
|
|
1001
|
+
entries = response.get("outputs")
|
|
1002
|
+
if not isinstance(entries, list) or not all(isinstance(entry, Mapping) for entry in entries):
|
|
1003
|
+
raise AdapterError("adapter outputs are invalid; confirmation was consumed")
|
|
1004
|
+
result: list[tuple[str, Path]] = []
|
|
1005
|
+
for entry in entries:
|
|
1006
|
+
if set(entry) != {"target", "source"}:
|
|
1007
|
+
raise AdapterError("adapter output fields are invalid; confirmation was consumed")
|
|
1008
|
+
target = entry.get("target")
|
|
1009
|
+
source = entry.get("source")
|
|
1010
|
+
if not isinstance(target, str) or not isinstance(source, str):
|
|
1011
|
+
raise AdapterError("adapter output paths are invalid; confirmation was consumed")
|
|
1012
|
+
path = Path(source)
|
|
1013
|
+
if not path.is_absolute() or path.parent != output_root or path.name in {"", ".", ".."}:
|
|
1014
|
+
raise AdapterError(
|
|
1015
|
+
"adapter output must use the run staging directory; confirmation was consumed"
|
|
1016
|
+
)
|
|
1017
|
+
result.append((target, path))
|
|
1018
|
+
expected = list(job["outputs"])
|
|
1019
|
+
if [target for target, _ in result] != expected:
|
|
1020
|
+
raise AdapterError("adapter outputs do not match the confirmed targets; confirmation was consumed")
|
|
1021
|
+
return result
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _copy_output(
|
|
1025
|
+
source: Path, target: Path, *, overwrite: bool
|
|
1026
|
+
) -> tuple[str, int]:
|
|
1027
|
+
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
1028
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1029
|
+
digest = hashlib.sha256()
|
|
1030
|
+
size = 0
|
|
1031
|
+
descriptor = -1
|
|
1032
|
+
try:
|
|
1033
|
+
try:
|
|
1034
|
+
before = source.lstat()
|
|
1035
|
+
except OSError as exc:
|
|
1036
|
+
raise AdapterError(
|
|
1037
|
+
"adapter output file is missing; confirmation was consumed"
|
|
1038
|
+
) from exc
|
|
1039
|
+
if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
|
|
1040
|
+
raise AdapterError("adapter output file is unsafe; confirmation was consumed")
|
|
1041
|
+
descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
1042
|
+
opened = os.fstat(descriptor)
|
|
1043
|
+
if (
|
|
1044
|
+
not stat.S_ISREG(opened.st_mode)
|
|
1045
|
+
or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)
|
|
1046
|
+
):
|
|
1047
|
+
raise AdapterError("adapter output changed while opening; confirmation was consumed")
|
|
1048
|
+
if opened.st_size > MAX_OUTPUT_BYTES:
|
|
1049
|
+
raise AdapterError("adapter output file is too large; confirmation was consumed")
|
|
1050
|
+
with os.fdopen(descriptor, "rb", closefd=True) as incoming, temporary.open(
|
|
1051
|
+
"xb"
|
|
1052
|
+
) as outgoing:
|
|
1053
|
+
descriptor = -1
|
|
1054
|
+
for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
|
|
1055
|
+
size += len(chunk)
|
|
1056
|
+
if size > MAX_OUTPUT_BYTES:
|
|
1057
|
+
raise AdapterError("adapter output exceeded the size limit")
|
|
1058
|
+
digest.update(chunk)
|
|
1059
|
+
outgoing.write(chunk)
|
|
1060
|
+
outgoing.flush()
|
|
1061
|
+
os.fsync(outgoing.fileno())
|
|
1062
|
+
if overwrite:
|
|
1063
|
+
os.replace(temporary, target)
|
|
1064
|
+
else:
|
|
1065
|
+
try:
|
|
1066
|
+
os.link(temporary, target, follow_symlinks=False)
|
|
1067
|
+
except FileExistsError as exc:
|
|
1068
|
+
raise FileExistsError(
|
|
1069
|
+
f"output appeared while production was running: {target.name}"
|
|
1070
|
+
) from exc
|
|
1071
|
+
temporary.unlink()
|
|
1072
|
+
finally:
|
|
1073
|
+
if descriptor >= 0:
|
|
1074
|
+
os.close(descriptor)
|
|
1075
|
+
try:
|
|
1076
|
+
temporary.unlink()
|
|
1077
|
+
except FileNotFoundError:
|
|
1078
|
+
pass
|
|
1079
|
+
return digest.hexdigest(), size
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _latest_run(root: Path, job_id: str) -> dict[str, Any] | None:
|
|
1083
|
+
history = _read_run_history(root, job_id)
|
|
1084
|
+
return history[-1] if history else None
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _parse_run_timestamp(value: object, *, label: str) -> datetime:
|
|
1088
|
+
if not isinstance(value, str) or not value:
|
|
1089
|
+
raise ValueError(f"production run {label} is invalid")
|
|
1090
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
1091
|
+
try:
|
|
1092
|
+
parsed = datetime.fromisoformat(normalized)
|
|
1093
|
+
except ValueError as exc:
|
|
1094
|
+
raise ValueError(f"production run {label} is invalid") from exc
|
|
1095
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
1096
|
+
raise ValueError(f"production run {label} is invalid")
|
|
1097
|
+
return parsed.astimezone(timezone.utc)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _completed_run_order(run: Mapping[str, Any]) -> tuple[datetime, str]:
|
|
1101
|
+
return (
|
|
1102
|
+
_parse_run_timestamp(run.get("finished_at"), label="finished_at"),
|
|
1103
|
+
str(run["run_id"]),
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _read_run_history(root: Path, job_id: str) -> list[dict[str, Any]]:
|
|
1108
|
+
directory_parts = ("runs", _job_key(job_id))
|
|
1109
|
+
history: list[dict[str, Any]] = []
|
|
1110
|
+
for name in _metadata_json_names(root, directory_parts):
|
|
1111
|
+
document = _metadata_read_json(
|
|
1112
|
+
root, directory_parts, name, maximum=MAX_RUN_RECORD_BYTES
|
|
1113
|
+
)
|
|
1114
|
+
if (
|
|
1115
|
+
not isinstance(document, dict)
|
|
1116
|
+
or document.get("job_id") != job_id
|
|
1117
|
+
or not isinstance(document.get("run_id"), str)
|
|
1118
|
+
or document.get("status") not in {"running", "succeeded", "failed"}
|
|
1119
|
+
or not isinstance(document.get("fingerprint"), str)
|
|
1120
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(document.get("fingerprint"))) is None
|
|
1121
|
+
):
|
|
1122
|
+
raise ValueError("production run record is invalid")
|
|
1123
|
+
started_at = _parse_run_timestamp(
|
|
1124
|
+
document.get("started_at"), label="started_at"
|
|
1125
|
+
)
|
|
1126
|
+
finished_at = document.get("finished_at")
|
|
1127
|
+
if document["status"] == "running":
|
|
1128
|
+
if finished_at is not None:
|
|
1129
|
+
raise ValueError("running production run has finished_at")
|
|
1130
|
+
else:
|
|
1131
|
+
completed_at = _parse_run_timestamp(finished_at, label="finished_at")
|
|
1132
|
+
if completed_at < started_at:
|
|
1133
|
+
raise ValueError("production run finished before it started")
|
|
1134
|
+
history.append(document)
|
|
1135
|
+
# Completed runs are ordered by completion because that is when their
|
|
1136
|
+
# terminal state and output claim become authoritative. Any unresolved
|
|
1137
|
+
# running attempt sorts last so status cannot hide it behind a later start.
|
|
1138
|
+
history.sort(
|
|
1139
|
+
key=lambda run: (
|
|
1140
|
+
run["status"] == "running",
|
|
1141
|
+
(
|
|
1142
|
+
_parse_run_timestamp(run.get("started_at"), label="started_at")
|
|
1143
|
+
if run["status"] == "running"
|
|
1144
|
+
else _completed_run_order(run)[0]
|
|
1145
|
+
),
|
|
1146
|
+
str(run["run_id"]),
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
return history
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _active_run(root: Path, job_id: str) -> dict[str, Any] | None:
|
|
1153
|
+
running = [
|
|
1154
|
+
run for run in _read_run_history(root, job_id) if run["status"] == "running"
|
|
1155
|
+
]
|
|
1156
|
+
return running[-1] if running else None
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _write_run(root: Path, job_id: str, run: Mapping[str, Any]) -> None:
|
|
1160
|
+
_metadata_atomic_json(
|
|
1161
|
+
root,
|
|
1162
|
+
("runs", _job_key(job_id)),
|
|
1163
|
+
f"{run['run_id']}.json",
|
|
1164
|
+
run,
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def run_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
|
|
1169
|
+
root = find_project(root)
|
|
1170
|
+
with tempfile.TemporaryDirectory(prefix="short-drama-inputs-") as directory:
|
|
1171
|
+
attempt_root = Path(directory)
|
|
1172
|
+
snapshot_root = attempt_root / "inputs"
|
|
1173
|
+
output_root = attempt_root / "outputs"
|
|
1174
|
+
snapshot_root.mkdir()
|
|
1175
|
+
output_root.mkdir()
|
|
1176
|
+
with _project_lock(root):
|
|
1177
|
+
if _active_run(root, job_id) is not None:
|
|
1178
|
+
raise RuntimeError("this job is already running")
|
|
1179
|
+
job = _read_job(root, job_id)
|
|
1180
|
+
command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
|
|
1181
|
+
try:
|
|
1182
|
+
receipt = _metadata_read_json(
|
|
1183
|
+
root,
|
|
1184
|
+
("confirmations",),
|
|
1185
|
+
f"{_job_key(job_id)}.json",
|
|
1186
|
+
maximum=MAX_RUN_RECORD_BYTES,
|
|
1187
|
+
)
|
|
1188
|
+
except FileNotFoundError as exc:
|
|
1189
|
+
raise ConfirmationRequiredError("job needs explicit confirmation") from exc
|
|
1190
|
+
if (
|
|
1191
|
+
not isinstance(receipt, dict)
|
|
1192
|
+
or receipt.get("fingerprint") != job.get("fingerprint")
|
|
1193
|
+
or receipt.get("consumed_at") is not None
|
|
1194
|
+
):
|
|
1195
|
+
raise ConfirmationRequiredError("job needs a new explicit confirmation")
|
|
1196
|
+
for output in job["outputs"]:
|
|
1197
|
+
target = _project_file(root, output)
|
|
1198
|
+
if target.exists() and not job["overwrite"]:
|
|
1199
|
+
raise FileExistsError(f"output exists and overwrite is false: {output}")
|
|
1200
|
+
# Pin every confirmed input into a private immutable snapshot before
|
|
1201
|
+
# consuming confirmation. Provider adapters never reopen live project paths.
|
|
1202
|
+
_snapshot_inputs(root, job, snapshot_root)
|
|
1203
|
+
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
|
1204
|
+
receipt["consumed_at"] = utc_now()
|
|
1205
|
+
receipt["run_id"] = run_id
|
|
1206
|
+
_metadata_atomic_json(
|
|
1207
|
+
root,
|
|
1208
|
+
("confirmations",),
|
|
1209
|
+
f"{_job_key(job_id)}.json",
|
|
1210
|
+
receipt,
|
|
1211
|
+
)
|
|
1212
|
+
run = {
|
|
1213
|
+
"schema_version": JOB_SCHEMA,
|
|
1214
|
+
"run_id": run_id,
|
|
1215
|
+
"job_id": job_id,
|
|
1216
|
+
"fingerprint": job["fingerprint"],
|
|
1217
|
+
"modality": job["modality"],
|
|
1218
|
+
"adapter": job["adapter"],
|
|
1219
|
+
"status": "running",
|
|
1220
|
+
"started_at": utc_now(),
|
|
1221
|
+
"finished_at": None,
|
|
1222
|
+
"outputs": [],
|
|
1223
|
+
}
|
|
1224
|
+
_write_run(root, job_id, run)
|
|
1225
|
+
|
|
1226
|
+
payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
|
|
1227
|
+
payload.update(
|
|
1228
|
+
{
|
|
1229
|
+
"run_id": run_id,
|
|
1230
|
+
"project_root": str(snapshot_root),
|
|
1231
|
+
"output_root": str(output_root),
|
|
1232
|
+
}
|
|
1233
|
+
)
|
|
1234
|
+
try:
|
|
1235
|
+
response = _run_adapter(command, timeout, payload, root)
|
|
1236
|
+
adapter_outputs = _validate_adapter_outputs(job, response, output_root)
|
|
1237
|
+
written: list[dict[str, Any]] = []
|
|
1238
|
+
with _project_lock(root):
|
|
1239
|
+
for target_name, source in adapter_outputs:
|
|
1240
|
+
target = _project_file(root, target_name, create_parent=True)
|
|
1241
|
+
digest, size = _copy_output(
|
|
1242
|
+
source, target, overwrite=bool(job["overwrite"])
|
|
1243
|
+
)
|
|
1244
|
+
written.append(
|
|
1245
|
+
{
|
|
1246
|
+
"path": target_name,
|
|
1247
|
+
"media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
|
|
1248
|
+
"bytes": size,
|
|
1249
|
+
"sha256": digest,
|
|
1250
|
+
}
|
|
1251
|
+
)
|
|
1252
|
+
run["status"] = "succeeded"
|
|
1253
|
+
run["finished_at"] = utc_now()
|
|
1254
|
+
run["outputs"] = written
|
|
1255
|
+
provider_job_id = response.get("provider_job_id")
|
|
1256
|
+
if isinstance(provider_job_id, str) and len(provider_job_id) <= 200:
|
|
1257
|
+
run["provider_job_id"] = provider_job_id
|
|
1258
|
+
_write_run(root, job_id, run)
|
|
1259
|
+
except Exception as exc:
|
|
1260
|
+
with _project_lock(root):
|
|
1261
|
+
run["status"] = "failed"
|
|
1262
|
+
run["finished_at"] = utc_now()
|
|
1263
|
+
if isinstance(exc, AdapterError) and exc.public_error is not None:
|
|
1264
|
+
run["error"] = exc.public_error
|
|
1265
|
+
_write_run(root, job_id, run)
|
|
1266
|
+
raise
|
|
1267
|
+
return {
|
|
1268
|
+
"job_id": job_id,
|
|
1269
|
+
"run_id": run_id,
|
|
1270
|
+
"state": "succeeded",
|
|
1271
|
+
"outputs": run["outputs"],
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def job_status(root: Path, *, job_id: str) -> dict[str, Any]:
|
|
1276
|
+
root = find_project(root)
|
|
1277
|
+
job = _read_job(root, job_id)
|
|
1278
|
+
latest = _latest_run(root, job_id)
|
|
1279
|
+
if not _inputs_current(root, job):
|
|
1280
|
+
state = "needs_reconfirmation"
|
|
1281
|
+
else:
|
|
1282
|
+
try:
|
|
1283
|
+
receipt = _metadata_read_json(
|
|
1284
|
+
root,
|
|
1285
|
+
("confirmations",),
|
|
1286
|
+
f"{_job_key(job_id)}.json",
|
|
1287
|
+
maximum=MAX_RUN_RECORD_BYTES,
|
|
1288
|
+
)
|
|
1289
|
+
except FileNotFoundError:
|
|
1290
|
+
receipt = None
|
|
1291
|
+
if latest and latest.get("status") == "running":
|
|
1292
|
+
state = "running"
|
|
1293
|
+
elif latest and latest.get("status") in {"succeeded", "failed"}:
|
|
1294
|
+
state = str(latest["status"])
|
|
1295
|
+
elif isinstance(receipt, Mapping) and receipt.get("consumed_at") is None:
|
|
1296
|
+
state = "confirmed"
|
|
1297
|
+
else:
|
|
1298
|
+
state = "needs_confirmation"
|
|
1299
|
+
return {
|
|
1300
|
+
"job_id": job_id,
|
|
1301
|
+
"modality": job["modality"],
|
|
1302
|
+
"adapter": job["adapter"],
|
|
1303
|
+
"outputs": job["outputs"],
|
|
1304
|
+
"state": state,
|
|
1305
|
+
"latest_run": latest,
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _hash_production_output(root: Path, relative: str) -> tuple[str, int]:
|
|
1310
|
+
digest = hashlib.sha256()
|
|
1311
|
+
size = 0
|
|
1312
|
+
with _open_project_input(root, relative) as handle:
|
|
1313
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1314
|
+
size += len(chunk)
|
|
1315
|
+
if size > MAX_OUTPUT_BYTES:
|
|
1316
|
+
raise ValueError("production output exceeds the size limit")
|
|
1317
|
+
digest.update(chunk)
|
|
1318
|
+
return digest.hexdigest(), size
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _validated_succeeded_outputs(
|
|
1322
|
+
job: Mapping[str, Any], run: Mapping[str, Any]
|
|
1323
|
+
) -> list[tuple[str, str, int]]:
|
|
1324
|
+
expected = job.get("outputs")
|
|
1325
|
+
entries = run.get("outputs")
|
|
1326
|
+
if (
|
|
1327
|
+
not isinstance(expected, list)
|
|
1328
|
+
or not isinstance(entries, list)
|
|
1329
|
+
or len(entries) != len(expected)
|
|
1330
|
+
):
|
|
1331
|
+
raise ValueError("succeeded outputs do not match the current job")
|
|
1332
|
+
validated: list[tuple[str, str, int]] = []
|
|
1333
|
+
# ``zip(strict=True)`` needs 3.10; the suite's floor is 3.9, so the length
|
|
1334
|
+
# agreement is asserted directly rather than by the zip flag.
|
|
1335
|
+
if len(expected) != len(entries):
|
|
1336
|
+
raise ValueError("expected %d outputs, got %d" % (len(expected), len(entries)))
|
|
1337
|
+
for expected_path, output in zip(expected, entries):
|
|
1338
|
+
if not isinstance(output, Mapping) or set(output) != {
|
|
1339
|
+
"path",
|
|
1340
|
+
"media_type",
|
|
1341
|
+
"bytes",
|
|
1342
|
+
"sha256",
|
|
1343
|
+
}:
|
|
1344
|
+
raise ValueError("succeeded output record fields are invalid")
|
|
1345
|
+
output_path = output.get("path")
|
|
1346
|
+
digest = output.get("sha256")
|
|
1347
|
+
size = output.get("bytes")
|
|
1348
|
+
media_type = output.get("media_type")
|
|
1349
|
+
if (
|
|
1350
|
+
not isinstance(expected_path, str)
|
|
1351
|
+
or output_path != expected_path
|
|
1352
|
+
or not isinstance(digest, str)
|
|
1353
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
1354
|
+
or not isinstance(size, int)
|
|
1355
|
+
or isinstance(size, bool)
|
|
1356
|
+
or size < 0
|
|
1357
|
+
or media_type
|
|
1358
|
+
!= MEDIA_TYPES.get(PurePosixPath(expected_path).suffix.casefold())
|
|
1359
|
+
):
|
|
1360
|
+
raise ValueError("succeeded output record is invalid")
|
|
1361
|
+
validated.append((output_path, digest, size))
|
|
1362
|
+
return validated
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def audit_project(root: Path) -> dict[str, Any]:
|
|
1366
|
+
"""Reconcile local attempt history and current output bytes, not media quality."""
|
|
1367
|
+
root = find_project(root)
|
|
1368
|
+
jobs: list[dict[str, Any]] = []
|
|
1369
|
+
problems: list[dict[str, Any]] = []
|
|
1370
|
+
for name in _metadata_json_names(root, ("jobs",)):
|
|
1371
|
+
try:
|
|
1372
|
+
document = _metadata_read_json(
|
|
1373
|
+
root, ("jobs",), name, maximum=MAX_JOB_BYTES
|
|
1374
|
+
)
|
|
1375
|
+
job = _validate_stored_job(root, document)
|
|
1376
|
+
job_id = job["job_id"]
|
|
1377
|
+
if not isinstance(job_id, str) or name != f"{_job_key(job_id)}.json":
|
|
1378
|
+
raise ValueError("stored job is invalid")
|
|
1379
|
+
jobs.append(job)
|
|
1380
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1381
|
+
problems.append(
|
|
1382
|
+
{
|
|
1383
|
+
"code": "invalid_job_record",
|
|
1384
|
+
"record": name,
|
|
1385
|
+
"action": "repair_production_metadata",
|
|
1386
|
+
}
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
attempts_total = 0
|
|
1390
|
+
attempts_succeeded = 0
|
|
1391
|
+
attempts_failed = 0
|
|
1392
|
+
attempts_running = 0
|
|
1393
|
+
attempts_superseded = 0
|
|
1394
|
+
repeated_content = 0
|
|
1395
|
+
recovered_jobs = 0
|
|
1396
|
+
running_jobs = 0
|
|
1397
|
+
terminal_failed_jobs = 0
|
|
1398
|
+
retryable_terminal_failed_jobs = 0
|
|
1399
|
+
current_output_claims: dict[
|
|
1400
|
+
str, tuple[tuple[datetime, str], str, int, str, str]
|
|
1401
|
+
] = {}
|
|
1402
|
+
|
|
1403
|
+
for job in jobs:
|
|
1404
|
+
job_id = str(job["job_id"])
|
|
1405
|
+
try:
|
|
1406
|
+
history = _read_run_history(root, job_id)
|
|
1407
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1408
|
+
problems.append(
|
|
1409
|
+
{
|
|
1410
|
+
"code": "invalid_run_history",
|
|
1411
|
+
"job_id": job_id,
|
|
1412
|
+
"action": "repair_production_metadata",
|
|
1413
|
+
}
|
|
1414
|
+
)
|
|
1415
|
+
continue
|
|
1416
|
+
attempts_total += len(history)
|
|
1417
|
+
completed = [run for run in history if run["status"] != "running"]
|
|
1418
|
+
running = [run for run in history if run["status"] == "running"]
|
|
1419
|
+
current = [
|
|
1420
|
+
run for run in history if run["fingerprint"] == job["fingerprint"]
|
|
1421
|
+
]
|
|
1422
|
+
current_completed = [run for run in current if run["status"] != "running"]
|
|
1423
|
+
current_running = [run for run in current if run["status"] == "running"]
|
|
1424
|
+
attempts_succeeded += sum(run["status"] == "succeeded" for run in completed)
|
|
1425
|
+
attempts_failed += sum(run["status"] == "failed" for run in completed)
|
|
1426
|
+
attempts_running += len(running)
|
|
1427
|
+
attempts_superseded += len(history) - len(current)
|
|
1428
|
+
if running:
|
|
1429
|
+
running_jobs += 1
|
|
1430
|
+
for run in running:
|
|
1431
|
+
problems.append(
|
|
1432
|
+
{
|
|
1433
|
+
"code": "running_attempt",
|
|
1434
|
+
"job_id": job_id,
|
|
1435
|
+
"run_id": str(run["run_id"]),
|
|
1436
|
+
"action": "wait_or_investigate_running_attempt",
|
|
1437
|
+
}
|
|
1438
|
+
)
|
|
1439
|
+
fingerprints = [
|
|
1440
|
+
str(run.get("fingerprint"))
|
|
1441
|
+
for run in history
|
|
1442
|
+
if isinstance(run.get("fingerprint"), str)
|
|
1443
|
+
]
|
|
1444
|
+
repeated_content += len(fingerprints) - len(set(fingerprints))
|
|
1445
|
+
succeeded_indexes = [
|
|
1446
|
+
index
|
|
1447
|
+
for index, run in enumerate(current_completed)
|
|
1448
|
+
if run["status"] == "succeeded"
|
|
1449
|
+
]
|
|
1450
|
+
failed_indexes = [
|
|
1451
|
+
index
|
|
1452
|
+
for index, run in enumerate(current_completed)
|
|
1453
|
+
if run["status"] == "failed"
|
|
1454
|
+
]
|
|
1455
|
+
if succeeded_indexes and failed_indexes and min(failed_indexes) < max(succeeded_indexes):
|
|
1456
|
+
recovered_jobs += 1
|
|
1457
|
+
if (
|
|
1458
|
+
not current_running
|
|
1459
|
+
and current_completed
|
|
1460
|
+
and current_completed[-1]["status"] == "failed"
|
|
1461
|
+
):
|
|
1462
|
+
terminal_failed_jobs += 1
|
|
1463
|
+
error = current_completed[-1].get("error")
|
|
1464
|
+
retryable = isinstance(error, Mapping) and error.get("retryable") is True
|
|
1465
|
+
if retryable:
|
|
1466
|
+
retryable_terminal_failed_jobs += 1
|
|
1467
|
+
problems.append(
|
|
1468
|
+
{
|
|
1469
|
+
"code": (
|
|
1470
|
+
"terminal_retryable_failure" if retryable else "terminal_failure"
|
|
1471
|
+
),
|
|
1472
|
+
"job_id": job_id,
|
|
1473
|
+
"run_id": current_completed[-1]["run_id"],
|
|
1474
|
+
"action": (
|
|
1475
|
+
"inspect_then_reconfirm_retry" if retryable else "inspect_failure"
|
|
1476
|
+
),
|
|
1477
|
+
}
|
|
1478
|
+
)
|
|
1479
|
+
for run in current:
|
|
1480
|
+
if run["status"] != "succeeded":
|
|
1481
|
+
continue
|
|
1482
|
+
try:
|
|
1483
|
+
outputs = _validated_succeeded_outputs(job, run)
|
|
1484
|
+
except ValueError:
|
|
1485
|
+
problems.append(
|
|
1486
|
+
{
|
|
1487
|
+
"code": "invalid_succeeded_output_record",
|
|
1488
|
+
"job_id": job_id,
|
|
1489
|
+
"run_id": str(run["run_id"]),
|
|
1490
|
+
"action": "repair_production_metadata",
|
|
1491
|
+
}
|
|
1492
|
+
)
|
|
1493
|
+
continue
|
|
1494
|
+
for output_path, digest, size in outputs:
|
|
1495
|
+
run_id = str(run["run_id"])
|
|
1496
|
+
claim_order = _completed_run_order(run)
|
|
1497
|
+
claim = current_output_claims.get(output_path)
|
|
1498
|
+
if claim is None or claim_order > claim[0]:
|
|
1499
|
+
current_output_claims[output_path] = (
|
|
1500
|
+
claim_order,
|
|
1501
|
+
digest,
|
|
1502
|
+
size,
|
|
1503
|
+
job_id,
|
|
1504
|
+
run_id,
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
if not _inputs_current(root, job):
|
|
1508
|
+
problems.append(
|
|
1509
|
+
{
|
|
1510
|
+
"code": "job_inputs_changed",
|
|
1511
|
+
"job_id": job_id,
|
|
1512
|
+
"action": "prepare_and_confirm_again",
|
|
1513
|
+
}
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
output_verified = 0
|
|
1517
|
+
output_missing = 0
|
|
1518
|
+
output_modified = 0
|
|
1519
|
+
for relative, (
|
|
1520
|
+
_claim_order,
|
|
1521
|
+
expected_digest,
|
|
1522
|
+
expected_size,
|
|
1523
|
+
job_id,
|
|
1524
|
+
run_id,
|
|
1525
|
+
) in sorted(current_output_claims.items()):
|
|
1526
|
+
try:
|
|
1527
|
+
safe_relative = _relative_path(relative, output=True)
|
|
1528
|
+
digest, size = _hash_production_output(root, safe_relative)
|
|
1529
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
1530
|
+
output_missing += 1
|
|
1531
|
+
problems.append(
|
|
1532
|
+
{
|
|
1533
|
+
"code": "output_missing_or_unsafe",
|
|
1534
|
+
"job_id": job_id,
|
|
1535
|
+
"run_id": run_id,
|
|
1536
|
+
"path": relative,
|
|
1537
|
+
"action": "restore_or_reproduce_output",
|
|
1538
|
+
}
|
|
1539
|
+
)
|
|
1540
|
+
continue
|
|
1541
|
+
if digest != expected_digest or size != expected_size:
|
|
1542
|
+
output_modified += 1
|
|
1543
|
+
problems.append(
|
|
1544
|
+
{
|
|
1545
|
+
"code": "output_digest_mismatch",
|
|
1546
|
+
"job_id": job_id,
|
|
1547
|
+
"run_id": run_id,
|
|
1548
|
+
"path": relative,
|
|
1549
|
+
"action": "review_current_bytes_or_reproduce_output",
|
|
1550
|
+
}
|
|
1551
|
+
)
|
|
1552
|
+
else:
|
|
1553
|
+
output_verified += 1
|
|
1554
|
+
|
|
1555
|
+
return {
|
|
1556
|
+
"status": "attention" if problems else "pass",
|
|
1557
|
+
"scope": "operational_evidence_only",
|
|
1558
|
+
"quality_verdict": "not_assessed",
|
|
1559
|
+
"jobs": {
|
|
1560
|
+
"total": len(jobs),
|
|
1561
|
+
"running": running_jobs,
|
|
1562
|
+
"recovered": recovered_jobs,
|
|
1563
|
+
"terminal_failed": terminal_failed_jobs,
|
|
1564
|
+
"retryable_terminal_failed": retryable_terminal_failed_jobs,
|
|
1565
|
+
},
|
|
1566
|
+
"attempts": {
|
|
1567
|
+
"total": attempts_total,
|
|
1568
|
+
"succeeded": attempts_succeeded,
|
|
1569
|
+
"failed": attempts_failed,
|
|
1570
|
+
"running": attempts_running,
|
|
1571
|
+
"superseded": attempts_superseded,
|
|
1572
|
+
"repeated_content": repeated_content,
|
|
1573
|
+
},
|
|
1574
|
+
"outputs": {
|
|
1575
|
+
"claimed_current": len(current_output_claims),
|
|
1576
|
+
"verified": output_verified,
|
|
1577
|
+
"missing": output_missing,
|
|
1578
|
+
"modified": output_modified,
|
|
1579
|
+
},
|
|
1580
|
+
"problems": problems,
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1585
|
+
parser = argparse.ArgumentParser(description="Run confirmed short-drama media jobs.")
|
|
1586
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
1587
|
+
prepare = commands.add_parser("prepare", help="Validate and preview a media job.")
|
|
1588
|
+
prepare.add_argument("project")
|
|
1589
|
+
prepare.add_argument("--job", required=True)
|
|
1590
|
+
confirm = commands.add_parser("confirm", help="Confirm the exact prepared job.")
|
|
1591
|
+
confirm.add_argument("project")
|
|
1592
|
+
confirm.add_argument("--job-id", required=True)
|
|
1593
|
+
confirm.add_argument("--confirmation", required=True)
|
|
1594
|
+
run = commands.add_parser("run", help="Execute a confirmed job through an adapter.")
|
|
1595
|
+
run.add_argument("project")
|
|
1596
|
+
run.add_argument("--job-id", required=True)
|
|
1597
|
+
run.add_argument("--adapter-config", required=True)
|
|
1598
|
+
status = commands.add_parser("status", help="Show one media job state.")
|
|
1599
|
+
status.add_argument("project")
|
|
1600
|
+
status.add_argument("--job-id", required=True)
|
|
1601
|
+
audit = commands.add_parser(
|
|
1602
|
+
"audit", help="Reconcile attempt history and current output bytes."
|
|
1603
|
+
)
|
|
1604
|
+
audit.add_argument("project")
|
|
1605
|
+
return parser
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1609
|
+
args = build_parser().parse_args(argv)
|
|
1610
|
+
try:
|
|
1611
|
+
if args.command == "prepare":
|
|
1612
|
+
result = prepare_job(Path(args.project), Path(args.job))
|
|
1613
|
+
elif args.command == "confirm":
|
|
1614
|
+
result = confirm_job(
|
|
1615
|
+
Path(args.project), job_id=args.job_id, confirmation=args.confirmation
|
|
1616
|
+
)
|
|
1617
|
+
elif args.command == "run":
|
|
1618
|
+
result = run_job(
|
|
1619
|
+
Path(args.project),
|
|
1620
|
+
job_id=args.job_id,
|
|
1621
|
+
adapter_config=Path(args.adapter_config),
|
|
1622
|
+
)
|
|
1623
|
+
elif args.command == "status":
|
|
1624
|
+
result = job_status(Path(args.project), job_id=args.job_id)
|
|
1625
|
+
else:
|
|
1626
|
+
result = audit_project(Path(args.project))
|
|
1627
|
+
print(json.dumps(result, ensure_ascii=True, sort_keys=True))
|
|
1628
|
+
return 0
|
|
1629
|
+
except Exception as exc:
|
|
1630
|
+
print(str(exc), file=sys.stderr)
|
|
1631
|
+
return 2
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
if __name__ == "__main__":
|
|
1635
|
+
raise SystemExit(main())
|