stream_weaver 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
module Components
|
|
5
|
+
module Deck
|
|
6
|
+
# Fixed top bar for cancel confirmation.
|
|
7
|
+
# Slides down from top with confirm/cancel buttons and auto-hide timer.
|
|
8
|
+
#
|
|
9
|
+
# sw- CSS classes:
|
|
10
|
+
# sw-confirmation-bar - fixed top bar wrapper
|
|
11
|
+
# sw-confirmation-bar--visible - visible state (slide-down)
|
|
12
|
+
# sw-confirmation-bar__message - message text
|
|
13
|
+
# sw-confirmation-bar__actions - button container
|
|
14
|
+
# sw-confirmation-bar__btn - action button base
|
|
15
|
+
# sw-confirmation-bar__btn--confirm - confirm (destructive) button
|
|
16
|
+
# sw-confirmation-bar__btn--cancel - cancel (dismiss) button
|
|
17
|
+
# sw-confirmation-bar__timer - auto-hide countdown display
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# confirmation_bar(
|
|
21
|
+
# message: "Are you sure you want to cancel?",
|
|
22
|
+
# confirm_label: "Yes, Cancel",
|
|
23
|
+
# cancel_label: "Keep Going",
|
|
24
|
+
# auto_hide: 5
|
|
25
|
+
# )
|
|
26
|
+
class ConfirmationBar < Base
|
|
27
|
+
attr_reader :message, :confirm_label, :cancel_label, :auto_hide
|
|
28
|
+
|
|
29
|
+
# @param message [String] Confirmation message
|
|
30
|
+
# @param confirm_label [String] Label for confirm action (default: "Cancel")
|
|
31
|
+
# @param cancel_label [String] Label for dismiss action (default: "Keep Going")
|
|
32
|
+
# @param auto_hide [Integer, nil] Auto-hide after N seconds (default: 5, nil to disable)
|
|
33
|
+
# @param options [Hash] Additional options
|
|
34
|
+
def initialize(message:, confirm_label: "Cancel", cancel_label: "Keep Going",
|
|
35
|
+
auto_hide: 5, **options)
|
|
36
|
+
@message = message
|
|
37
|
+
@confirm_label = confirm_label
|
|
38
|
+
@cancel_label = cancel_label
|
|
39
|
+
@auto_hide = auto_hide
|
|
40
|
+
super(**options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render(view, state)
|
|
44
|
+
view.adapter.render_confirmation_bar(view, self, state)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether auto-hide is enabled.
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def auto_hide?
|
|
50
|
+
!@auto_hide.nil? && @auto_hide > 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# CSS class list
|
|
54
|
+
def css_classes
|
|
55
|
+
"sw-confirmation-bar"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
module Components
|
|
5
|
+
module Deck
|
|
6
|
+
# A selectable option card within a DeckSlide.
|
|
7
|
+
# Displays a label, optional preview content (mermaid, code_block, etc.),
|
|
8
|
+
# optional aside text, recommended badge, and a notes textarea.
|
|
9
|
+
#
|
|
10
|
+
# ARIA: role="radio", aria-checked (selection state is T8 concern,
|
|
11
|
+
# but the structural ARIA attributes are set here).
|
|
12
|
+
#
|
|
13
|
+
# sw- CSS classes:
|
|
14
|
+
# sw-deck-option - option card wrapper
|
|
15
|
+
# sw-deck-option--recommended - when recommended: true
|
|
16
|
+
# sw-deck-option__radio - radio indicator circle
|
|
17
|
+
# sw-deck-option__header - label header area
|
|
18
|
+
# sw-deck-option__label - label text
|
|
19
|
+
# sw-deck-option__badge - "Recommended" badge
|
|
20
|
+
# sw-deck-option__preview - preview content area
|
|
21
|
+
# sw-deck-option__aside - aside text below preview
|
|
22
|
+
# sw-deck-option__notes - notes textarea container
|
|
23
|
+
# sw-deck-option__notes-input - the textarea itself
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# option "Monolith", aside: "Simple deployment", recommended: true do
|
|
27
|
+
# mermaid "graph TD; A-->B", compact: true
|
|
28
|
+
# end
|
|
29
|
+
class DeckOption < Base
|
|
30
|
+
attr_reader :label, :aside, :recommended, :description
|
|
31
|
+
attr_accessor :children, :slide_id, :option_index
|
|
32
|
+
|
|
33
|
+
# @param label [String] Option label text
|
|
34
|
+
# @param aside [String, nil] Aside text displayed below the preview
|
|
35
|
+
# @param recommended [Boolean] Whether to show "Recommended" badge
|
|
36
|
+
# @param description [String, nil] Description text (for tooltip/aria)
|
|
37
|
+
# @param options [Hash] Additional options
|
|
38
|
+
def initialize(label, aside: nil, recommended: false, description: nil, **options)
|
|
39
|
+
@label = label
|
|
40
|
+
@aside = aside
|
|
41
|
+
@recommended = recommended
|
|
42
|
+
@description = description
|
|
43
|
+
@children = []
|
|
44
|
+
super(**options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def render(view, state)
|
|
48
|
+
view.adapter.render_deck_option(view, self, state)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# CSS class list (selected state added dynamically by adapter)
|
|
52
|
+
def css_classes(selected: false)
|
|
53
|
+
classes = ["sw-deck-option"]
|
|
54
|
+
classes << "sw-deck-option--recommended" if @recommended
|
|
55
|
+
classes << "sw-deck-option--selected" if selected
|
|
56
|
+
classes.join(" ")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StreamWeaver
|
|
4
|
+
module Components
|
|
5
|
+
module Deck
|
|
6
|
+
# A single decision slide within a DesignDeck.
|
|
7
|
+
# Contains DeckOption children rendered as a grid of option cards.
|
|
8
|
+
#
|
|
9
|
+
# Auto-column detection based on option count:
|
|
10
|
+
# 1 option -> 1 column
|
|
11
|
+
# 2 options -> 2 columns
|
|
12
|
+
# 3 options -> 3 columns
|
|
13
|
+
# 4+ options -> 2 columns
|
|
14
|
+
#
|
|
15
|
+
# sw- CSS classes:
|
|
16
|
+
# sw-deck-slide - slide wrapper
|
|
17
|
+
# sw-deck-slide__title - slide title
|
|
18
|
+
# sw-deck-slide__context - context text above options
|
|
19
|
+
# sw-deck-slide__grid - options grid container
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# slide "arch", "System Architecture", context: "Choose the backend" do
|
|
23
|
+
# option "Monolith" do
|
|
24
|
+
# code_block "...", lang: "ts"
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
class DeckSlide < Base
|
|
28
|
+
attr_reader :id, :title, :context_text, :columns
|
|
29
|
+
attr_accessor :children
|
|
30
|
+
|
|
31
|
+
# @param id [String] Unique slide identifier
|
|
32
|
+
# @param title [String, nil] Slide title
|
|
33
|
+
# @param context [String, nil] Context text displayed above the options
|
|
34
|
+
# @param columns [Integer, nil] Override auto-detected column count
|
|
35
|
+
# @param options [Hash] Additional options
|
|
36
|
+
def initialize(id, title = nil, context: nil, columns: nil, **options)
|
|
37
|
+
@id = id.to_s
|
|
38
|
+
@title = title
|
|
39
|
+
@context_text = context
|
|
40
|
+
@columns = columns
|
|
41
|
+
@children = []
|
|
42
|
+
super(**options)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render(view, state)
|
|
46
|
+
view.adapter.render_deck_slide(view, self, state)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Auto-detect the number of grid columns from option count.
|
|
50
|
+
# Can be overridden with the columns: parameter.
|
|
51
|
+
#
|
|
52
|
+
# @return [Integer] Number of grid columns
|
|
53
|
+
def auto_columns
|
|
54
|
+
return @columns if @columns
|
|
55
|
+
|
|
56
|
+
count = option_count
|
|
57
|
+
case count
|
|
58
|
+
when 0, 1 then 1
|
|
59
|
+
when 2 then 2
|
|
60
|
+
when 3 then 3
|
|
61
|
+
else 2
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Count only DeckOption children (not other components)
|
|
66
|
+
def option_count
|
|
67
|
+
@children.count { |c| c.is_a?(DeckOption) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# CSS class list
|
|
71
|
+
def css_classes
|
|
72
|
+
"sw-deck-slide"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module StreamWeaver
|
|
8
|
+
module Components
|
|
9
|
+
module Deck
|
|
10
|
+
# File-backed state store for design deck selections, notes, and
|
|
11
|
+
# generate-more request queue.
|
|
12
|
+
# Keyed by session ID. Each session gets a JSON file in tmp/deck_state/.
|
|
13
|
+
#
|
|
14
|
+
# Thread-safe via file locking (flock). Multiple Puma threads and
|
|
15
|
+
# background agent threads can safely read/write the same file.
|
|
16
|
+
#
|
|
17
|
+
# State shape:
|
|
18
|
+
# {
|
|
19
|
+
# "selections" => { "slide_id" => "option_label" },
|
|
20
|
+
# "notes" => { "slide_id" => { "option_label" => "text" } },
|
|
21
|
+
# "generate" => { "status" => "idle", ... },
|
|
22
|
+
# "generate_queue" => [ { request data } ],
|
|
23
|
+
# "generated_options" => { "slide_id" => [ { option data } ] }
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# state = DeckState.new("abc123")
|
|
28
|
+
# state.select("arch", "Monolith")
|
|
29
|
+
# state.set_note("arch", "Monolith", "Simple deployment")
|
|
30
|
+
# state.selected?("arch", "Monolith") # => true
|
|
31
|
+
# state.selection("arch") # => "Monolith"
|
|
32
|
+
# state.note("arch", "Monolith") # => "Simple deployment"
|
|
33
|
+
class DeckState
|
|
34
|
+
attr_reader :session_id
|
|
35
|
+
|
|
36
|
+
# Base directory for state files (relative to app working dir)
|
|
37
|
+
DEFAULT_STATE_DIR = "tmp/deck_state"
|
|
38
|
+
|
|
39
|
+
# @param session_id [String] Unique session identifier
|
|
40
|
+
# @param state_dir [String] Override state directory (for testing)
|
|
41
|
+
def initialize(session_id, state_dir: nil)
|
|
42
|
+
@session_id = session_id
|
|
43
|
+
@state_dir = state_dir || DEFAULT_STATE_DIR
|
|
44
|
+
ensure_state_dir!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generate a new unique session ID
|
|
48
|
+
# @return [String] UUID-style session ID
|
|
49
|
+
def self.generate_session_id
|
|
50
|
+
SecureRandom.uuid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get or create a DeckState for the given session.
|
|
54
|
+
# Generates a new session ID if none provided.
|
|
55
|
+
#
|
|
56
|
+
# @param session_id [String, nil] Existing session ID or nil
|
|
57
|
+
# @param state_dir [String, nil] Override state directory
|
|
58
|
+
# @return [DeckState] The state instance
|
|
59
|
+
def self.for_session(session_id = nil, state_dir: nil)
|
|
60
|
+
session_id ||= generate_session_id
|
|
61
|
+
new(session_id, state_dir: state_dir)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Select an option for a slide (radio semantics: one per slide)
|
|
65
|
+
#
|
|
66
|
+
# @param slide_id [String] Slide identifier
|
|
67
|
+
# @param option_label [String] Option label to select
|
|
68
|
+
def select(slide_id, option_label)
|
|
69
|
+
with_lock(:write) do |data|
|
|
70
|
+
data["selections"][slide_id.to_s] = option_label.to_s
|
|
71
|
+
data
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get the selected option label for a slide
|
|
76
|
+
#
|
|
77
|
+
# @param slide_id [String] Slide identifier
|
|
78
|
+
# @return [String, nil] Selected option label or nil
|
|
79
|
+
def selection(slide_id)
|
|
80
|
+
read_data["selections"][slide_id.to_s]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check if a specific option is selected for a slide
|
|
84
|
+
#
|
|
85
|
+
# @param slide_id [String] Slide identifier
|
|
86
|
+
# @param option_label [String] Option label to check
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def selected?(slide_id, option_label)
|
|
89
|
+
selection(slide_id) == option_label.to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get all selections
|
|
93
|
+
# @return [Hash] { slide_id => option_label }
|
|
94
|
+
def selections
|
|
95
|
+
read_data["selections"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Set a note for an option on a slide
|
|
99
|
+
#
|
|
100
|
+
# @param slide_id [String] Slide identifier
|
|
101
|
+
# @param option_label [String] Option label
|
|
102
|
+
# @param text [String] Note text
|
|
103
|
+
def set_note(slide_id, option_label, text)
|
|
104
|
+
with_lock(:write) do |data|
|
|
105
|
+
data["notes"][slide_id.to_s] ||= {}
|
|
106
|
+
data["notes"][slide_id.to_s][option_label.to_s] = text.to_s
|
|
107
|
+
data
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get the note for an option on a slide
|
|
112
|
+
#
|
|
113
|
+
# @param slide_id [String] Slide identifier
|
|
114
|
+
# @param option_label [String] Option label
|
|
115
|
+
# @return [String, nil] Note text or nil
|
|
116
|
+
def note(slide_id, option_label)
|
|
117
|
+
notes = read_data["notes"][slide_id.to_s]
|
|
118
|
+
notes&.dig(option_label.to_s)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get all notes
|
|
122
|
+
# @return [Hash] { slide_id => { option_label => text } }
|
|
123
|
+
def notes
|
|
124
|
+
read_data["notes"]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Mark this deck as submitted
|
|
128
|
+
def submit!
|
|
129
|
+
with_lock(:write) do |data|
|
|
130
|
+
data["submitted"] = true
|
|
131
|
+
data
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if this deck has been submitted
|
|
136
|
+
# @return [Boolean]
|
|
137
|
+
def submitted?
|
|
138
|
+
read_data["submitted"] == true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Set the final notes (overall comments on the summary slide)
|
|
142
|
+
#
|
|
143
|
+
# @param text [String] Final notes text
|
|
144
|
+
def set_final_notes(text)
|
|
145
|
+
with_lock(:write) do |data|
|
|
146
|
+
data["final_notes"] = text.to_s
|
|
147
|
+
data
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get the final notes
|
|
152
|
+
# @return [String, nil]
|
|
153
|
+
def final_notes
|
|
154
|
+
read_data["final_notes"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# =========================================
|
|
158
|
+
# Model Selector State (T14)
|
|
159
|
+
# =========================================
|
|
160
|
+
|
|
161
|
+
# Set the selected AI model for generate-more.
|
|
162
|
+
#
|
|
163
|
+
# @param model_id [String] Model identifier
|
|
164
|
+
def set_model(model_id)
|
|
165
|
+
with_lock(:write) do |data|
|
|
166
|
+
data["selected_model"] = model_id.to_s
|
|
167
|
+
data
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Get the currently selected model ID.
|
|
172
|
+
# @return [String, nil]
|
|
173
|
+
def selected_model
|
|
174
|
+
read_data["selected_model"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# =========================================
|
|
178
|
+
# Generate-More State (T10)
|
|
179
|
+
# =========================================
|
|
180
|
+
|
|
181
|
+
# Start a generate request. Transitions to :generating.
|
|
182
|
+
# Returns the request_id for versioning.
|
|
183
|
+
#
|
|
184
|
+
# @param slide_id [String] Slide to generate options for
|
|
185
|
+
# @param count [Integer] Number of options to generate
|
|
186
|
+
# @param prompt [String, nil] User's custom prompt
|
|
187
|
+
# @return [String] The request_id
|
|
188
|
+
def start_generate(slide_id, count, prompt: nil)
|
|
189
|
+
request_id = SecureRandom.hex(8)
|
|
190
|
+
with_lock(:write) do |data|
|
|
191
|
+
data["generate"] = {
|
|
192
|
+
"status" => "generating",
|
|
193
|
+
"slide_id" => slide_id.to_s,
|
|
194
|
+
"requested_count" => count,
|
|
195
|
+
"received_count" => 0,
|
|
196
|
+
"prompt" => prompt,
|
|
197
|
+
"request_id" => request_id,
|
|
198
|
+
"started_at" => Time.now.to_f
|
|
199
|
+
}
|
|
200
|
+
# Queue the request for agent polling
|
|
201
|
+
data["generate_queue"] ||= []
|
|
202
|
+
data["generate_queue"] << {
|
|
203
|
+
"session_id" => @session_id,
|
|
204
|
+
"slide_id" => slide_id.to_s,
|
|
205
|
+
"count" => count,
|
|
206
|
+
"prompt" => prompt,
|
|
207
|
+
"request_id" => request_id,
|
|
208
|
+
"timestamp" => Time.now.to_f
|
|
209
|
+
}
|
|
210
|
+
data
|
|
211
|
+
end
|
|
212
|
+
request_id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Cancel a pending generation. Transitions to :idle.
|
|
216
|
+
# Sets a cancellation flag so agents can detect it.
|
|
217
|
+
def cancel_generate
|
|
218
|
+
with_lock(:write) do |data|
|
|
219
|
+
gen = data["generate"] || {}
|
|
220
|
+
if gen["status"] == "generating"
|
|
221
|
+
gen["status"] = "cancelled"
|
|
222
|
+
data["generate"] = gen
|
|
223
|
+
# Remove from queue
|
|
224
|
+
data["generate_queue"] = []
|
|
225
|
+
end
|
|
226
|
+
data
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Transition a timed-out or cancelled generate to idle.
|
|
231
|
+
def reset_generate
|
|
232
|
+
with_lock(:write) do |data|
|
|
233
|
+
gen = data["generate"] || {}
|
|
234
|
+
gen["status"] = "idle"
|
|
235
|
+
data["generate"] = gen
|
|
236
|
+
data
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Mark generation as timed out.
|
|
241
|
+
def timeout_generate
|
|
242
|
+
with_lock(:write) do |data|
|
|
243
|
+
gen = data["generate"] || {}
|
|
244
|
+
if gen["status"] == "generating"
|
|
245
|
+
gen["status"] = "timed_out"
|
|
246
|
+
data["generate"] = gen
|
|
247
|
+
end
|
|
248
|
+
data
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Get the current generate state hash.
|
|
253
|
+
# @return [Hash] Generate state or empty idle state
|
|
254
|
+
def generate_state
|
|
255
|
+
data = read_data
|
|
256
|
+
data["generate"] || { "status" => "idle" }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Check if currently generating.
|
|
260
|
+
# @return [Boolean]
|
|
261
|
+
def generating?
|
|
262
|
+
generate_state["status"] == "generating"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Get the generate status.
|
|
266
|
+
# @return [Symbol] :idle, :generating, :timed_out, :cancelled
|
|
267
|
+
def generate_status
|
|
268
|
+
(generate_state["status"] || "idle").to_sym
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Take pending requests from the queue (for agent polling).
|
|
272
|
+
# Returns pending requests and clears the queue atomically.
|
|
273
|
+
#
|
|
274
|
+
# @return [Array<Hash>] Pending requests
|
|
275
|
+
def take_pending_requests!
|
|
276
|
+
requests = []
|
|
277
|
+
with_lock(:write) do |data|
|
|
278
|
+
requests = data["generate_queue"] || []
|
|
279
|
+
data["generate_queue"] = []
|
|
280
|
+
data
|
|
281
|
+
end
|
|
282
|
+
requests
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Add a generated option to the state (called by agent).
|
|
286
|
+
# Increments received_count and transitions to :idle when all received.
|
|
287
|
+
#
|
|
288
|
+
# @param slide_id [String] Slide the option belongs to
|
|
289
|
+
# @param option_data [Hash] Option data (label, description, etc.)
|
|
290
|
+
# @param request_id [String] Request ID for versioning
|
|
291
|
+
# @return [Hash] Updated generate state
|
|
292
|
+
def add_generated_option(slide_id, option_data, request_id:)
|
|
293
|
+
result_gen = nil
|
|
294
|
+
with_lock(:write) do |data|
|
|
295
|
+
gen = data["generate"] || {}
|
|
296
|
+
|
|
297
|
+
# Request versioning: ignore stale pushes
|
|
298
|
+
if gen["request_id"] != request_id
|
|
299
|
+
result_gen = gen
|
|
300
|
+
next data
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Ignore if cancelled or timed out
|
|
304
|
+
unless gen["status"] == "generating"
|
|
305
|
+
result_gen = gen
|
|
306
|
+
next data
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Add option to generated_options store
|
|
310
|
+
data["generated_options"] ||= {}
|
|
311
|
+
data["generated_options"][slide_id.to_s] ||= []
|
|
312
|
+
data["generated_options"][slide_id.to_s] << option_data.merge("generated" => true)
|
|
313
|
+
|
|
314
|
+
# Update received count
|
|
315
|
+
gen["received_count"] = (gen["received_count"] || 0) + 1
|
|
316
|
+
|
|
317
|
+
# If all received, transition to idle
|
|
318
|
+
if gen["received_count"] >= gen["requested_count"]
|
|
319
|
+
gen["status"] = "idle"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
data["generate"] = gen
|
|
323
|
+
result_gen = gen.dup
|
|
324
|
+
data
|
|
325
|
+
end
|
|
326
|
+
result_gen || { "status" => "idle" }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Get generated options for a slide.
|
|
330
|
+
# @param slide_id [String] Slide identifier
|
|
331
|
+
# @return [Array<Hash>] Generated options
|
|
332
|
+
def generated_options(slide_id)
|
|
333
|
+
data = read_data
|
|
334
|
+
(data["generated_options"] || {})[slide_id.to_s] || []
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Get all generated options across all slides.
|
|
338
|
+
# @return [Hash] { slide_id => [options] }
|
|
339
|
+
def all_generated_options
|
|
340
|
+
read_data["generated_options"] || {}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Check if a generate request has been cancelled.
|
|
344
|
+
# @return [Boolean]
|
|
345
|
+
def generate_cancelled?
|
|
346
|
+
generate_state["status"] == "cancelled"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Read all state data
|
|
350
|
+
# @return [Hash] Full state hash
|
|
351
|
+
def to_h
|
|
352
|
+
read_data
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Clear all state for this session
|
|
356
|
+
def clear!
|
|
357
|
+
with_lock(:write) do |_data|
|
|
358
|
+
empty_state
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Delete the state file entirely
|
|
363
|
+
def delete!
|
|
364
|
+
File.delete(state_file_path) if File.exist?(state_file_path)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Path to the JSON state file
|
|
368
|
+
# @return [String]
|
|
369
|
+
def state_file_path
|
|
370
|
+
File.join(@state_dir, "#{@session_id}.json")
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Clean up stale state files older than the given age
|
|
374
|
+
#
|
|
375
|
+
# @param max_age_seconds [Integer] Maximum age in seconds (default: 24 hours)
|
|
376
|
+
# @param state_dir [String] Override state directory
|
|
377
|
+
# @return [Integer] Number of files cleaned up
|
|
378
|
+
def self.cleanup_stale(max_age_seconds: 86400, state_dir: DEFAULT_STATE_DIR)
|
|
379
|
+
return 0 unless Dir.exist?(state_dir)
|
|
380
|
+
|
|
381
|
+
count = 0
|
|
382
|
+
cutoff = Time.now - max_age_seconds
|
|
383
|
+
Dir.glob(File.join(state_dir, "*.json")).each do |file|
|
|
384
|
+
if File.mtime(file) < cutoff
|
|
385
|
+
File.delete(file) rescue nil
|
|
386
|
+
count += 1
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
count
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def empty_state
|
|
395
|
+
{
|
|
396
|
+
"selections" => {},
|
|
397
|
+
"notes" => {},
|
|
398
|
+
"submitted" => false,
|
|
399
|
+
"final_notes" => "",
|
|
400
|
+
"generate" => { "status" => "idle" },
|
|
401
|
+
"generate_queue" => [],
|
|
402
|
+
"generated_options" => {}
|
|
403
|
+
}
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def ensure_state_dir!
|
|
407
|
+
FileUtils.mkdir_p(@state_dir) unless Dir.exist?(@state_dir)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Read state from file. Returns empty state if file doesn't exist.
|
|
411
|
+
def read_data
|
|
412
|
+
with_lock(:read) do |data|
|
|
413
|
+
data
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Execute a block with file locking.
|
|
418
|
+
# For :write mode, the block receives current data and must return updated data.
|
|
419
|
+
# For :read mode, the block receives current data and returns it.
|
|
420
|
+
def with_lock(mode)
|
|
421
|
+
path = state_file_path
|
|
422
|
+
lock_mode = (mode == :write) ? File::LOCK_EX : File::LOCK_SH
|
|
423
|
+
|
|
424
|
+
# Create file if it doesn't exist
|
|
425
|
+
unless File.exist?(path)
|
|
426
|
+
if mode == :write
|
|
427
|
+
File.open(path, File::CREAT | File::WRONLY | File::EXCL) do |f|
|
|
428
|
+
f.flock(File::LOCK_EX)
|
|
429
|
+
f.write(JSON.generate(empty_state))
|
|
430
|
+
end rescue Errno::EEXIST
|
|
431
|
+
# If EEXIST, another thread created it -- that's fine
|
|
432
|
+
else
|
|
433
|
+
return yield(empty_state)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
File.open(path, "r+") do |f|
|
|
438
|
+
f.flock(lock_mode)
|
|
439
|
+
content = f.read
|
|
440
|
+
data = content.empty? ? empty_state : JSON.parse(content)
|
|
441
|
+
|
|
442
|
+
result = yield(data)
|
|
443
|
+
|
|
444
|
+
if mode == :write
|
|
445
|
+
f.rewind
|
|
446
|
+
f.truncate(0)
|
|
447
|
+
f.write(JSON.generate(result))
|
|
448
|
+
f.flush
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
result
|
|
452
|
+
end
|
|
453
|
+
rescue JSON::ParserError
|
|
454
|
+
# Corrupted file -- reset to empty
|
|
455
|
+
if mode == :write
|
|
456
|
+
File.open(path, "w") do |f|
|
|
457
|
+
f.flock(File::LOCK_EX)
|
|
458
|
+
result = yield(empty_state)
|
|
459
|
+
f.write(JSON.generate(result))
|
|
460
|
+
result
|
|
461
|
+
end
|
|
462
|
+
else
|
|
463
|
+
yield(empty_state)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|