@lemoncat7/dsh-partner 2.5.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/docs/THIRD_PARTY_LICENSES.md +13 -0
- package/docs/attachment-storage.md +18 -0
- package/docs/director-skills.md +24 -0
- package/docs/long-task-continuation.md +1 -1
- package/docs/releases/2.6.0.md +11 -0
- package/docs/releases/2.7.1.md +10 -0
- package/lib/agent-runtime.d.ts +3 -1
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +35 -11
- package/lib/agent-runtime.js.map +1 -1
- package/lib/api/features/attachments-api.d.ts.map +1 -1
- package/lib/api/features/attachments-api.js +31 -1
- package/lib/api/features/attachments-api.js.map +1 -1
- package/lib/api.d.ts +1 -1
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/attachments/grouped-storage.d.ts +5 -0
- package/lib/attachments/grouped-storage.d.ts.map +1 -0
- package/lib/attachments/grouped-storage.js +47 -0
- package/lib/attachments/grouped-storage.js.map +1 -0
- package/lib/attachments/service.d.ts +17 -0
- package/lib/attachments/service.d.ts.map +1 -1
- package/lib/attachments/service.js +100 -8
- package/lib/attachments/service.js.map +1 -1
- package/lib/attachments/shared-storage.d.ts +7 -0
- package/lib/attachments/shared-storage.d.ts.map +1 -0
- package/lib/attachments/shared-storage.js +44 -0
- package/lib/attachments/shared-storage.js.map +1 -0
- package/lib/attachments/storage-admin.d.ts +6 -0
- package/lib/attachments/storage-admin.d.ts.map +1 -0
- package/lib/attachments/storage-admin.js +43 -0
- package/lib/attachments/storage-admin.js.map +1 -0
- package/lib/attachments/storage-types.d.ts +33 -0
- package/lib/attachments/storage-types.d.ts.map +1 -0
- package/lib/attachments/storage-types.js +2 -0
- package/lib/attachments/storage-types.js.map +1 -0
- package/lib/attachments/tool.d.ts.map +1 -1
- package/lib/attachments/tool.js +16 -5
- package/lib/attachments/tool.js.map +1 -1
- package/lib/channels/delivery-policy.d.ts.map +1 -1
- package/lib/channels/delivery-policy.js +6 -4
- package/lib/channels/delivery-policy.js.map +1 -1
- package/lib/channels/direct/message-format.js +294 -157
- package/lib/channels/direct/message-format.js.map +4 -4
- package/lib/channels/manager.d.ts +6 -3
- package/lib/channels/manager.d.ts.map +1 -1
- package/lib/channels/manager.js +64 -44
- package/lib/channels/manager.js.map +1 -1
- package/lib/channels/weixin/api.d.ts.map +1 -1
- package/lib/channels/weixin/api.js +7 -2
- package/lib/channels/weixin/api.js.map +1 -1
- package/lib/channels/weixin/errors.d.ts +14 -0
- package/lib/channels/weixin/errors.d.ts.map +1 -0
- package/lib/channels/weixin/errors.js +29 -0
- package/lib/channels/weixin/errors.js.map +1 -0
- package/lib/channels/weixin/poll.d.ts +14 -0
- package/lib/channels/weixin/poll.d.ts.map +1 -0
- package/lib/channels/weixin/poll.js +50 -0
- package/lib/channels/weixin/poll.js.map +1 -0
- package/lib/client-controller.d.ts.map +1 -1
- package/lib/client-controller.js +3 -3
- package/lib/client-controller.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +983 -575
- package/lib/client.js.map +4 -4
- package/lib/collaboration/composition.d.ts.map +1 -1
- package/lib/collaboration/composition.js +16 -6
- package/lib/collaboration/composition.js.map +1 -1
- package/lib/collaboration/domain.d.ts +6 -0
- package/lib/collaboration/domain.d.ts.map +1 -1
- package/lib/collaboration/domain.js.map +1 -1
- package/lib/collaboration/service.d.ts.map +1 -1
- package/lib/collaboration/service.js +33 -3
- package/lib/collaboration/service.js.map +1 -1
- package/lib/concern-notification.d.ts.map +1 -1
- package/lib/concern-notification.js +2 -1
- package/lib/concern-notification.js.map +1 -1
- package/lib/concern-tool.js +1 -1
- package/lib/concern-tool.js.map +1 -1
- package/lib/execution/reply-progress.d.ts.map +1 -1
- package/lib/execution/reply-progress.js +4 -2
- package/lib/execution/reply-progress.js.map +1 -1
- package/lib/execution/service.d.ts +1 -1
- package/lib/execution/service.d.ts.map +1 -1
- package/lib/execution/service.js +4 -7
- package/lib/execution/service.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/memory-reflection.js +2 -2
- package/lib/memory-reflection.js.map +1 -1
- package/lib/message-source.d.ts +25 -0
- package/lib/message-source.d.ts.map +1 -0
- package/lib/message-source.js +8 -0
- package/lib/message-source.js.map +1 -0
- package/lib/observation-loop.js +4 -4
- package/lib/observation-loop.js.map +1 -1
- package/lib/pendant/reader.js +1 -1
- package/lib/pendant/reader.js.map +1 -1
- package/lib/pendant/renderer.d.ts.map +1 -1
- package/lib/pendant/renderer.js +18 -2
- package/lib/pendant/renderer.js.map +1 -1
- package/lib/pendant/settings-panel.js +1 -1
- package/lib/pendant/settings-panel.js.map +1 -1
- package/lib/pendant/startup.d.ts +4 -0
- package/lib/pendant/startup.d.ts.map +1 -0
- package/lib/pendant/startup.js +64 -0
- package/lib/pendant/startup.js.map +1 -0
- package/lib/pendant/widget.d.ts.map +1 -1
- package/lib/pendant/widget.js +2 -1
- package/lib/pendant/widget.js.map +1 -1
- package/lib/pendant-renderer.js +169 -169
- package/lib/persona/service.js +1 -1
- package/lib/persona/service.js.map +1 -1
- package/lib/requirements/attachments.d.ts +7 -0
- package/lib/requirements/attachments.d.ts.map +1 -0
- package/lib/requirements/attachments.js +26 -0
- package/lib/requirements/attachments.js.map +1 -0
- package/lib/requirements/domain.d.ts +1 -1
- package/lib/requirements/domain.d.ts.map +1 -1
- package/lib/requirements/service.d.ts.map +1 -1
- package/lib/requirements/service.js +4 -1
- package/lib/requirements/service.js.map +1 -1
- package/lib/scheduler/channel-origin.d.ts +7 -0
- package/lib/scheduler/channel-origin.d.ts.map +1 -0
- package/lib/scheduler/channel-origin.js +27 -0
- package/lib/scheduler/channel-origin.js.map +1 -0
- package/lib/scheduler/continuations.d.ts +2 -2
- package/lib/scheduler/continuations.d.ts.map +1 -1
- package/lib/scheduler/continuations.js +28 -8
- package/lib/scheduler/continuations.js.map +1 -1
- package/lib/scheduler/domain.d.ts +13 -0
- package/lib/scheduler/domain.d.ts.map +1 -1
- package/lib/scheduler/final-reply.d.ts +13 -0
- package/lib/scheduler/final-reply.d.ts.map +1 -0
- package/lib/scheduler/final-reply.js +21 -0
- package/lib/scheduler/final-reply.js.map +1 -0
- package/lib/skills/builtin-resources.d.ts +6 -0
- package/lib/skills/builtin-resources.d.ts.map +1 -0
- package/lib/skills/builtin-resources.js +47 -0
- package/lib/skills/builtin-resources.js.map +1 -0
- package/lib/skills/builtin.d.ts +2 -5
- package/lib/skills/builtin.d.ts.map +1 -1
- package/lib/skills/builtin.js +6 -0
- package/lib/skills/builtin.js.map +1 -1
- package/lib/skills/director-builtins.d.ts +8 -0
- package/lib/skills/director-builtins.d.ts.map +1 -0
- package/lib/skills/director-builtins.js +65 -0
- package/lib/skills/director-builtins.js.map +1 -0
- package/lib/skills/drama-builtin.d.ts +3 -0
- package/lib/skills/drama-builtin.d.ts.map +1 -0
- package/lib/skills/drama-builtin.js +49 -0
- package/lib/skills/drama-builtin.js.map +1 -0
- package/lib/skills/long-task-continuation.d.ts +2 -2
- package/lib/skills/long-task-continuation.d.ts.map +1 -1
- package/lib/skills/long-task-continuation.js +2 -2
- package/lib/skills/service.d.ts.map +1 -1
- package/lib/skills/service.js +8 -3
- package/lib/skills/service.js.map +1 -1
- package/lib/skills/viora-canvas-builtin.d.ts +3 -0
- package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
- package/lib/skills/viora-canvas-builtin.js +19 -0
- package/lib/skills/viora-canvas-builtin.js.map +1 -0
- package/lib/storage/migrate-layout.d.ts.map +1 -1
- package/lib/storage/migrate-layout.js +13 -4
- package/lib/storage/migrate-layout.js.map +1 -1
- package/lib/tasks/context.js +1 -0
- package/lib/tasks/context.js.map +1 -1
- package/lib/tasks/continuation.d.ts.map +1 -1
- package/lib/tasks/continuation.js +3 -0
- package/lib/tasks/continuation.js.map +1 -1
- package/lib/tasks/domain.d.ts +2 -0
- package/lib/tasks/domain.d.ts.map +1 -1
- package/lib/tasks/service.d.ts +2 -1
- package/lib/tasks/service.d.ts.map +1 -1
- package/lib/tasks/service.js +15 -1
- package/lib/tasks/service.js.map +1 -1
- package/lib/ui/attachment-storage.d.ts +2 -0
- package/lib/ui/attachment-storage.d.ts.map +1 -0
- package/lib/ui/attachment-storage.js +59 -0
- package/lib/ui/attachment-storage.js.map +1 -0
- package/lib/ui/capability-editor.js +1 -1
- package/lib/ui/capability-editor.js.map +1 -1
- package/lib/ui/companion-access-panel.js +1 -1
- package/lib/ui/companion-access-panel.js.map +1 -1
- package/lib/ui/companion-create.js +1 -1
- package/lib/ui/companion-create.js.map +1 -1
- package/lib/ui/companion-picker.js +1 -1
- package/lib/ui/companion-picker.js.map +1 -1
- package/lib/ui/identity-editor.js +1 -1
- package/lib/ui/identity-editor.js.map +1 -1
- package/lib/ui/mcp-capabilities.js +1 -1
- package/lib/ui/mcp-capabilities.js.map +1 -1
- package/lib/ui/mcp-panel.js +1 -1
- package/lib/ui/mcp-panel.js.map +1 -1
- package/lib/ui/memory/concern-board.js +2 -2
- package/lib/ui/memory/concern-board.js.map +1 -1
- package/lib/ui/memory/concern-deletion.d.ts +1 -1
- package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
- package/lib/ui/memory/concern-deletion.js +9 -4
- package/lib/ui/memory/concern-deletion.js.map +1 -1
- package/lib/ui/memory/concern-panel.js +1 -1
- package/lib/ui/memory/concern-panel.js.map +1 -1
- package/lib/ui/memory/memory-library.js +1 -1
- package/lib/ui/memory/memory-library.js.map +1 -1
- package/lib/ui/memory/memory-panel.js +1 -1
- package/lib/ui/memory/memory-panel.js.map +1 -1
- package/lib/ui/partner-components.js +1 -1
- package/lib/ui/partner-components.js.map +1 -1
- package/lib/ui/requirement-board.js +1 -1
- package/lib/ui/requirement-board.js.map +1 -1
- package/lib/ui/requirement-dialog.js +1 -1
- package/lib/ui/requirement-dialog.js.map +1 -1
- package/lib/ui/requirement-tasks-panel.js +1 -1
- package/lib/ui/requirement-tasks-panel.js.map +1 -1
- package/lib/ui/schedule-panel.js +1 -1
- package/lib/ui/schedule-panel.js.map +1 -1
- package/lib/ui/skills-panel.js +1 -1
- package/lib/ui/skills-panel.js.map +1 -1
- package/lib/ui/storage-settings.d.ts.map +1 -1
- package/lib/ui/storage-settings.js +2 -1
- package/lib/ui/storage-settings.js.map +1 -1
- package/lib/ui/task-board-stage.js +1 -1
- package/lib/ui/task-board-stage.js.map +1 -1
- package/lib/ui/workspace-components.js +1 -1
- package/lib/ui/workspace-components.js.map +1 -1
- package/lib/ui/workspace-navigation.d.ts +16 -16
- package/lib/ui/workspace-navigation.js +1 -1
- package/lib/ui/workspace-navigation.js.map +1 -1
- package/package.json +53 -41
- package/resources/skills/cinematic-director/manifest.json +205 -0
- package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
- package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
- package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
- package/resources/skills/cinematic-director/upstream/README.md +507 -0
- package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
- package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
- package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
- package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
- package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
- package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
- package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
- package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
- package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
- package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
- package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
- package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
- package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
- package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
- package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
- package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
- package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
- package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
- package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
- package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
- package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
- package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
- package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
- package/resources/skills/director-skills/manifest.json +541 -0
- package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
- package/resources/skills/director-skills/upstream/LICENSE +21 -0
- package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
- package/resources/skills/director-skills/upstream/README.md +251 -0
- package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
- package/resources/skills/director-skills/upstream/SKILL.md +114 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
- package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
- package/resources/skills/drama-production/manifest.json +933 -0
- package/resources/skills/drama-production/upstream/LICENSE +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
- package/resources/skills/viora-canvas-production/manifest.json +23 -0
- package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
- package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
- package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
- package/resources/skills/viora-canvas-production/references/production-ledger.md +25 -0
package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py
ADDED
|
@@ -0,0 +1,1798 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A loopback-only HTTP dashboard for short-drama project text files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import contextlib
|
|
8
|
+
import errno
|
|
9
|
+
import hmac
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import signal
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
import webbrowser
|
|
25
|
+
from collections.abc import Iterator, Mapping
|
|
26
|
+
from http import HTTPStatus
|
|
27
|
+
from http.cookies import SimpleCookie
|
|
28
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path, PurePosixPath
|
|
30
|
+
from types import ModuleType
|
|
31
|
+
from typing import Any, BinaryIO, Union
|
|
32
|
+
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Creators run these scripts on whatever interpreter their machine provides, so
|
|
36
|
+
# an unsupported version must say so instead of failing inside an import.
|
|
37
|
+
MINIMUM_PYTHON = (3, 9)
|
|
38
|
+
if sys.version_info < MINIMUM_PYTHON:
|
|
39
|
+
raise SystemExit(
|
|
40
|
+
"short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format(
|
|
41
|
+
*MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
TEXT_EXTENSIONS = frozenset({".md", ".json", ".jsonl", ".txt", ".srt", ".ass"})
|
|
46
|
+
MEDIA_EXTENSIONS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
".png",
|
|
49
|
+
".jpg",
|
|
50
|
+
".jpeg",
|
|
51
|
+
".webp",
|
|
52
|
+
".gif",
|
|
53
|
+
".mp4",
|
|
54
|
+
".webm",
|
|
55
|
+
".mov",
|
|
56
|
+
".wav",
|
|
57
|
+
".mp3",
|
|
58
|
+
".m4a",
|
|
59
|
+
".aac",
|
|
60
|
+
".flac",
|
|
61
|
+
".opus",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
MEDIA_TYPES = {
|
|
65
|
+
".png": "image/png",
|
|
66
|
+
".jpg": "image/jpeg",
|
|
67
|
+
".jpeg": "image/jpeg",
|
|
68
|
+
".webp": "image/webp",
|
|
69
|
+
".gif": "image/gif",
|
|
70
|
+
".mp4": "video/mp4",
|
|
71
|
+
".webm": "video/webm",
|
|
72
|
+
".mov": "video/quicktime",
|
|
73
|
+
".wav": "audio/wav",
|
|
74
|
+
".mp3": "audio/mpeg",
|
|
75
|
+
".m4a": "audio/mp4",
|
|
76
|
+
".aac": "audio/aac",
|
|
77
|
+
".flac": "audio/flac",
|
|
78
|
+
".opus": "audio/ogg",
|
|
79
|
+
}
|
|
80
|
+
DEFAULT_MAX_DEPTH = 8
|
|
81
|
+
DEFAULT_MAX_NODES = 2_000
|
|
82
|
+
DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
83
|
+
DEFAULT_MAX_MEDIA_BYTES = 256 * 1024 * 1024
|
|
84
|
+
MAX_JSON_EXPANSION = 6
|
|
85
|
+
REQUEST_OVERHEAD_BYTES = 64 * 1024
|
|
86
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
87
|
+
SESSION_SCHEMA = "1.0"
|
|
88
|
+
SESSION_RELATIVE = PurePosixPath(".short-drama/dashboard.json")
|
|
89
|
+
SESSION_LOCK_SUFFIX = ".lock"
|
|
90
|
+
DETACH_TIMEOUT_SECONDS = 20.0
|
|
91
|
+
STOP_TIMEOUT_SECONDS = 10.0
|
|
92
|
+
STATIC_ROOT = SKILL_ROOT / "assets/dashboard"
|
|
93
|
+
|
|
94
|
+
# Windows opens files in text mode unless told otherwise, which would rewrite
|
|
95
|
+
# every newline and break the SHA-256 versions the editor round-trips on.
|
|
96
|
+
BINARY = getattr(os, "O_BINARY", 0)
|
|
97
|
+
NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
98
|
+
# POSIX pins directories by descriptor, which makes traversal free of races by
|
|
99
|
+
# construction. Everywhere else the same guarantee is approximated by checking
|
|
100
|
+
# each component and re-checking identity; see ``_PathDirectory``.
|
|
101
|
+
SECURE_DIR_FD = (
|
|
102
|
+
os.name != "nt"
|
|
103
|
+
and bool(getattr(os, "O_DIRECTORY", 0))
|
|
104
|
+
and bool(NOFOLLOW)
|
|
105
|
+
and os.open in os.supports_dir_fd
|
|
106
|
+
)
|
|
107
|
+
# Windows refuses to replace a file another process still holds open, and an
|
|
108
|
+
# editor or a search indexer is enough to hold one for a moment.
|
|
109
|
+
REPLACE_ATTEMPTS = 6
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class DashboardError(Exception):
|
|
113
|
+
def __init__(self, status: int, message: str) -> None:
|
|
114
|
+
super().__init__(message)
|
|
115
|
+
self.status = status
|
|
116
|
+
self.message = message
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_loopback(host: str) -> bool:
|
|
120
|
+
if host.casefold() == "localhost":
|
|
121
|
+
return True
|
|
122
|
+
try:
|
|
123
|
+
return ipaddress.ip_address(host).is_loopback
|
|
124
|
+
except ValueError:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_project_tool(root: Path) -> ModuleType:
|
|
129
|
+
"""Dynamically load the suite's canonical project status implementation."""
|
|
130
|
+
root = root.resolve()
|
|
131
|
+
installed_script = root / "scripts/project_tool.py"
|
|
132
|
+
repository_script = root / "skills/short-drama/scripts/project_tool.py"
|
|
133
|
+
script = installed_script if installed_script.is_file() else repository_script
|
|
134
|
+
spec = importlib.util.spec_from_file_location("dashboard_project_tool", script)
|
|
135
|
+
if spec is None or spec.loader is None:
|
|
136
|
+
raise RuntimeError(f"cannot load project tool: {script}")
|
|
137
|
+
module = importlib.util.module_from_spec(spec)
|
|
138
|
+
spec.loader.exec_module(module)
|
|
139
|
+
return module
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _version(data: bytes) -> str:
|
|
143
|
+
return hashlib.sha256(data).hexdigest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_structured_text(path: PurePosixPath, content: str) -> None:
|
|
147
|
+
suffix = path.suffix.casefold()
|
|
148
|
+
if suffix == ".json":
|
|
149
|
+
try:
|
|
150
|
+
json.loads(content)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise DashboardError(
|
|
153
|
+
HTTPStatus.BAD_REQUEST,
|
|
154
|
+
f"JSON is invalid at line {exc.lineno}, column {exc.colno}",
|
|
155
|
+
) from exc
|
|
156
|
+
return
|
|
157
|
+
if suffix != ".jsonl":
|
|
158
|
+
return
|
|
159
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
160
|
+
if not line.strip():
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
json.loads(line)
|
|
164
|
+
except json.JSONDecodeError as exc:
|
|
165
|
+
raise DashboardError(
|
|
166
|
+
HTTPStatus.BAD_REQUEST,
|
|
167
|
+
f"JSONL line {line_number} is invalid at column {exc.colno}",
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_link_or_reparse(details: os.stat_result) -> bool:
|
|
172
|
+
"""Say whether a stat result describes something that must not be traversed.
|
|
173
|
+
|
|
174
|
+
``S_ISLNK`` alone is not enough on Windows: junctions and every other
|
|
175
|
+
reparse point redirect just as well, and need no privilege to create.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
attributes = getattr(details, "st_file_attributes", 0)
|
|
179
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
180
|
+
return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _entry_is_link(entry: os.DirEntry) -> bool:
|
|
184
|
+
try:
|
|
185
|
+
if entry.is_symlink():
|
|
186
|
+
return True
|
|
187
|
+
if os.name != "nt":
|
|
188
|
+
return False
|
|
189
|
+
return _is_link_or_reparse(entry.stat(follow_symlinks=False))
|
|
190
|
+
except OSError:
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _identity(details: os.stat_result) -> tuple[int, int]:
|
|
195
|
+
return details.st_dev, details.st_ino
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _same_object(before: tuple[int, int], after: tuple[int, int]) -> bool:
|
|
199
|
+
# A volume that reports no inode gives nothing to compare, so treat the
|
|
200
|
+
# check as inapplicable there instead of failing every read on it.
|
|
201
|
+
if not before[1] or not after[1]:
|
|
202
|
+
return True
|
|
203
|
+
return before == after
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _DescriptorDirectory:
|
|
207
|
+
"""A directory pinned by descriptor, the POSIX backend.
|
|
208
|
+
|
|
209
|
+
Every operation names a file relative to a descriptor the request already
|
|
210
|
+
holds, so the pin is the directory itself rather than a name that resolves
|
|
211
|
+
to one, and nothing in between can be swapped mid-request.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
__slots__ = ("_fd",)
|
|
215
|
+
|
|
216
|
+
contract = (
|
|
217
|
+
"project_status_at",
|
|
218
|
+
"is_protected_project_text",
|
|
219
|
+
"coordinated_project_text_edit_at",
|
|
220
|
+
"project_path_lifecycle_at",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def __init__(self, descriptor: int) -> None:
|
|
224
|
+
self._fd = descriptor
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def open_root(cls, path: Path) -> _DescriptorDirectory:
|
|
228
|
+
return cls(os.open(path, os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW))
|
|
229
|
+
|
|
230
|
+
def reopen(self) -> _DescriptorDirectory:
|
|
231
|
+
# A directory cursor belongs to the open file description, so dup()
|
|
232
|
+
# would still let concurrent requests advance one another's scan.
|
|
233
|
+
# Opening "." gives every caller an independent description.
|
|
234
|
+
return _DescriptorDirectory(
|
|
235
|
+
os.open(".", os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW, dir_fd=self._fd)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def child(self, name: str) -> _DescriptorDirectory:
|
|
239
|
+
return _DescriptorDirectory(
|
|
240
|
+
os.open(
|
|
241
|
+
name, os.O_RDONLY | os.O_DIRECTORY | NOFOLLOW, dir_fd=self._fd
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@contextlib.contextmanager
|
|
246
|
+
def scandir(self) -> Iterator[Iterator[os.DirEntry]]:
|
|
247
|
+
with os.scandir(self._fd) as entries:
|
|
248
|
+
yield entries
|
|
249
|
+
|
|
250
|
+
def open_regular(self, name: str, flags: int, mode: int = 0o666) -> int:
|
|
251
|
+
return os.open(name, flags | NOFOLLOW | BINARY, mode, dir_fd=self._fd)
|
|
252
|
+
|
|
253
|
+
def replace(self, source: str, target: str) -> None:
|
|
254
|
+
os.replace(source, target, src_dir_fd=self._fd, dst_dir_fd=self._fd)
|
|
255
|
+
|
|
256
|
+
def unlink(self, name: str) -> None:
|
|
257
|
+
os.unlink(name, dir_fd=self._fd)
|
|
258
|
+
|
|
259
|
+
def sync(self) -> None:
|
|
260
|
+
os.fsync(self._fd)
|
|
261
|
+
|
|
262
|
+
def close(self) -> None:
|
|
263
|
+
if self._fd >= 0:
|
|
264
|
+
os.close(self._fd)
|
|
265
|
+
self._fd = -1
|
|
266
|
+
|
|
267
|
+
def status(self, tool: ModuleType, project_root: str) -> dict[str, Any]:
|
|
268
|
+
return tool.project_status_at(self._fd, project_root=project_root)
|
|
269
|
+
|
|
270
|
+
def lifecycle(self, tool: ModuleType, relative: str) -> dict[str, str] | None:
|
|
271
|
+
return tool.project_path_lifecycle_at(self._fd, relative)
|
|
272
|
+
|
|
273
|
+
def coordinated_edit(
|
|
274
|
+
self, tool: ModuleType, relative: str, expected_version: str
|
|
275
|
+
) -> Any:
|
|
276
|
+
return tool.coordinated_project_text_edit_at(
|
|
277
|
+
self._fd, relative, expected_version
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _PathDirectory:
|
|
282
|
+
"""A directory pinned by verified path, for platforms without ``openat``.
|
|
283
|
+
|
|
284
|
+
Windows cannot name a file relative to an open directory, so the guarantee
|
|
285
|
+
is rebuilt from two checks: every component is inspected with ``os.lstat``,
|
|
286
|
+
which never follows, and refused if it is a link or any other reparse
|
|
287
|
+
point; then the pinned directory's identity is re-checked before each
|
|
288
|
+
operation and an opened file's identity against what was inspected.
|
|
289
|
+
|
|
290
|
+
This is weaker than a descriptor -- a swap timed between the two checks is
|
|
291
|
+
not caught. It is the boundary this suite already accepts for Windows
|
|
292
|
+
``verify``, and the adversary it admits, a local process running as the
|
|
293
|
+
creator, can open these files directly anyway.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
__slots__ = ("_path", "_identity")
|
|
297
|
+
|
|
298
|
+
contract = (
|
|
299
|
+
"project_status_from_root",
|
|
300
|
+
"is_protected_project_text",
|
|
301
|
+
"coordinated_project_text_edit",
|
|
302
|
+
"project_path_lifecycle",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def __init__(self, path: Path, identity: tuple[int, int]) -> None:
|
|
306
|
+
self._path = path
|
|
307
|
+
self._identity = identity
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def open_root(cls, path: Path) -> _PathDirectory:
|
|
311
|
+
details = os.lstat(path)
|
|
312
|
+
if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
|
|
313
|
+
raise OSError(
|
|
314
|
+
errno.ENOTDIR, "not a plain directory", str(path)
|
|
315
|
+
)
|
|
316
|
+
return cls(path, _identity(details))
|
|
317
|
+
|
|
318
|
+
def _pinned(self) -> Path:
|
|
319
|
+
details = os.lstat(self._path)
|
|
320
|
+
if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
|
|
321
|
+
raise OSError(
|
|
322
|
+
errno.ENOTDIR, "pinned directory is no longer a directory",
|
|
323
|
+
str(self._path),
|
|
324
|
+
)
|
|
325
|
+
if not _same_object(self._identity, _identity(details)):
|
|
326
|
+
raise OSError(
|
|
327
|
+
errno.ENOENT, "pinned directory was replaced", str(self._path)
|
|
328
|
+
)
|
|
329
|
+
return self._path
|
|
330
|
+
|
|
331
|
+
def reopen(self) -> _PathDirectory:
|
|
332
|
+
return _PathDirectory(self._pinned(), self._identity)
|
|
333
|
+
|
|
334
|
+
def child(self, name: str) -> _PathDirectory:
|
|
335
|
+
return _PathDirectory.open_root(self._pinned() / name)
|
|
336
|
+
|
|
337
|
+
@contextlib.contextmanager
|
|
338
|
+
def scandir(self) -> Iterator[Iterator[os.DirEntry]]:
|
|
339
|
+
with os.scandir(self._pinned()) as entries:
|
|
340
|
+
yield entries
|
|
341
|
+
|
|
342
|
+
def open_regular(self, name: str, flags: int, mode: int = 0o666) -> int:
|
|
343
|
+
target = self._pinned() / name
|
|
344
|
+
expected: tuple[int, int] | None = None
|
|
345
|
+
if not flags & os.O_CREAT:
|
|
346
|
+
# O_CREAT|O_EXCL already refuses an existing name, so only an open
|
|
347
|
+
# of something that must already be there needs inspecting first.
|
|
348
|
+
details = os.lstat(target)
|
|
349
|
+
if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
|
|
350
|
+
raise OSError(errno.EPERM, "not a plain file", str(target))
|
|
351
|
+
expected = _identity(details)
|
|
352
|
+
descriptor = os.open(target, flags | BINARY, mode)
|
|
353
|
+
if expected is None:
|
|
354
|
+
return descriptor
|
|
355
|
+
try:
|
|
356
|
+
if not _same_object(expected, _identity(os.fstat(descriptor))):
|
|
357
|
+
raise OSError(
|
|
358
|
+
errno.ENOENT, "file was replaced while opening", str(target)
|
|
359
|
+
)
|
|
360
|
+
except BaseException:
|
|
361
|
+
os.close(descriptor)
|
|
362
|
+
raise
|
|
363
|
+
return descriptor
|
|
364
|
+
|
|
365
|
+
def replace(self, source: str, target: str) -> None:
|
|
366
|
+
root = self._pinned()
|
|
367
|
+
delay = 0.01
|
|
368
|
+
for remaining in range(REPLACE_ATTEMPTS - 1, -1, -1):
|
|
369
|
+
try:
|
|
370
|
+
os.replace(root / source, root / target)
|
|
371
|
+
return
|
|
372
|
+
except PermissionError:
|
|
373
|
+
if not remaining:
|
|
374
|
+
raise
|
|
375
|
+
time.sleep(delay)
|
|
376
|
+
delay *= 2
|
|
377
|
+
|
|
378
|
+
def unlink(self, name: str) -> None:
|
|
379
|
+
os.unlink(self._pinned() / name)
|
|
380
|
+
|
|
381
|
+
def sync(self) -> None:
|
|
382
|
+
# Windows has no directory handle to flush; the file itself was already
|
|
383
|
+
# fsynced before the replace, which is the same guarantee the CLI makes.
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
def close(self) -> None:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
def status(self, tool: ModuleType, project_root: str) -> dict[str, Any]:
|
|
390
|
+
return tool.project_status_from_root(
|
|
391
|
+
self._pinned(), project_root=project_root
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def lifecycle(self, tool: ModuleType, relative: str) -> dict[str, str] | None:
|
|
395
|
+
return tool.project_path_lifecycle(self._pinned(), relative)
|
|
396
|
+
|
|
397
|
+
def coordinated_edit(
|
|
398
|
+
self, tool: ModuleType, relative: str, expected_version: str
|
|
399
|
+
) -> Any:
|
|
400
|
+
return tool.coordinated_project_text_edit(
|
|
401
|
+
self._pinned(), relative, expected_version
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
Directory = Union[_DescriptorDirectory, _PathDirectory]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def directory_backend() -> type[_DescriptorDirectory] | type[_PathDirectory]:
|
|
409
|
+
return _DescriptorDirectory if SECURE_DIR_FD else _PathDirectory
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@contextlib.contextmanager
|
|
413
|
+
def _open_parent_directory(
|
|
414
|
+
root: Directory, relative: PurePosixPath
|
|
415
|
+
) -> Iterator[tuple[Directory, str]]:
|
|
416
|
+
current = root.reopen()
|
|
417
|
+
try:
|
|
418
|
+
for part in relative.parts[:-1]:
|
|
419
|
+
child = current.child(part)
|
|
420
|
+
current.close()
|
|
421
|
+
current = child
|
|
422
|
+
yield current, relative.name
|
|
423
|
+
finally:
|
|
424
|
+
current.close()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class ProjectStore:
|
|
428
|
+
def __init__(
|
|
429
|
+
self,
|
|
430
|
+
workspace: Path,
|
|
431
|
+
project_tool: ModuleType,
|
|
432
|
+
*,
|
|
433
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
434
|
+
max_nodes: int = DEFAULT_MAX_NODES,
|
|
435
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
436
|
+
max_media_bytes: int = DEFAULT_MAX_MEDIA_BYTES,
|
|
437
|
+
) -> None:
|
|
438
|
+
workspace = workspace.expanduser().resolve(strict=True)
|
|
439
|
+
if not workspace.is_dir():
|
|
440
|
+
raise NotADirectoryError(workspace)
|
|
441
|
+
self.workspace = workspace
|
|
442
|
+
self.project_tool = project_tool
|
|
443
|
+
self.max_depth = max_depth
|
|
444
|
+
self.max_nodes = max_nodes
|
|
445
|
+
self.max_file_bytes = max_file_bytes
|
|
446
|
+
self.max_media_bytes = max_media_bytes
|
|
447
|
+
# JSON can encode one content byte as a six-byte ``\u00xx`` escape.
|
|
448
|
+
# Size the transport envelope from the configured file limit so a
|
|
449
|
+
# valid text file is never rejected only because of JSON escaping.
|
|
450
|
+
self.max_request_bytes = (
|
|
451
|
+
max_file_bytes * MAX_JSON_EXPANSION + REQUEST_OVERHEAD_BYTES
|
|
452
|
+
)
|
|
453
|
+
# Which four entry points this server invokes depends on how the
|
|
454
|
+
# platform can pin a directory: by descriptor, or by verified path.
|
|
455
|
+
self.backend = directory_backend()
|
|
456
|
+
missing = [
|
|
457
|
+
name
|
|
458
|
+
for name in self.backend.contract
|
|
459
|
+
if not callable(getattr(project_tool, name, None))
|
|
460
|
+
]
|
|
461
|
+
if missing:
|
|
462
|
+
raise RuntimeError(
|
|
463
|
+
"project tool does not support the Dashboard contract: "
|
|
464
|
+
+ ", ".join(missing)
|
|
465
|
+
)
|
|
466
|
+
self._workspace = self.backend.open_root(workspace)
|
|
467
|
+
self._write_lock = threading.Lock()
|
|
468
|
+
|
|
469
|
+
def close(self) -> None:
|
|
470
|
+
self._workspace.close()
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _project_id(relative: str) -> str:
|
|
474
|
+
return hashlib.sha256(relative.encode("utf-8")).hexdigest()[:20]
|
|
475
|
+
|
|
476
|
+
def discover(self) -> tuple[list[dict[str, Any]], list[str]]:
|
|
477
|
+
projects: list[dict[str, Any]] = []
|
|
478
|
+
warnings: list[str] = []
|
|
479
|
+
nodes = 0
|
|
480
|
+
depth_truncated = False
|
|
481
|
+
node_truncated = False
|
|
482
|
+
|
|
483
|
+
def walk(directory: Directory, parts: tuple[str, ...], depth: int) -> None:
|
|
484
|
+
nonlocal depth_truncated, node_truncated, nodes
|
|
485
|
+
if nodes >= self.max_nodes:
|
|
486
|
+
return
|
|
487
|
+
try:
|
|
488
|
+
with directory.scandir() as iterator:
|
|
489
|
+
entries = sorted(iterator, key=lambda item: item.name.casefold())
|
|
490
|
+
except OSError as exc:
|
|
491
|
+
warnings.append(f"无法读取 {'/'.join(parts) or '.'}: {exc}")
|
|
492
|
+
return
|
|
493
|
+
for entry in entries:
|
|
494
|
+
if nodes >= self.max_nodes:
|
|
495
|
+
node_truncated = True
|
|
496
|
+
return
|
|
497
|
+
nodes += 1
|
|
498
|
+
if _entry_is_link(entry):
|
|
499
|
+
continue
|
|
500
|
+
if (
|
|
501
|
+
entry.is_file(follow_symlinks=False)
|
|
502
|
+
and entry.name == "short-drama.json"
|
|
503
|
+
):
|
|
504
|
+
relative = "/".join(parts) or "."
|
|
505
|
+
title = "未命名短剧"
|
|
506
|
+
try:
|
|
507
|
+
raw, _mode = self._read_regular(directory, entry.name)
|
|
508
|
+
manifest = json.loads(raw.decode("utf-8"))
|
|
509
|
+
candidate = manifest.get("title") if isinstance(manifest, dict) else None
|
|
510
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
511
|
+
title = " ".join(candidate.split())[:200]
|
|
512
|
+
except (DashboardError, OSError, UnicodeError, json.JSONDecodeError):
|
|
513
|
+
pass
|
|
514
|
+
projects.append(
|
|
515
|
+
{
|
|
516
|
+
"id": self._project_id(relative),
|
|
517
|
+
"path": relative,
|
|
518
|
+
"title": title,
|
|
519
|
+
}
|
|
520
|
+
)
|
|
521
|
+
continue
|
|
522
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
523
|
+
continue
|
|
524
|
+
if depth >= self.max_depth:
|
|
525
|
+
depth_truncated = True
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
child = directory.child(entry.name)
|
|
529
|
+
except OSError:
|
|
530
|
+
continue
|
|
531
|
+
try:
|
|
532
|
+
walk(child, (*parts, entry.name), depth + 1)
|
|
533
|
+
finally:
|
|
534
|
+
child.close()
|
|
535
|
+
|
|
536
|
+
# Reopen rather than share: on POSIX a directory cursor belongs to the
|
|
537
|
+
# open file description, so two concurrent discoveries walking one
|
|
538
|
+
# handle would advance each other's scan.
|
|
539
|
+
discovery = self._workspace.reopen()
|
|
540
|
+
try:
|
|
541
|
+
walk(discovery, (), 0)
|
|
542
|
+
finally:
|
|
543
|
+
discovery.close()
|
|
544
|
+
if node_truncated:
|
|
545
|
+
warnings.append(f"项目发现达到节点上限 {self.max_nodes},结果已截断")
|
|
546
|
+
if depth_truncated:
|
|
547
|
+
warnings.append(f"项目发现达到深度上限 {self.max_depth},更深目录已截断")
|
|
548
|
+
return projects, warnings
|
|
549
|
+
|
|
550
|
+
@contextlib.contextmanager
|
|
551
|
+
def _pinned_project(self, project_id: str) -> Iterator[tuple[Directory, Path]]:
|
|
552
|
+
"""Pin the project root and report its creator-facing path.
|
|
553
|
+
|
|
554
|
+
Every parent is opened from the pinned workspace with the backend's
|
|
555
|
+
no-follow guarantee, so a directory swapped for a symlink mid-request
|
|
556
|
+
fails instead of redirecting the operation. The yielded path is for
|
|
557
|
+
display only — no caller reads through it.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
selected = next(
|
|
561
|
+
(item for item in self.discover()[0] if item["id"] == project_id), None
|
|
562
|
+
)
|
|
563
|
+
if selected is None:
|
|
564
|
+
raise DashboardError(HTTPStatus.NOT_FOUND, "project not found")
|
|
565
|
+
display = (
|
|
566
|
+
self.workspace
|
|
567
|
+
if selected["path"] == "."
|
|
568
|
+
else self.workspace / selected["path"]
|
|
569
|
+
)
|
|
570
|
+
# Reopen instead of sharing the workspace handle: on POSIX that handle
|
|
571
|
+
# carries a seek position other requests would advance, including when
|
|
572
|
+
# the workspace itself is the selected project.
|
|
573
|
+
directory = self._workspace.reopen()
|
|
574
|
+
marker = -1
|
|
575
|
+
try:
|
|
576
|
+
for part in PurePosixPath(selected["path"]).parts:
|
|
577
|
+
if part == ".":
|
|
578
|
+
continue
|
|
579
|
+
child = directory.child(part)
|
|
580
|
+
directory.close()
|
|
581
|
+
directory = child
|
|
582
|
+
marker = directory.open_regular("short-drama.json", os.O_RDONLY)
|
|
583
|
+
if not stat.S_ISREG(os.fstat(marker).st_mode):
|
|
584
|
+
raise OSError("project manifest is not a regular file")
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
if marker >= 0:
|
|
587
|
+
os.close(marker)
|
|
588
|
+
directory.close()
|
|
589
|
+
raise DashboardError(
|
|
590
|
+
HTTPStatus.FORBIDDEN, "project root cannot be opened safely"
|
|
591
|
+
) from exc
|
|
592
|
+
os.close(marker)
|
|
593
|
+
try:
|
|
594
|
+
yield directory, display
|
|
595
|
+
finally:
|
|
596
|
+
directory.close()
|
|
597
|
+
|
|
598
|
+
def status(self, project_id: str) -> dict[str, Any]:
|
|
599
|
+
with self._pinned_project(project_id) as (directory, root):
|
|
600
|
+
return directory.status(self.project_tool, str(root))
|
|
601
|
+
|
|
602
|
+
@staticmethod
|
|
603
|
+
def _safe_relative(relative: str) -> PurePosixPath:
|
|
604
|
+
raw = unquote(relative).replace("\\", "/")
|
|
605
|
+
pure = PurePosixPath(raw)
|
|
606
|
+
if (
|
|
607
|
+
not raw
|
|
608
|
+
or pure.is_absolute()
|
|
609
|
+
or any(part in ("", ".", "..") for part in pure.parts)
|
|
610
|
+
):
|
|
611
|
+
raise DashboardError(HTTPStatus.BAD_REQUEST, "unsafe project-relative path")
|
|
612
|
+
return pure
|
|
613
|
+
|
|
614
|
+
def _is_protected(self, relative: PurePosixPath) -> bool:
|
|
615
|
+
return bool(
|
|
616
|
+
self.project_tool.is_protected_project_text(relative.as_posix())
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def tree(self, project_id: str) -> dict[str, Any]:
|
|
620
|
+
with self._pinned_project(project_id) as (directory, _root):
|
|
621
|
+
return self._tree_from_root(directory)
|
|
622
|
+
|
|
623
|
+
def _tree_from_root(self, directory: Directory) -> dict[str, Any]:
|
|
624
|
+
nodes = 0
|
|
625
|
+
warnings: list[str] = []
|
|
626
|
+
depth_truncated = False
|
|
627
|
+
node_truncated = False
|
|
628
|
+
oversized_text = 0
|
|
629
|
+
oversized_media = 0
|
|
630
|
+
|
|
631
|
+
def scan(
|
|
632
|
+
parent: Directory, parts: tuple[str, ...], depth: int
|
|
633
|
+
) -> list[dict[str, Any]]:
|
|
634
|
+
nonlocal depth_truncated, node_truncated, nodes
|
|
635
|
+
nonlocal oversized_media, oversized_text
|
|
636
|
+
children: list[dict[str, Any]] = []
|
|
637
|
+
try:
|
|
638
|
+
with parent.scandir() as iterator:
|
|
639
|
+
entries = sorted(
|
|
640
|
+
iterator,
|
|
641
|
+
key=lambda item: (
|
|
642
|
+
not item.is_dir(follow_symlinks=False),
|
|
643
|
+
item.name.casefold(),
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
except OSError as exc:
|
|
647
|
+
warnings.append(f"无法读取 {'/'.join(parts) or '.'}: {exc}")
|
|
648
|
+
return children
|
|
649
|
+
for entry in entries:
|
|
650
|
+
if _entry_is_link(entry):
|
|
651
|
+
continue
|
|
652
|
+
relative = "/".join((*parts, entry.name))
|
|
653
|
+
if parts == () and entry.name.casefold() == ".short-drama":
|
|
654
|
+
continue
|
|
655
|
+
if nodes >= self.max_nodes:
|
|
656
|
+
node_truncated = True
|
|
657
|
+
break
|
|
658
|
+
nodes += 1
|
|
659
|
+
if entry.is_dir(follow_symlinks=False):
|
|
660
|
+
node: dict[str, Any] = {
|
|
661
|
+
"name": entry.name,
|
|
662
|
+
"path": relative,
|
|
663
|
+
"type": "directory",
|
|
664
|
+
"children": [],
|
|
665
|
+
}
|
|
666
|
+
if depth < self.max_depth:
|
|
667
|
+
try:
|
|
668
|
+
child = parent.child(entry.name)
|
|
669
|
+
except OSError:
|
|
670
|
+
continue
|
|
671
|
+
try:
|
|
672
|
+
node["children"] = scan(
|
|
673
|
+
child, (*parts, entry.name), depth + 1
|
|
674
|
+
)
|
|
675
|
+
finally:
|
|
676
|
+
child.close()
|
|
677
|
+
else:
|
|
678
|
+
depth_truncated = True
|
|
679
|
+
node["truncated"] = True
|
|
680
|
+
children.append(node)
|
|
681
|
+
continue
|
|
682
|
+
suffix = PurePosixPath(entry.name).suffix.casefold()
|
|
683
|
+
if suffix not in TEXT_EXTENSIONS and suffix not in MEDIA_EXTENSIONS:
|
|
684
|
+
continue
|
|
685
|
+
try:
|
|
686
|
+
size = entry.stat(follow_symlinks=False).st_size
|
|
687
|
+
except OSError:
|
|
688
|
+
continue
|
|
689
|
+
limit = (
|
|
690
|
+
self.max_file_bytes
|
|
691
|
+
if suffix in TEXT_EXTENSIONS
|
|
692
|
+
else self.max_media_bytes
|
|
693
|
+
)
|
|
694
|
+
if size > limit:
|
|
695
|
+
if suffix in TEXT_EXTENSIONS:
|
|
696
|
+
oversized_text += 1
|
|
697
|
+
else:
|
|
698
|
+
oversized_media += 1
|
|
699
|
+
children.append(
|
|
700
|
+
{
|
|
701
|
+
"name": entry.name,
|
|
702
|
+
"path": relative,
|
|
703
|
+
"type": "text" if suffix in TEXT_EXTENSIONS else "media",
|
|
704
|
+
"size": size,
|
|
705
|
+
"oversize": size > limit,
|
|
706
|
+
"writable": suffix in TEXT_EXTENSIONS
|
|
707
|
+
and not self._is_protected(PurePosixPath(relative)),
|
|
708
|
+
}
|
|
709
|
+
)
|
|
710
|
+
return children
|
|
711
|
+
|
|
712
|
+
tree = scan(directory, (), 0)
|
|
713
|
+
if node_truncated:
|
|
714
|
+
warnings.append(f"文件树达到节点上限 {self.max_nodes},结果已截断")
|
|
715
|
+
if depth_truncated:
|
|
716
|
+
warnings.append(f"文件树达到深度上限 {self.max_depth},更深目录已截断")
|
|
717
|
+
if oversized_text:
|
|
718
|
+
warnings.append(
|
|
719
|
+
f"{oversized_text} 个文本文件超过大小上限 {self.max_file_bytes} bytes,内容预览已禁用"
|
|
720
|
+
)
|
|
721
|
+
if oversized_media:
|
|
722
|
+
warnings.append(
|
|
723
|
+
f"{oversized_media} 个媒体文件超过大小上限 {self.max_media_bytes} bytes,媒体预览已禁用"
|
|
724
|
+
)
|
|
725
|
+
return {
|
|
726
|
+
"tree": tree,
|
|
727
|
+
"warnings": warnings,
|
|
728
|
+
"limits": {
|
|
729
|
+
"depth": self.max_depth,
|
|
730
|
+
"nodes": self.max_nodes,
|
|
731
|
+
"fileBytes": self.max_file_bytes,
|
|
732
|
+
"mediaBytes": self.max_media_bytes,
|
|
733
|
+
},
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
def read_text(self, project_id: str, relative: str) -> dict[str, Any]:
|
|
737
|
+
pure = self._safe_relative(relative)
|
|
738
|
+
if pure.suffix.casefold() not in TEXT_EXTENSIONS:
|
|
739
|
+
raise DashboardError(
|
|
740
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "file type is not editable text"
|
|
741
|
+
)
|
|
742
|
+
try:
|
|
743
|
+
with self._pinned_project(project_id) as (directory, _root):
|
|
744
|
+
with _open_parent_directory(directory, pure) as (parent, name):
|
|
745
|
+
data, _ = self._read_regular(parent, name)
|
|
746
|
+
except OSError as exc:
|
|
747
|
+
raise DashboardError(
|
|
748
|
+
HTTPStatus.FORBIDDEN, "text file cannot be opened safely"
|
|
749
|
+
) from exc
|
|
750
|
+
try:
|
|
751
|
+
content = data.decode("utf-8")
|
|
752
|
+
except UnicodeDecodeError as exc:
|
|
753
|
+
raise DashboardError(
|
|
754
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "text file must be UTF-8"
|
|
755
|
+
) from exc
|
|
756
|
+
return {
|
|
757
|
+
"path": pure.as_posix(),
|
|
758
|
+
"content": content,
|
|
759
|
+
"version": _version(data),
|
|
760
|
+
"writable": not self._is_protected(pure),
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
def _read_regular(self, parent: Directory, name: str) -> tuple[bytes, int]:
|
|
764
|
+
descriptor = parent.open_regular(name, os.O_RDONLY)
|
|
765
|
+
try:
|
|
766
|
+
details = os.fstat(descriptor)
|
|
767
|
+
if not stat.S_ISREG(details.st_mode):
|
|
768
|
+
raise DashboardError(HTTPStatus.BAD_REQUEST, "path is not a file")
|
|
769
|
+
if details.st_size > self.max_file_bytes:
|
|
770
|
+
raise DashboardError(
|
|
771
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
|
772
|
+
"file exceeds preview limit",
|
|
773
|
+
)
|
|
774
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
775
|
+
descriptor = -1
|
|
776
|
+
data = handle.read(self.max_file_bytes + 1)
|
|
777
|
+
if len(data) > self.max_file_bytes:
|
|
778
|
+
raise DashboardError(
|
|
779
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
|
780
|
+
"file exceeds preview limit",
|
|
781
|
+
)
|
|
782
|
+
return data, stat.S_IMODE(details.st_mode)
|
|
783
|
+
finally:
|
|
784
|
+
if descriptor >= 0:
|
|
785
|
+
os.close(descriptor)
|
|
786
|
+
|
|
787
|
+
def _replace_text(
|
|
788
|
+
self,
|
|
789
|
+
parent: Directory,
|
|
790
|
+
name: str,
|
|
791
|
+
encoded: bytes,
|
|
792
|
+
mode: int,
|
|
793
|
+
expected_version: str,
|
|
794
|
+
) -> None:
|
|
795
|
+
temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
|
|
796
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
797
|
+
descriptor = parent.open_regular(temporary_name, flags, mode)
|
|
798
|
+
replaced = False
|
|
799
|
+
try:
|
|
800
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
801
|
+
descriptor = -1
|
|
802
|
+
handle.write(encoded)
|
|
803
|
+
handle.flush()
|
|
804
|
+
os.fsync(handle.fileno())
|
|
805
|
+
# Windows has no fchmod, and its chmod only moves the read-only
|
|
806
|
+
# bit; the mode the file already carries is the right one there.
|
|
807
|
+
if hasattr(os, "fchmod"):
|
|
808
|
+
os.fchmod(handle.fileno(), mode)
|
|
809
|
+
latest, _ = self._read_regular(parent, name)
|
|
810
|
+
if _version(latest) != expected_version:
|
|
811
|
+
raise DashboardError(
|
|
812
|
+
HTTPStatus.CONFLICT, "file changed since it was opened"
|
|
813
|
+
)
|
|
814
|
+
try:
|
|
815
|
+
parent.replace(temporary_name, name)
|
|
816
|
+
except PermissionError as exc:
|
|
817
|
+
# Windows refuses the rename while anything else holds the
|
|
818
|
+
# target open; elsewhere the same errno means the file is
|
|
819
|
+
# write-protected. Neither is an unsafe path, so neither may
|
|
820
|
+
# report as one.
|
|
821
|
+
raise DashboardError(
|
|
822
|
+
HTTPStatus.CONFLICT, "file is locked or not writable"
|
|
823
|
+
) from exc
|
|
824
|
+
replaced = True
|
|
825
|
+
parent.sync()
|
|
826
|
+
finally:
|
|
827
|
+
if descriptor >= 0:
|
|
828
|
+
os.close(descriptor)
|
|
829
|
+
if not replaced:
|
|
830
|
+
# Best effort: the save already failed, and a cleanup that
|
|
831
|
+
# cannot run must not replace that error with its own.
|
|
832
|
+
with contextlib.suppress(OSError):
|
|
833
|
+
parent.unlink(temporary_name)
|
|
834
|
+
|
|
835
|
+
@contextlib.contextmanager
|
|
836
|
+
def _coordinated_edit(
|
|
837
|
+
self, directory: Directory, relative: PurePosixPath, expected_version: str
|
|
838
|
+
) -> Iterator[None]:
|
|
839
|
+
try:
|
|
840
|
+
with directory.coordinated_edit(
|
|
841
|
+
self.project_tool, relative.as_posix(), expected_version
|
|
842
|
+
):
|
|
843
|
+
yield
|
|
844
|
+
except Exception as exc:
|
|
845
|
+
if exc.__class__.__name__ == "ProjectConflictError":
|
|
846
|
+
raise DashboardError(HTTPStatus.CONFLICT, str(exc)) from exc
|
|
847
|
+
if isinstance(exc, ValueError):
|
|
848
|
+
raise DashboardError(
|
|
849
|
+
HTTPStatus.FORBIDDEN, "project path changed during the save"
|
|
850
|
+
) from exc
|
|
851
|
+
if isinstance(exc, OSError):
|
|
852
|
+
# Both backends refuse to take the project lock through a
|
|
853
|
+
# redirected `.short-drama`. That is a refused path, not the
|
|
854
|
+
# internal error it used to surface as.
|
|
855
|
+
raise DashboardError(
|
|
856
|
+
HTTPStatus.FORBIDDEN, "text file cannot be replaced safely"
|
|
857
|
+
) from exc
|
|
858
|
+
raise
|
|
859
|
+
|
|
860
|
+
def write_text(
|
|
861
|
+
self, project_id: str, relative: str, content: Any, expected_version: Any
|
|
862
|
+
) -> dict[str, Any]:
|
|
863
|
+
pure = self._safe_relative(relative)
|
|
864
|
+
if pure.suffix.casefold() not in TEXT_EXTENSIONS:
|
|
865
|
+
raise DashboardError(
|
|
866
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "file type is not editable text"
|
|
867
|
+
)
|
|
868
|
+
if self._is_protected(pure):
|
|
869
|
+
raise DashboardError(
|
|
870
|
+
HTTPStatus.FORBIDDEN, "file is protected and read-only"
|
|
871
|
+
)
|
|
872
|
+
if not isinstance(content, str) or not isinstance(expected_version, str):
|
|
873
|
+
raise DashboardError(
|
|
874
|
+
HTTPStatus.BAD_REQUEST,
|
|
875
|
+
"content and expectedVersion are required strings",
|
|
876
|
+
)
|
|
877
|
+
if re.fullmatch(r"[0-9a-f]{64}", expected_version) is None:
|
|
878
|
+
raise DashboardError(
|
|
879
|
+
HTTPStatus.BAD_REQUEST, "expectedVersion must be a SHA-256 digest"
|
|
880
|
+
)
|
|
881
|
+
encoded = content.encode("utf-8")
|
|
882
|
+
if len(encoded) > self.max_file_bytes:
|
|
883
|
+
raise DashboardError(
|
|
884
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "content exceeds file limit"
|
|
885
|
+
)
|
|
886
|
+
_validate_structured_text(pure, content)
|
|
887
|
+
with (
|
|
888
|
+
self._write_lock,
|
|
889
|
+
self._pinned_project(project_id) as (directory, _root),
|
|
890
|
+
self._coordinated_edit(directory, pure, expected_version),
|
|
891
|
+
):
|
|
892
|
+
# The in-process lock and the project's file lock keep the version
|
|
893
|
+
# check and atomic replace together. Pinned parents stop a
|
|
894
|
+
# concurrent symlink swap from redirecting the write.
|
|
895
|
+
try:
|
|
896
|
+
with _open_parent_directory(directory, pure) as (parent, name):
|
|
897
|
+
current, mode = self._read_regular(parent, name)
|
|
898
|
+
if _version(current) != expected_version:
|
|
899
|
+
raise DashboardError(
|
|
900
|
+
HTTPStatus.CONFLICT,
|
|
901
|
+
"file changed since it was opened",
|
|
902
|
+
)
|
|
903
|
+
self._replace_text(
|
|
904
|
+
parent, name, encoded, mode, expected_version
|
|
905
|
+
)
|
|
906
|
+
except OSError as exc:
|
|
907
|
+
raise DashboardError(
|
|
908
|
+
HTTPStatus.FORBIDDEN, "text file cannot be replaced safely"
|
|
909
|
+
) from exc
|
|
910
|
+
return {"path": pure.as_posix(), "version": _version(encoded), "saved": True}
|
|
911
|
+
|
|
912
|
+
def media_info(self, project_id: str, relative: str) -> dict[str, Any]:
|
|
913
|
+
pure = self._safe_relative(relative)
|
|
914
|
+
with self._pinned_project(project_id) as (directory, _root):
|
|
915
|
+
handle, content_type, size = self._open_media(directory, pure)
|
|
916
|
+
handle.close()
|
|
917
|
+
lifecycle = directory.lifecycle(self.project_tool, pure.as_posix())
|
|
918
|
+
kind = "image"
|
|
919
|
+
if content_type.startswith("video/"):
|
|
920
|
+
kind = "video"
|
|
921
|
+
elif content_type.startswith("audio/"):
|
|
922
|
+
kind = "audio"
|
|
923
|
+
result = {
|
|
924
|
+
"path": pure.as_posix(),
|
|
925
|
+
"kind": kind,
|
|
926
|
+
"contentType": content_type,
|
|
927
|
+
"size": size,
|
|
928
|
+
"readOnly": True,
|
|
929
|
+
"contentUrl": f"/api/media/content?{urlencode({'project': project_id, 'path': pure.as_posix()})}",
|
|
930
|
+
"status": "ready",
|
|
931
|
+
}
|
|
932
|
+
if lifecycle is not None:
|
|
933
|
+
result["lifecycle"] = lifecycle
|
|
934
|
+
return result
|
|
935
|
+
|
|
936
|
+
def open_media(
|
|
937
|
+
self, project_id: str, relative: str
|
|
938
|
+
) -> tuple[BinaryIO, PurePosixPath, str, int]:
|
|
939
|
+
pure = self._safe_relative(relative)
|
|
940
|
+
with self._pinned_project(project_id) as (directory, _root):
|
|
941
|
+
handle, content_type, size = self._open_media(directory, pure)
|
|
942
|
+
return handle, pure, content_type, size
|
|
943
|
+
|
|
944
|
+
def _open_media(
|
|
945
|
+
self, directory: Directory, pure: PurePosixPath
|
|
946
|
+
) -> tuple[BinaryIO, str, int]:
|
|
947
|
+
content_type = MEDIA_TYPES.get(pure.suffix.casefold())
|
|
948
|
+
if content_type is None:
|
|
949
|
+
raise DashboardError(
|
|
950
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported preview media"
|
|
951
|
+
)
|
|
952
|
+
try:
|
|
953
|
+
with _open_parent_directory(directory, pure) as (parent, name):
|
|
954
|
+
descriptor = parent.open_regular(name, os.O_RDONLY)
|
|
955
|
+
except OSError as exc:
|
|
956
|
+
raise DashboardError(
|
|
957
|
+
HTTPStatus.FORBIDDEN, "media file cannot be opened safely"
|
|
958
|
+
) from exc
|
|
959
|
+
try:
|
|
960
|
+
details = os.fstat(descriptor)
|
|
961
|
+
if not stat.S_ISREG(details.st_mode):
|
|
962
|
+
raise DashboardError(HTTPStatus.BAD_REQUEST, "media path is not a file")
|
|
963
|
+
if details.st_size > self.max_media_bytes:
|
|
964
|
+
raise DashboardError(
|
|
965
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "media exceeds preview limit"
|
|
966
|
+
)
|
|
967
|
+
return os.fdopen(descriptor, "rb"), content_type, details.st_size
|
|
968
|
+
except Exception:
|
|
969
|
+
os.close(descriptor)
|
|
970
|
+
raise
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
class DashboardHTTPServer(ThreadingHTTPServer):
|
|
974
|
+
daemon_threads = True
|
|
975
|
+
|
|
976
|
+
def __init__(self, server_address: tuple[str, int], store: ProjectStore) -> None:
|
|
977
|
+
self.store = store
|
|
978
|
+
self.workspace_fingerprint = workspace_fingerprint(store.workspace)
|
|
979
|
+
self.access_token = secrets.token_urlsafe(32)
|
|
980
|
+
self.api_prefix = f"/_short_drama/{secrets.token_urlsafe(18)}"
|
|
981
|
+
cookie_suffix = hashlib.sha256(self.api_prefix.encode("utf-8")).hexdigest()[:16]
|
|
982
|
+
self.session_cookie = f"short_drama_{cookie_suffix}"
|
|
983
|
+
super().__init__(server_address, DashboardHandler)
|
|
984
|
+
|
|
985
|
+
def allowed_authority(self, authority: str) -> bool:
|
|
986
|
+
parsed = urlsplit(f"//{authority}")
|
|
987
|
+
if (
|
|
988
|
+
not parsed.hostname
|
|
989
|
+
or not _is_loopback(parsed.hostname)
|
|
990
|
+
or parsed.username is not None
|
|
991
|
+
or parsed.password is not None
|
|
992
|
+
or bool(parsed.path or parsed.query or parsed.fragment)
|
|
993
|
+
):
|
|
994
|
+
return False
|
|
995
|
+
try:
|
|
996
|
+
port = parsed.port
|
|
997
|
+
except ValueError:
|
|
998
|
+
return False
|
|
999
|
+
return port == self.server_address[1]
|
|
1000
|
+
|
|
1001
|
+
def server_close(self) -> None:
|
|
1002
|
+
try:
|
|
1003
|
+
super().server_close()
|
|
1004
|
+
finally:
|
|
1005
|
+
self.store.close()
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
class DashboardHandler(SimpleHTTPRequestHandler):
|
|
1009
|
+
server: DashboardHTTPServer
|
|
1010
|
+
|
|
1011
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
1012
|
+
super().__init__(*args, directory=str(STATIC_ROOT), **kwargs)
|
|
1013
|
+
|
|
1014
|
+
def end_headers(self) -> None:
|
|
1015
|
+
self.send_header("Cache-Control", "no-store")
|
|
1016
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
1017
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
1018
|
+
self.send_header(
|
|
1019
|
+
"Content-Security-Policy",
|
|
1020
|
+
"default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; media-src 'self'",
|
|
1021
|
+
)
|
|
1022
|
+
super().end_headers()
|
|
1023
|
+
|
|
1024
|
+
def _request_token(self) -> str:
|
|
1025
|
+
header = self.headers.get("X-Short-Drama-Token")
|
|
1026
|
+
if header:
|
|
1027
|
+
return header
|
|
1028
|
+
cookies = SimpleCookie()
|
|
1029
|
+
try:
|
|
1030
|
+
cookies.load(self.headers.get("Cookie", ""))
|
|
1031
|
+
except Exception:
|
|
1032
|
+
return ""
|
|
1033
|
+
morsel = cookies.get(self.server.session_cookie)
|
|
1034
|
+
return morsel.value if morsel is not None else ""
|
|
1035
|
+
|
|
1036
|
+
def _security_ok(self, *, require_token: bool = False) -> bool:
|
|
1037
|
+
host = self.headers.get("Host", "")
|
|
1038
|
+
if not host or not self.server.allowed_authority(host):
|
|
1039
|
+
self._json(HTTPStatus.FORBIDDEN, {"error": "invalid Host header"})
|
|
1040
|
+
return False
|
|
1041
|
+
origin = self.headers.get("Origin")
|
|
1042
|
+
if origin:
|
|
1043
|
+
parsed = urlsplit(origin)
|
|
1044
|
+
if (
|
|
1045
|
+
parsed.scheme != "http"
|
|
1046
|
+
or not parsed.netloc
|
|
1047
|
+
or bool(parsed.path not in {"", "/"} or parsed.query or parsed.fragment)
|
|
1048
|
+
or not self.server.allowed_authority(parsed.netloc)
|
|
1049
|
+
):
|
|
1050
|
+
self._json(HTTPStatus.FORBIDDEN, {"error": "invalid Origin header"})
|
|
1051
|
+
return False
|
|
1052
|
+
# Compare bytes, not str: `hmac.compare_digest` raises TypeError on a
|
|
1053
|
+
# str operand holding any codepoint above U+007F, and http.client
|
|
1054
|
+
# decodes header bytes as iso-8859-1, so a single 0x80+ byte in the
|
|
1055
|
+
# header would escape this pre-auth check and drop the connection with
|
|
1056
|
+
# no response at all.
|
|
1057
|
+
if require_token and not hmac.compare_digest(
|
|
1058
|
+
self._request_token().encode("utf-8", "surrogateescape"),
|
|
1059
|
+
self.server.access_token.encode("utf-8"),
|
|
1060
|
+
):
|
|
1061
|
+
self._json(HTTPStatus.UNAUTHORIZED, {"error": "dashboard session required"})
|
|
1062
|
+
return False
|
|
1063
|
+
return True
|
|
1064
|
+
|
|
1065
|
+
def _json(
|
|
1066
|
+
self, status: int, value: Any, *, headers: dict[str, str] | None = None
|
|
1067
|
+
) -> None:
|
|
1068
|
+
body = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode(
|
|
1069
|
+
"utf-8"
|
|
1070
|
+
)
|
|
1071
|
+
self.send_response(status)
|
|
1072
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
1073
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1074
|
+
for name, content in (headers or {}).items():
|
|
1075
|
+
self.send_header(name, content)
|
|
1076
|
+
self.end_headers()
|
|
1077
|
+
self.wfile.write(body)
|
|
1078
|
+
|
|
1079
|
+
def _query(self) -> tuple[str, dict[str, list[str]]]:
|
|
1080
|
+
parsed = urlsplit(self.path)
|
|
1081
|
+
path = parsed.path
|
|
1082
|
+
if path.startswith(f"{self.server.api_prefix}/api/"):
|
|
1083
|
+
path = path[len(self.server.api_prefix) :]
|
|
1084
|
+
return path, parse_qs(parsed.query, keep_blank_values=True)
|
|
1085
|
+
|
|
1086
|
+
@staticmethod
|
|
1087
|
+
def _one(query: dict[str, list[str]], name: str) -> str:
|
|
1088
|
+
values = query.get(name)
|
|
1089
|
+
if not values or not values[0]:
|
|
1090
|
+
raise DashboardError(
|
|
1091
|
+
HTTPStatus.BAD_REQUEST, f"missing query parameter: {name}"
|
|
1092
|
+
)
|
|
1093
|
+
return values[0]
|
|
1094
|
+
|
|
1095
|
+
@staticmethod
|
|
1096
|
+
def _byte_range(value: str | None, size: int) -> tuple[int, int] | None:
|
|
1097
|
+
if value is None:
|
|
1098
|
+
return None
|
|
1099
|
+
if not value.startswith("bytes=") or "," in value or size <= 0:
|
|
1100
|
+
raise DashboardError(
|
|
1101
|
+
HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
|
|
1102
|
+
)
|
|
1103
|
+
bounds = value[6:].strip()
|
|
1104
|
+
if "-" not in bounds:
|
|
1105
|
+
raise DashboardError(
|
|
1106
|
+
HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
|
|
1107
|
+
)
|
|
1108
|
+
first, last = bounds.split("-", 1)
|
|
1109
|
+
try:
|
|
1110
|
+
if first:
|
|
1111
|
+
start = int(first)
|
|
1112
|
+
end = int(last) if last else size - 1
|
|
1113
|
+
if start < 0 or start >= size or end < start:
|
|
1114
|
+
raise ValueError
|
|
1115
|
+
end = min(end, size - 1)
|
|
1116
|
+
else:
|
|
1117
|
+
suffix = int(last)
|
|
1118
|
+
if suffix <= 0:
|
|
1119
|
+
raise ValueError
|
|
1120
|
+
start = max(0, size - suffix)
|
|
1121
|
+
end = size - 1
|
|
1122
|
+
except ValueError as exc:
|
|
1123
|
+
raise DashboardError(
|
|
1124
|
+
HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, "invalid byte range"
|
|
1125
|
+
) from exc
|
|
1126
|
+
return start, end
|
|
1127
|
+
|
|
1128
|
+
def _serve_media(self, query: dict[str, list[str]], *, send_body: bool) -> None:
|
|
1129
|
+
handle, _, content_type, size = self.server.store.open_media(
|
|
1130
|
+
self._one(query, "project"), self._one(query, "path")
|
|
1131
|
+
)
|
|
1132
|
+
try:
|
|
1133
|
+
try:
|
|
1134
|
+
selected = self._byte_range(self.headers.get("Range"), size)
|
|
1135
|
+
except DashboardError:
|
|
1136
|
+
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
|
1137
|
+
self.send_header("Content-Range", f"bytes */{size}")
|
|
1138
|
+
self.send_header("Content-Length", "0")
|
|
1139
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
1140
|
+
self.end_headers()
|
|
1141
|
+
return
|
|
1142
|
+
start, end = selected if selected is not None else (0, size - 1)
|
|
1143
|
+
length = 0 if size == 0 else end - start + 1
|
|
1144
|
+
self.send_response(
|
|
1145
|
+
HTTPStatus.PARTIAL_CONTENT if selected is not None else HTTPStatus.OK
|
|
1146
|
+
)
|
|
1147
|
+
self.send_header("Content-Type", content_type)
|
|
1148
|
+
self.send_header("Content-Length", str(length))
|
|
1149
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
1150
|
+
if selected is not None:
|
|
1151
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
1152
|
+
self.end_headers()
|
|
1153
|
+
if not send_body or length == 0:
|
|
1154
|
+
return
|
|
1155
|
+
handle.seek(start)
|
|
1156
|
+
remaining = length
|
|
1157
|
+
while remaining:
|
|
1158
|
+
chunk = handle.read(min(64 * 1024, remaining))
|
|
1159
|
+
if not chunk:
|
|
1160
|
+
break
|
|
1161
|
+
self.wfile.write(chunk)
|
|
1162
|
+
remaining -= len(chunk)
|
|
1163
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1164
|
+
pass
|
|
1165
|
+
finally:
|
|
1166
|
+
handle.close()
|
|
1167
|
+
|
|
1168
|
+
def do_GET(self) -> None:
|
|
1169
|
+
path, query = self._query()
|
|
1170
|
+
if not self._security_ok(require_token=path.startswith("/api/")):
|
|
1171
|
+
return
|
|
1172
|
+
try:
|
|
1173
|
+
if path == "/api/projects":
|
|
1174
|
+
projects, warnings = self.server.store.discover()
|
|
1175
|
+
self._json(HTTPStatus.OK, {"projects": projects, "warnings": warnings})
|
|
1176
|
+
return
|
|
1177
|
+
if path == "/api/status":
|
|
1178
|
+
self._json(
|
|
1179
|
+
HTTPStatus.OK, self.server.store.status(self._one(query, "project"))
|
|
1180
|
+
)
|
|
1181
|
+
return
|
|
1182
|
+
if path == "/api/tree":
|
|
1183
|
+
self._json(
|
|
1184
|
+
HTTPStatus.OK, self.server.store.tree(self._one(query, "project"))
|
|
1185
|
+
)
|
|
1186
|
+
return
|
|
1187
|
+
if path == "/api/file":
|
|
1188
|
+
self._json(
|
|
1189
|
+
HTTPStatus.OK,
|
|
1190
|
+
self.server.store.read_text(
|
|
1191
|
+
self._one(query, "project"), self._one(query, "path")
|
|
1192
|
+
),
|
|
1193
|
+
)
|
|
1194
|
+
return
|
|
1195
|
+
if path == "/api/media":
|
|
1196
|
+
info = self.server.store.media_info(
|
|
1197
|
+
self._one(query, "project"), self._one(query, "path")
|
|
1198
|
+
)
|
|
1199
|
+
info["contentUrl"] = (
|
|
1200
|
+
f"{self.server.api_prefix}{info['contentUrl']}"
|
|
1201
|
+
)
|
|
1202
|
+
self._json(
|
|
1203
|
+
HTTPStatus.OK,
|
|
1204
|
+
info,
|
|
1205
|
+
)
|
|
1206
|
+
return
|
|
1207
|
+
if path == "/api/media/content":
|
|
1208
|
+
self._serve_media(query, send_body=True)
|
|
1209
|
+
return
|
|
1210
|
+
if path.startswith("/api/"):
|
|
1211
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
|
|
1212
|
+
return
|
|
1213
|
+
if path == "/":
|
|
1214
|
+
self.path = "/index.html"
|
|
1215
|
+
super().do_GET()
|
|
1216
|
+
except DashboardError as exc:
|
|
1217
|
+
self._json(exc.status, {"error": exc.message})
|
|
1218
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1219
|
+
self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
|
|
1220
|
+
except Exception:
|
|
1221
|
+
# A handler that lets anything escape closes the socket without a
|
|
1222
|
+
# status line, so the browser reports a network failure it cannot
|
|
1223
|
+
# act on. Every unexpected type still becomes an answered request.
|
|
1224
|
+
self._json(
|
|
1225
|
+
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal dashboard error"}
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
def do_HEAD(self) -> None:
|
|
1229
|
+
path, query = self._query()
|
|
1230
|
+
if not self._security_ok(require_token=path.startswith("/api/")):
|
|
1231
|
+
return
|
|
1232
|
+
if path == "/api/media/content":
|
|
1233
|
+
try:
|
|
1234
|
+
self._serve_media(query, send_body=False)
|
|
1235
|
+
except DashboardError as exc:
|
|
1236
|
+
self._json(exc.status, {"error": exc.message})
|
|
1237
|
+
except OSError as exc:
|
|
1238
|
+
self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
|
|
1239
|
+
except Exception:
|
|
1240
|
+
self._json(
|
|
1241
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
1242
|
+
{"error": "internal dashboard error"},
|
|
1243
|
+
)
|
|
1244
|
+
return
|
|
1245
|
+
if path.startswith("/api/"):
|
|
1246
|
+
self._json(
|
|
1247
|
+
HTTPStatus.METHOD_NOT_ALLOWED,
|
|
1248
|
+
{"error": "HEAD is not supported for API endpoints"},
|
|
1249
|
+
)
|
|
1250
|
+
return
|
|
1251
|
+
if path == "/":
|
|
1252
|
+
self.path = "/index.html"
|
|
1253
|
+
super().do_HEAD()
|
|
1254
|
+
|
|
1255
|
+
def do_PUT(self) -> None:
|
|
1256
|
+
path, query = self._query()
|
|
1257
|
+
if not self._security_ok(require_token=path.startswith("/api/")):
|
|
1258
|
+
return
|
|
1259
|
+
if path != "/api/file":
|
|
1260
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
|
|
1261
|
+
return
|
|
1262
|
+
try:
|
|
1263
|
+
raw_length = self.headers.get("Content-Length")
|
|
1264
|
+
if raw_length is None:
|
|
1265
|
+
raise DashboardError(
|
|
1266
|
+
HTTPStatus.LENGTH_REQUIRED, "Content-Length is required"
|
|
1267
|
+
)
|
|
1268
|
+
length = int(raw_length)
|
|
1269
|
+
if length < 0 or length > self.server.store.max_request_bytes:
|
|
1270
|
+
raise DashboardError(
|
|
1271
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "request body is too large"
|
|
1272
|
+
)
|
|
1273
|
+
if self.headers.get_content_type() != "application/json":
|
|
1274
|
+
raise DashboardError(
|
|
1275
|
+
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
|
1276
|
+
"Content-Type must be application/json",
|
|
1277
|
+
)
|
|
1278
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
1279
|
+
if not isinstance(payload, dict):
|
|
1280
|
+
raise DashboardError(
|
|
1281
|
+
HTTPStatus.BAD_REQUEST, "request body must be an object"
|
|
1282
|
+
)
|
|
1283
|
+
result = self.server.store.write_text(
|
|
1284
|
+
self._one(query, "project"),
|
|
1285
|
+
self._one(query, "path"),
|
|
1286
|
+
payload.get("content"),
|
|
1287
|
+
payload.get("expectedVersion"),
|
|
1288
|
+
)
|
|
1289
|
+
self._json(HTTPStatus.OK, result)
|
|
1290
|
+
except DashboardError as exc:
|
|
1291
|
+
self._json(exc.status, {"error": exc.message})
|
|
1292
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
1293
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON request"})
|
|
1294
|
+
except OSError as exc:
|
|
1295
|
+
self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)})
|
|
1296
|
+
except Exception:
|
|
1297
|
+
self._json(
|
|
1298
|
+
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal dashboard error"}
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
def do_POST(self) -> None:
|
|
1302
|
+
path, _ = self._query()
|
|
1303
|
+
if not self._security_ok(require_token=path.startswith("/api/")):
|
|
1304
|
+
return
|
|
1305
|
+
if path != "/api/session":
|
|
1306
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "API endpoint not found"})
|
|
1307
|
+
return
|
|
1308
|
+
self._json(
|
|
1309
|
+
HTTPStatus.OK,
|
|
1310
|
+
{"status": "ready", "apiBase": self.server.api_prefix},
|
|
1311
|
+
headers={
|
|
1312
|
+
"Set-Cookie": (
|
|
1313
|
+
f"{self.server.session_cookie}={self.server.access_token}; "
|
|
1314
|
+
f"HttpOnly; SameSite=Strict; Path={self.server.api_prefix}/"
|
|
1315
|
+
)
|
|
1316
|
+
},
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def workspace_fingerprint(workspace: Path) -> str:
|
|
1321
|
+
"""Identify one workspace without exposing its path in a response header."""
|
|
1322
|
+
return hashlib.sha256(
|
|
1323
|
+
str(workspace).encode("utf-8", "surrogateescape")
|
|
1324
|
+
).hexdigest()[:16]
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def session_file_for(workspace: Path, override: Path | None = None) -> Path:
|
|
1328
|
+
if override is not None:
|
|
1329
|
+
return override.expanduser().resolve()
|
|
1330
|
+
return workspace / Path(str(SESSION_RELATIVE))
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def read_session(path: Path) -> dict[str, Any] | None:
|
|
1334
|
+
"""Return the recorded session, or ``None`` when there is nothing usable."""
|
|
1335
|
+
try:
|
|
1336
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
1337
|
+
except (OSError, ValueError):
|
|
1338
|
+
return None
|
|
1339
|
+
if not isinstance(document, dict) or document.get("schema_version") != SESSION_SCHEMA:
|
|
1340
|
+
return None
|
|
1341
|
+
required = ("host", "port", "token", "fingerprint", "url", "pid")
|
|
1342
|
+
if any(key not in document for key in required):
|
|
1343
|
+
return None
|
|
1344
|
+
return document
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def write_session(path: Path, document: dict[str, Any]) -> None:
|
|
1348
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1349
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
1350
|
+
descriptor = os.open(
|
|
1351
|
+
str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
|
|
1352
|
+
)
|
|
1353
|
+
try:
|
|
1354
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
1355
|
+
json.dump(document, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
1356
|
+
handle.write("\n")
|
|
1357
|
+
handle.flush()
|
|
1358
|
+
os.fsync(handle.fileno())
|
|
1359
|
+
os.replace(temporary, path)
|
|
1360
|
+
finally:
|
|
1361
|
+
with contextlib.suppress(OSError):
|
|
1362
|
+
temporary.unlink()
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def session_lock_path(session_path: Path) -> Path:
|
|
1366
|
+
return session_path.with_name(session_path.name + SESSION_LOCK_SUFFIX)
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _try_exclusive_lock(handle: Any) -> bool:
|
|
1370
|
+
"""Take the serving lock without waiting. False means someone else holds it."""
|
|
1371
|
+
if os.name == "nt":
|
|
1372
|
+
import msvcrt
|
|
1373
|
+
|
|
1374
|
+
handle.seek(0, os.SEEK_END)
|
|
1375
|
+
if handle.tell() == 0:
|
|
1376
|
+
handle.write(b"0")
|
|
1377
|
+
handle.flush()
|
|
1378
|
+
handle.seek(0)
|
|
1379
|
+
# Reached through getattr for the same reason project_tool.py does: the
|
|
1380
|
+
# POSIX stubs mypy checks against do not declare these Windows names.
|
|
1381
|
+
locking = getattr(msvcrt, "locking")
|
|
1382
|
+
non_blocking = getattr(msvcrt, "LK_NBLCK")
|
|
1383
|
+
try:
|
|
1384
|
+
locking(handle.fileno(), non_blocking, 1)
|
|
1385
|
+
except OSError:
|
|
1386
|
+
return False
|
|
1387
|
+
return True
|
|
1388
|
+
import fcntl
|
|
1389
|
+
|
|
1390
|
+
try:
|
|
1391
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1392
|
+
except OSError:
|
|
1393
|
+
return False
|
|
1394
|
+
return True
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
class SessionUnavailable(RuntimeError):
|
|
1398
|
+
"""This location cannot hold a session record (read-only workspace, ...).
|
|
1399
|
+
|
|
1400
|
+
Serving does not depend on the record: it exists so --detach can report a
|
|
1401
|
+
URL and --status/--stop can find the server later. A workspace the creator
|
|
1402
|
+
can read but not write must still open, the way it did before sessions
|
|
1403
|
+
existed.
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
@contextlib.contextmanager
|
|
1408
|
+
def hold_session_lock(session_path: Path) -> Iterator[bool]:
|
|
1409
|
+
"""Hold the serving lock, yielding whether WE hold it (vs. another process).
|
|
1410
|
+
|
|
1411
|
+
Liveness is answered by this lock rather than by probing the recorded port.
|
|
1412
|
+
A PID can be reused and a port can be inherited by something unrelated; a
|
|
1413
|
+
lock is released by the kernel exactly when the serving process ends. It
|
|
1414
|
+
also keeps the dashboard free of any outbound network client.
|
|
1415
|
+
"""
|
|
1416
|
+
lock_path = session_lock_path(session_path)
|
|
1417
|
+
try:
|
|
1418
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1419
|
+
handle = lock_path.open("a+b")
|
|
1420
|
+
except OSError as exc:
|
|
1421
|
+
raise SessionUnavailable(
|
|
1422
|
+
f"cannot use a dashboard session file at {lock_path}: {exc}"
|
|
1423
|
+
) from exc
|
|
1424
|
+
if not _try_exclusive_lock(handle):
|
|
1425
|
+
handle.close()
|
|
1426
|
+
yield False
|
|
1427
|
+
return
|
|
1428
|
+
try:
|
|
1429
|
+
yield True
|
|
1430
|
+
finally:
|
|
1431
|
+
handle.close()
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def session_is_live(session_path: Path) -> bool:
|
|
1435
|
+
"""Answer whether a dashboard is still serving this workspace."""
|
|
1436
|
+
try:
|
|
1437
|
+
with hold_session_lock(session_path) as held:
|
|
1438
|
+
return not held
|
|
1439
|
+
except SessionUnavailable:
|
|
1440
|
+
return False
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def session_matches(session: Mapping[str, Any], workspace: Path) -> bool:
|
|
1444
|
+
"""Is this record about the workspace the caller asked for?
|
|
1445
|
+
|
|
1446
|
+
A --session-file shared between two workspaces would otherwise hand the
|
|
1447
|
+
second creator a link that serves the first one's project.
|
|
1448
|
+
"""
|
|
1449
|
+
return session.get("fingerprint") == workspace_fingerprint(workspace)
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def watch_workspace(server: Any, workspace: Path, interval: float = 5.0) -> None:
|
|
1453
|
+
"""Shut the server down if its workspace stops existing.
|
|
1454
|
+
|
|
1455
|
+
A detached dashboard used to die with its shell. Now that it does not, a
|
|
1456
|
+
creator who deletes or moves the project would otherwise leave a server
|
|
1457
|
+
holding a port and answering on the old token URL for a tree that is gone --
|
|
1458
|
+
and its session record went with the directory, so nothing could stop it.
|
|
1459
|
+
"""
|
|
1460
|
+
try:
|
|
1461
|
+
expected = os.stat(workspace)
|
|
1462
|
+
except OSError:
|
|
1463
|
+
return
|
|
1464
|
+
identity = (expected.st_dev, expected.st_ino)
|
|
1465
|
+
misses = 0
|
|
1466
|
+
|
|
1467
|
+
def loop() -> None:
|
|
1468
|
+
nonlocal misses
|
|
1469
|
+
while True:
|
|
1470
|
+
time.sleep(interval)
|
|
1471
|
+
try:
|
|
1472
|
+
current = os.stat(workspace)
|
|
1473
|
+
gone = (current.st_dev, current.st_ino) != identity
|
|
1474
|
+
except OSError:
|
|
1475
|
+
gone = True
|
|
1476
|
+
misses = misses + 1 if gone else 0
|
|
1477
|
+
if misses >= 2: # tolerate one transient stat failure
|
|
1478
|
+
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def stop_session(path: Path) -> bool:
|
|
1485
|
+
"""Stop the recorded dashboard and forget it. Returns whether one was live."""
|
|
1486
|
+
session = read_session(path)
|
|
1487
|
+
live = session is not None and session_is_live(path)
|
|
1488
|
+
if live:
|
|
1489
|
+
pid = session.get("pid") if session else None
|
|
1490
|
+
if isinstance(pid, int) and pid > 0:
|
|
1491
|
+
with contextlib.suppress(OSError):
|
|
1492
|
+
os.kill(pid, signal.SIGTERM)
|
|
1493
|
+
deadline = time.monotonic() + STOP_TIMEOUT_SECONDS
|
|
1494
|
+
while time.monotonic() < deadline and session_is_live(path):
|
|
1495
|
+
time.sleep(0.1)
|
|
1496
|
+
with contextlib.suppress(OSError):
|
|
1497
|
+
path.unlink()
|
|
1498
|
+
return live
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _detached_child(
|
|
1502
|
+
workspace: Path, *, host: str, port: int, session_path: Path
|
|
1503
|
+
) -> subprocess.Popen[bytes]:
|
|
1504
|
+
command = [
|
|
1505
|
+
sys.executable,
|
|
1506
|
+
"-B",
|
|
1507
|
+
str(Path(__file__).resolve()),
|
|
1508
|
+
"--workspace",
|
|
1509
|
+
str(workspace),
|
|
1510
|
+
"--host",
|
|
1511
|
+
host,
|
|
1512
|
+
"--port",
|
|
1513
|
+
str(port),
|
|
1514
|
+
"--session-file",
|
|
1515
|
+
str(session_path),
|
|
1516
|
+
]
|
|
1517
|
+
log_path = session_path.with_name(f"{session_path.stem}.log")
|
|
1518
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1519
|
+
log = open(log_path, "ab")
|
|
1520
|
+
options: dict[str, Any] = {}
|
|
1521
|
+
if os.name == "nt":
|
|
1522
|
+
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit and
|
|
1523
|
+
# no Ctrl-C from the launching shell.
|
|
1524
|
+
options["creationflags"] = 0x00000008 | 0x00000200
|
|
1525
|
+
else:
|
|
1526
|
+
options["start_new_session"] = True
|
|
1527
|
+
try:
|
|
1528
|
+
return subprocess.Popen(
|
|
1529
|
+
command,
|
|
1530
|
+
stdin=subprocess.DEVNULL,
|
|
1531
|
+
stdout=log,
|
|
1532
|
+
stderr=log,
|
|
1533
|
+
close_fds=True,
|
|
1534
|
+
cwd=str(workspace),
|
|
1535
|
+
**options,
|
|
1536
|
+
)
|
|
1537
|
+
finally:
|
|
1538
|
+
log.close()
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def start_detached(
|
|
1542
|
+
workspace: Path, *, host: str, port: int, session_path: Path
|
|
1543
|
+
) -> dict[str, Any]:
|
|
1544
|
+
"""Start a dashboard that outlives the shell that asked for it."""
|
|
1545
|
+
process = _detached_child(workspace, host=host, port=port, session_path=session_path)
|
|
1546
|
+
deadline = time.monotonic() + DETACH_TIMEOUT_SECONDS
|
|
1547
|
+
while time.monotonic() < deadline:
|
|
1548
|
+
session = read_session(session_path)
|
|
1549
|
+
if session is not None and session.get("pid") == process.pid:
|
|
1550
|
+
return session
|
|
1551
|
+
if process.poll() is not None:
|
|
1552
|
+
break
|
|
1553
|
+
time.sleep(0.1)
|
|
1554
|
+
if process.poll() is None:
|
|
1555
|
+
with contextlib.suppress(OSError):
|
|
1556
|
+
process.kill()
|
|
1557
|
+
log_path = session_path.with_name(f"{session_path.stem}.log")
|
|
1558
|
+
raise RuntimeError(f"dashboard did not start; see {log_path}")
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def create_server(
|
|
1562
|
+
workspace: Path,
|
|
1563
|
+
*,
|
|
1564
|
+
host: str = "127.0.0.1",
|
|
1565
|
+
port: int = 8765,
|
|
1566
|
+
suite_root: Path | None = None,
|
|
1567
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
1568
|
+
max_nodes: int = DEFAULT_MAX_NODES,
|
|
1569
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
1570
|
+
max_media_bytes: int = DEFAULT_MAX_MEDIA_BYTES,
|
|
1571
|
+
) -> DashboardHTTPServer:
|
|
1572
|
+
try:
|
|
1573
|
+
address = ipaddress.ip_address(host)
|
|
1574
|
+
except ValueError:
|
|
1575
|
+
address = None
|
|
1576
|
+
if not _is_loopback(host) or (address is not None and address.version != 4):
|
|
1577
|
+
raise ValueError("dashboard may only bind to an IPv4 loopback address")
|
|
1578
|
+
suite = (suite_root or SKILL_ROOT).resolve()
|
|
1579
|
+
store = ProjectStore(
|
|
1580
|
+
workspace,
|
|
1581
|
+
load_project_tool(suite),
|
|
1582
|
+
max_depth=max_depth,
|
|
1583
|
+
max_nodes=max_nodes,
|
|
1584
|
+
max_file_bytes=max_file_bytes,
|
|
1585
|
+
max_media_bytes=max_media_bytes,
|
|
1586
|
+
)
|
|
1587
|
+
return DashboardHTTPServer((host, port), store)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1591
|
+
parser = argparse.ArgumentParser(
|
|
1592
|
+
description="Serve the local short-drama project dashboard"
|
|
1593
|
+
)
|
|
1594
|
+
parser.add_argument(
|
|
1595
|
+
"--workspace", required=True, type=Path, help="explicit workspace root to scan"
|
|
1596
|
+
)
|
|
1597
|
+
parser.add_argument(
|
|
1598
|
+
"--host", default="127.0.0.1", help="loopback address (default: 127.0.0.1)"
|
|
1599
|
+
)
|
|
1600
|
+
parser.add_argument("--port", default=8765, type=int)
|
|
1601
|
+
parser.add_argument(
|
|
1602
|
+
"--open",
|
|
1603
|
+
action="store_true",
|
|
1604
|
+
help="open the dashboard in the default browser after binding",
|
|
1605
|
+
)
|
|
1606
|
+
parser.add_argument(
|
|
1607
|
+
"--detach",
|
|
1608
|
+
action="store_true",
|
|
1609
|
+
help="serve in a background process that outlives this shell",
|
|
1610
|
+
)
|
|
1611
|
+
parser.add_argument(
|
|
1612
|
+
"--restart",
|
|
1613
|
+
action="store_true",
|
|
1614
|
+
help="stop a dashboard already serving this workspace before starting",
|
|
1615
|
+
)
|
|
1616
|
+
parser.add_argument(
|
|
1617
|
+
"--status",
|
|
1618
|
+
action="store_true",
|
|
1619
|
+
help="print the recorded dashboard for this workspace and exit",
|
|
1620
|
+
)
|
|
1621
|
+
parser.add_argument(
|
|
1622
|
+
"--stop",
|
|
1623
|
+
action="store_true",
|
|
1624
|
+
help="stop the dashboard recorded for this workspace and exit",
|
|
1625
|
+
)
|
|
1626
|
+
parser.add_argument(
|
|
1627
|
+
"--session-file",
|
|
1628
|
+
type=Path,
|
|
1629
|
+
help=f"where to record the running dashboard (default: {SESSION_RELATIVE})",
|
|
1630
|
+
)
|
|
1631
|
+
args = parser.parse_args(argv)
|
|
1632
|
+
if args.status and args.stop:
|
|
1633
|
+
parser.error("--status and --stop cannot be combined")
|
|
1634
|
+
|
|
1635
|
+
workspace = args.workspace.expanduser().resolve()
|
|
1636
|
+
session_path = session_file_for(workspace, args.session_file)
|
|
1637
|
+
if not (args.status or args.stop) and not workspace.is_dir():
|
|
1638
|
+
# Creating a mistyped --workspace and serving it as an empty dashboard
|
|
1639
|
+
# leaves a long-lived server for a directory that never existed.
|
|
1640
|
+
print(f"workspace is not an existing directory: {workspace}", file=sys.stderr)
|
|
1641
|
+
return 2
|
|
1642
|
+
|
|
1643
|
+
if args.status:
|
|
1644
|
+
session = read_session(session_path)
|
|
1645
|
+
holding = session is not None and session_is_live(session_path)
|
|
1646
|
+
mine = holding and session is not None and session_matches(session, workspace)
|
|
1647
|
+
status: dict[str, Any] = {
|
|
1648
|
+
"running": bool(mine),
|
|
1649
|
+
"workspace": str(workspace),
|
|
1650
|
+
"session_file": str(session_path),
|
|
1651
|
+
}
|
|
1652
|
+
if holding and session is not None:
|
|
1653
|
+
status["url"] = session["url"]
|
|
1654
|
+
status["pid"] = session["pid"]
|
|
1655
|
+
recorded = session.get("workspace")
|
|
1656
|
+
if not mine:
|
|
1657
|
+
# The record is live but describes a different workspace.
|
|
1658
|
+
status["serves_workspace"] = recorded
|
|
1659
|
+
print(json.dumps(status, ensure_ascii=False, sort_keys=True), flush=True)
|
|
1660
|
+
return 0 if mine else 1
|
|
1661
|
+
|
|
1662
|
+
if args.stop:
|
|
1663
|
+
stopped = stop_session(session_path)
|
|
1664
|
+
print("Dashboard stopped" if stopped else "No dashboard was running", flush=True)
|
|
1665
|
+
return 0
|
|
1666
|
+
|
|
1667
|
+
existing = read_session(session_path)
|
|
1668
|
+
if (
|
|
1669
|
+
existing is not None
|
|
1670
|
+
and session_matches(existing, workspace)
|
|
1671
|
+
and session_is_live(session_path)
|
|
1672
|
+
):
|
|
1673
|
+
if args.restart:
|
|
1674
|
+
stop_session(session_path)
|
|
1675
|
+
else:
|
|
1676
|
+
url = str(existing["url"])
|
|
1677
|
+
print(f"Dashboard: {url}", flush=True)
|
|
1678
|
+
print("Reusing the dashboard already serving this workspace", flush=True)
|
|
1679
|
+
if args.open:
|
|
1680
|
+
webbrowser.open(url)
|
|
1681
|
+
return 0
|
|
1682
|
+
|
|
1683
|
+
if args.detach:
|
|
1684
|
+
try:
|
|
1685
|
+
session = start_detached(
|
|
1686
|
+
workspace, host=args.host, port=args.port, session_path=session_path
|
|
1687
|
+
)
|
|
1688
|
+
except SessionUnavailable as exc:
|
|
1689
|
+
print(f"{exc}", file=sys.stderr)
|
|
1690
|
+
return 2
|
|
1691
|
+
except RuntimeError as exc:
|
|
1692
|
+
# Losing the race to another start lands here: the winner took the
|
|
1693
|
+
# serving lock and this child exited without recording anything.
|
|
1694
|
+
# A dashboard IS serving this workspace, so report its URL.
|
|
1695
|
+
winner = read_session(session_path)
|
|
1696
|
+
live = winner is not None and session_is_live(session_path)
|
|
1697
|
+
if live and winner is not None and session_matches(winner, workspace):
|
|
1698
|
+
url = str(winner["url"])
|
|
1699
|
+
print(f"Dashboard: {url}", flush=True)
|
|
1700
|
+
print("Reusing the dashboard already serving this workspace", flush=True)
|
|
1701
|
+
if args.open:
|
|
1702
|
+
webbrowser.open(url)
|
|
1703
|
+
return 0
|
|
1704
|
+
if live and winner is not None:
|
|
1705
|
+
print(
|
|
1706
|
+
f"{session_path} already records a dashboard serving "
|
|
1707
|
+
f"{winner.get('workspace')}; give this workspace its own "
|
|
1708
|
+
"--session-file",
|
|
1709
|
+
file=sys.stderr,
|
|
1710
|
+
)
|
|
1711
|
+
return 1
|
|
1712
|
+
print(str(exc), file=sys.stderr)
|
|
1713
|
+
return 1
|
|
1714
|
+
url = str(session["url"])
|
|
1715
|
+
print(f"Dashboard: {url}", flush=True)
|
|
1716
|
+
print(f"Serving in the background as pid {session['pid']}", flush=True)
|
|
1717
|
+
if args.open:
|
|
1718
|
+
webbrowser.open(url)
|
|
1719
|
+
return 0
|
|
1720
|
+
|
|
1721
|
+
with contextlib.ExitStack() as stack:
|
|
1722
|
+
try:
|
|
1723
|
+
held = stack.enter_context(hold_session_lock(session_path))
|
|
1724
|
+
recordable = True
|
|
1725
|
+
except SessionUnavailable as exc:
|
|
1726
|
+
# A read-only workspace still opens. The record is what --detach and
|
|
1727
|
+
# --status need; serving never depended on it.
|
|
1728
|
+
print(f"{exc}", file=sys.stderr)
|
|
1729
|
+
print(
|
|
1730
|
+
"serving without a session record; --status and --stop cannot find it",
|
|
1731
|
+
file=sys.stderr,
|
|
1732
|
+
)
|
|
1733
|
+
held, recordable = True, False
|
|
1734
|
+
if not held:
|
|
1735
|
+
reused = read_session(session_path)
|
|
1736
|
+
if reused is not None and session_matches(reused, workspace):
|
|
1737
|
+
print(f"Dashboard: {reused['url']}", flush=True)
|
|
1738
|
+
print("Reusing the dashboard already serving this workspace", flush=True)
|
|
1739
|
+
return 0
|
|
1740
|
+
print(
|
|
1741
|
+
"another dashboard is already starting for this workspace",
|
|
1742
|
+
file=sys.stderr,
|
|
1743
|
+
)
|
|
1744
|
+
return 1
|
|
1745
|
+
server = create_server(workspace, host=args.host, port=args.port)
|
|
1746
|
+
raw_host, port = server.server_address[:2]
|
|
1747
|
+
host = raw_host.decode("ascii") if isinstance(raw_host, bytes) else raw_host
|
|
1748
|
+
display_host = f"[{host}]" if ":" in host else host
|
|
1749
|
+
scheme = "http"
|
|
1750
|
+
url = f"{scheme}://{display_host}:{port}/#{server.access_token}"
|
|
1751
|
+
if recordable:
|
|
1752
|
+
try:
|
|
1753
|
+
write_session(
|
|
1754
|
+
session_path,
|
|
1755
|
+
{
|
|
1756
|
+
"schema_version": SESSION_SCHEMA,
|
|
1757
|
+
"workspace": str(workspace),
|
|
1758
|
+
"fingerprint": server.workspace_fingerprint,
|
|
1759
|
+
"host": host,
|
|
1760
|
+
"port": port,
|
|
1761
|
+
"pid": os.getpid(),
|
|
1762
|
+
"token": server.access_token,
|
|
1763
|
+
"url": url,
|
|
1764
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1765
|
+
},
|
|
1766
|
+
)
|
|
1767
|
+
except OSError as exc:
|
|
1768
|
+
print(f"cannot write the session record: {exc}", file=sys.stderr)
|
|
1769
|
+
recordable = False
|
|
1770
|
+
# A detached server is stopped with SIGTERM. Handle it so the recorded
|
|
1771
|
+
# session is removed instead of being left behind. `shutdown` blocks
|
|
1772
|
+
# until `serve_forever` returns, so it cannot run on the thread that is
|
|
1773
|
+
# inside `serve_forever`.
|
|
1774
|
+
with contextlib.suppress(ValueError, OSError, AttributeError):
|
|
1775
|
+
signal.signal(
|
|
1776
|
+
signal.SIGTERM,
|
|
1777
|
+
lambda *_: threading.Thread(target=server.shutdown, daemon=True).start(),
|
|
1778
|
+
)
|
|
1779
|
+
watch_workspace(server, workspace)
|
|
1780
|
+
print(f"Dashboard: {url}", flush=True)
|
|
1781
|
+
if args.open:
|
|
1782
|
+
webbrowser.open(url)
|
|
1783
|
+
try:
|
|
1784
|
+
server.serve_forever()
|
|
1785
|
+
except KeyboardInterrupt:
|
|
1786
|
+
pass
|
|
1787
|
+
finally:
|
|
1788
|
+
server.server_close()
|
|
1789
|
+
if recordable:
|
|
1790
|
+
current = read_session(session_path)
|
|
1791
|
+
if current is not None and current.get("pid") == os.getpid():
|
|
1792
|
+
with contextlib.suppress(OSError):
|
|
1793
|
+
session_path.unlink()
|
|
1794
|
+
return 0
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
if __name__ == "__main__":
|
|
1798
|
+
raise SystemExit(main())
|