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,1471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sinatra/base'
|
|
4
|
+
require 'sinatra/streaming'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require 'digest'
|
|
10
|
+
require 'uri'
|
|
11
|
+
require_relative 'session_store'
|
|
12
|
+
require_relative 'dev_fallback_overlay'
|
|
13
|
+
|
|
14
|
+
module StreamWeaver
|
|
15
|
+
# Generated Sinatra application for serving the StreamWeaver app
|
|
16
|
+
class SinatraApp < Sinatra::Base
|
|
17
|
+
# ── Session store configuration ──────────────────────────────────────────
|
|
18
|
+
# SW_SESSION_STORE=file (default) — one file per session, no 4KB limit
|
|
19
|
+
# SW_SESSION_STORE=cookie — cookie-based, 4KB limit, warns when near limit
|
|
20
|
+
SW_SESSION_STORE_NAME = (ENV['SW_SESSION_STORE'] || 'file').freeze
|
|
21
|
+
SW_SESSION_FILTER = StreamWeaver::SessionStore.build(SW_SESSION_STORE_NAME)
|
|
22
|
+
|
|
23
|
+
SW_SESSION_SECRET = ENV.fetch('SESSION_SECRET') {
|
|
24
|
+
if ENV['RACK_ENV'] == 'production'
|
|
25
|
+
raise "SESSION_SECRET environment variable required in production"
|
|
26
|
+
else
|
|
27
|
+
'stream-weaver-development-secret-key-change-in-production-environments-minimum-64-characters'
|
|
28
|
+
end
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case SW_SESSION_STORE_NAME
|
|
32
|
+
when 'file'
|
|
33
|
+
SW_SESSION_DIR = ENV.fetch('SW_SESSION_DIR') {
|
|
34
|
+
::File.join(Dir.home, '.config', 'stream_weaver', 'sessions')
|
|
35
|
+
}
|
|
36
|
+
FileUtils.mkdir_p(SW_SESSION_DIR)
|
|
37
|
+
# Clean up session files older than 7 days on startup
|
|
38
|
+
cutoff = Time.now - (7 * 86_400)
|
|
39
|
+
Dir.glob("#{SW_SESSION_DIR}/session_*").each { |f| ::File.delete(f) if ::File.mtime(f) < cutoff rescue nil }
|
|
40
|
+
use StreamWeaver::FileSession, path: SW_SESSION_DIR, expire_after: 86_400, same_site: :lax
|
|
41
|
+
$stderr.puts "[SW] Sessions: file (#{SW_SESSION_DIR})"
|
|
42
|
+
else
|
|
43
|
+
enable :sessions
|
|
44
|
+
set :session_secret, SW_SESSION_SECRET
|
|
45
|
+
set :sessions, same_site: :lax
|
|
46
|
+
$stderr.puts "[SW] Sessions: cookie (4KB limit — set SW_SESSION_STORE=file to remove limit)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Disable protection in test mode to allow Rack::Test requests
|
|
50
|
+
set :protection, false if ENV['RACK_ENV'] == 'test'
|
|
51
|
+
|
|
52
|
+
# Disable Sinatra's startup messages for cleaner output
|
|
53
|
+
set :logging, false
|
|
54
|
+
set :show_exceptions, :after_handler
|
|
55
|
+
set :dump_errors, true
|
|
56
|
+
set :raise_errors, false
|
|
57
|
+
# Allow any Host header — StreamWeaver is a local dev tool, and reverse
|
|
58
|
+
# proxies like puma-dev send .test domains to loopback
|
|
59
|
+
set :host_authorization, { permitted_hosts: [] }
|
|
60
|
+
|
|
61
|
+
# Debug middleware - logs before Sinatra/session processing
|
|
62
|
+
if ENV['SW_DEBUG']
|
|
63
|
+
use(Class.new {
|
|
64
|
+
def initialize(app) @app = app end
|
|
65
|
+
def call(env)
|
|
66
|
+
$stderr.puts "[SW:rack] >>> #{env['REQUEST_METHOD']} #{env['PATH_INFO']} (thread: #{Thread.current.object_id})"
|
|
67
|
+
$stderr.puts "[SW:rack] cookie size: #{env['HTTP_COOKIE']&.bytesize || 0}B"
|
|
68
|
+
$stderr.flush
|
|
69
|
+
status, headers, body = @app.call(env)
|
|
70
|
+
$stderr.puts "[SW:rack] <<< #{env['REQUEST_METHOD']} #{env['PATH_INFO']} -> #{status}"
|
|
71
|
+
$stderr.flush
|
|
72
|
+
[status, headers, body]
|
|
73
|
+
end
|
|
74
|
+
})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Create a Sinatra app from a StreamWeaver::App instance
|
|
79
|
+
#
|
|
80
|
+
# @param streamlit_app [StreamWeaver::App] The app definition
|
|
81
|
+
# @return [Class] Sinatra::Base subclass
|
|
82
|
+
def self.create(streamlit_app)
|
|
83
|
+
helpers Sinatra::Streaming
|
|
84
|
+
|
|
85
|
+
# Store the streamlit app for access in routes
|
|
86
|
+
set :streamlit_app, streamlit_app
|
|
87
|
+
|
|
88
|
+
# Create adapter instance (Alpine.js by default)
|
|
89
|
+
set :adapter, Adapter::AlpineJS.new
|
|
90
|
+
|
|
91
|
+
# Initialize streamer for SSE push (always available for POST /stream/push)
|
|
92
|
+
set :streamer, Streamer.new
|
|
93
|
+
|
|
94
|
+
# Helper methods for state synchronization
|
|
95
|
+
helpers do
|
|
96
|
+
# dev-loud-failure-overlay: follows this file's existing RACK_ENV
|
|
97
|
+
# convention (line ~22's `== 'production'`, line ~48's `== 'test'`)
|
|
98
|
+
# rather than Sinatra's settings.environment/development? -- that
|
|
99
|
+
# settings value is fixed once at class-load time (spec_helper sets
|
|
100
|
+
# RACK_ENV=test before requiring stream_weaver), so it can't be
|
|
101
|
+
# toggled per-request/per-example the way a plain ENV read can.
|
|
102
|
+
# Anything that isn't explicitly production or test counts as dev.
|
|
103
|
+
def sw_dev_mode?
|
|
104
|
+
!%w[production test].include?(ENV['RACK_ENV'])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Coerce a form parameter value to the appropriate Ruby type
|
|
108
|
+
def coerce_param_value(value, current_value)
|
|
109
|
+
case
|
|
110
|
+
when value.is_a?(Array) then value
|
|
111
|
+
when value == "on" || value == "true" then true
|
|
112
|
+
when value == "false" then false
|
|
113
|
+
when current_value.is_a?(Array) then Array(value)
|
|
114
|
+
else value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Routing is just state, and the URL — not the session — is what says
|
|
119
|
+
# which state. GET has always known that; POST never did. Session
|
|
120
|
+
# state is one hash per browser (one cookie), so a route flag set by
|
|
121
|
+
# a tab on a dedicated view (e.g. /now) is read by EVERY sibling tab's
|
|
122
|
+
# next click, and `path_for_state` pushes them all there.
|
|
123
|
+
#
|
|
124
|
+
# `seed_route_state!` is the one merge-not-replace idea ("routing is
|
|
125
|
+
# just state") used by both GET routes and every state-mutating POST
|
|
126
|
+
# — a single core so the three call sites can't drift. htmx sends the
|
|
127
|
+
# requesting tab's own on-screen URL (HX-Current-URL) for free, so
|
|
128
|
+
# `reseed_route_state!` re-seeds from it exactly the way a GET seeds
|
|
129
|
+
# from its own path: same merge, same precedence, no new plumbing.
|
|
130
|
+
#
|
|
131
|
+
# Consequences worth knowing: every key a parser returns is now
|
|
132
|
+
# re-asserted on every htmx POST, not just at navigation time — put
|
|
133
|
+
# only URL-derived truth in a parser hash, never something a POST
|
|
134
|
+
# should be free to change without a URL change. And a second POST
|
|
135
|
+
# fired before the browser applies a prior HX-Push-Url reports the
|
|
136
|
+
# OLD url and reverts that navigation — a narrow window, self-healing
|
|
137
|
+
# on the tab's next interaction, and strictly better than the bug
|
|
138
|
+
# being fixed, but a new failure mode worth naming rather than
|
|
139
|
+
# leaving for the next reader to discover. A raw (non-htmx) POST
|
|
140
|
+
# sends no HX-Current-URL and is left exactly as before (multi-app
|
|
141
|
+
# `service.rb` carries the identical fix — see that file's own
|
|
142
|
+
# `seed_route_state!`/`reseed_route_state!`, mount-prefix aware).
|
|
143
|
+
def seed_route_state!(state, path)
|
|
144
|
+
return if path.to_s.empty?
|
|
145
|
+
return unless settings.streamlit_app.routable?
|
|
146
|
+
|
|
147
|
+
settings.streamlit_app.state_for_path(path)&.each { |k, v| state[k] = v }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reseed_route_state!(state)
|
|
151
|
+
current_url = request.env['HTTP_HX_CURRENT_URL']
|
|
152
|
+
return unless current_url
|
|
153
|
+
|
|
154
|
+
path = begin
|
|
155
|
+
URI.parse(current_url).path
|
|
156
|
+
rescue URI::Error
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
seed_route_state!(state, path)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Sync form params to state hash
|
|
163
|
+
def sync_params_to_state(state, excluded_keys: [])
|
|
164
|
+
excluded = App::ROUTE_OWNED_PARAMS + excluded_keys.map(&:to_s)
|
|
165
|
+
|
|
166
|
+
params.each do |key, value|
|
|
167
|
+
next if excluded.include?(key)
|
|
168
|
+
state[key.to_sym] = coerce_param_value(value, state[key.to_sym])
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Filter state before saving to session.
|
|
173
|
+
# Cookie store enforces 4KB limit and warns; file store passes through unchanged.
|
|
174
|
+
def session_safe_state(state)
|
|
175
|
+
app_transient = settings.streamlit_app.transient_keys
|
|
176
|
+
scope_names = settings.streamlit_app.scope_names
|
|
177
|
+
SW_SESSION_FILTER.filter(state, app_transient: app_transient, scope_names: scope_names)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Set unchecked checkboxes to false (they don't send params)
|
|
181
|
+
def handle_unchecked_checkboxes(state, components)
|
|
182
|
+
self.class.collect_input_keys(components).each do |key|
|
|
183
|
+
component = self.class.find_component_by_key(components, key)
|
|
184
|
+
if component.is_a?(Components::Checkbox) && !params.key?(key.to_s)
|
|
185
|
+
state[key] = false
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Inject deck state into the render state so adapter can read it.
|
|
191
|
+
# Called before rebuild_with_state for routes that render content.
|
|
192
|
+
def inject_deck_state!(state)
|
|
193
|
+
if session[:deck_session_id]
|
|
194
|
+
state[:_deck_state] = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A full GET's URL -- never the session -- decides a `url: true` tabs
|
|
199
|
+
# group's index (see App#tab_index_source). Renders answering a POST leave
|
|
200
|
+
# those groups on the state they were given.
|
|
201
|
+
def render_app(state, is_htmx: false)
|
|
202
|
+
adapter = settings.adapter
|
|
203
|
+
streamlit_app = settings.streamlit_app
|
|
204
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
205
|
+
session_theme = session[:theme_override]
|
|
206
|
+
streamlit_app.with_render_lock do
|
|
207
|
+
inject_deck_state!(state)
|
|
208
|
+
generation = session[:sw_action_generation] ||= SecureRandom.hex(12)
|
|
209
|
+
streamlit_app.rebuild_with_state(state, generation: generation,
|
|
210
|
+
state_version: (session[:sw_state_version] || 0),
|
|
211
|
+
url_params: (params if request.get?))
|
|
212
|
+
session[:sw_action_manifest] = streamlit_app.render_state.action_tokens.to_a
|
|
213
|
+
|
|
214
|
+
# Scrub transient keys that leaked into the session
|
|
215
|
+
unless streamlit_app.transient_keys.empty?
|
|
216
|
+
state.reject! { |k, _| streamlit_app.transient_keys.include?(k) }
|
|
217
|
+
end
|
|
218
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
219
|
+
|
|
220
|
+
if is_htmx
|
|
221
|
+
Views::AppContentView.new(streamlit_app, state, adapter, is_agentic).call
|
|
222
|
+
else
|
|
223
|
+
Views::AppView.new(streamlit_app, state, adapter, is_agentic, session_theme: session_theme).call
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Render error page for debugging
|
|
229
|
+
def render_error(route_name, error)
|
|
230
|
+
File.open("/tmp/streamweaver_error.log", "a") do |f|
|
|
231
|
+
f.puts "#{Time.now} POST #{route_name} error: #{error.class}: #{error.message}"
|
|
232
|
+
f.puts error.backtrace.first(10).join("\n")
|
|
233
|
+
f.puts "---"
|
|
234
|
+
end
|
|
235
|
+
status 500
|
|
236
|
+
content_type 'text/html'
|
|
237
|
+
<<~HTML
|
|
238
|
+
<div style="color: red; padding: 1rem; border: 1px solid red; margin: 1rem; font-family: monospace;">
|
|
239
|
+
<h3>Error in #{route_name}</h3>
|
|
240
|
+
<p><strong>#{error.class}:</strong> #{Rack::Utils.escape_html(error.message)}</p>
|
|
241
|
+
<pre style="background: #f5f5f5; padding: 0.5rem; overflow-x: auto;">#{Rack::Utils.escape_html(error.backtrace.first(15).join("\n"))}</pre>
|
|
242
|
+
</div>
|
|
243
|
+
HTML
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Request logging when DEBUG is set
|
|
248
|
+
before do
|
|
249
|
+
if ENV['SW_DEBUG']
|
|
250
|
+
state = session[:streamlit_state]
|
|
251
|
+
state_size = state ? state.to_s.bytesize : 0
|
|
252
|
+
cookie_size = request.env['HTTP_COOKIE']&.bytesize || 0
|
|
253
|
+
$stderr.puts "[SW] #{request.request_method} #{request.path_info} | cookie=#{cookie_size}B state=#{state_size}B keys=#{state&.keys&.join(',')}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
after do
|
|
258
|
+
if ENV['SW_DEBUG']
|
|
259
|
+
state = session[:streamlit_state]
|
|
260
|
+
state_size = state ? state.to_s.bytesize : 0
|
|
261
|
+
$stderr.puts "[SW] #{request.request_method} #{request.path_info} -> #{response.status} | state_after=#{state_size}B"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Multi-tab route_with fix (2026-08-31): a `before` filter, not a call
|
|
266
|
+
# at each POST route, so route #5 can't be added without it — see
|
|
267
|
+
# `reseed_route_state!` above for the full rationale.
|
|
268
|
+
before do
|
|
269
|
+
reseed_route_state!(session[:streamlit_state] ||= {}) if request.post?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Suppress browser's automatic /favicon.ico request (the real favicon
|
|
273
|
+
# is served via <link rel="icon"> in the HTML head)
|
|
274
|
+
get '/favicon.ico' do
|
|
275
|
+
status 204
|
|
276
|
+
""
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Serve local files registered via css_path/js_path class macros, or via
|
|
280
|
+
# App#local_asset / auto-detected stylesheets: entries (stream_weaver-1lo).
|
|
281
|
+
# Only files whose absolute path was explicitly registered are served
|
|
282
|
+
# (no traversal -- the key is a hash of that path, never derived from
|
|
283
|
+
# the request), and registration itself already validated the path was
|
|
284
|
+
# under the app's script dir / assets_dirs: (see App#local_asset).
|
|
285
|
+
get '/sw-asset/:key/:filename' do
|
|
286
|
+
abs_path = StreamWeaver::ComponentAssets.resolve_file(params[:key])
|
|
287
|
+
halt 404 unless abs_path && File.exist?(abs_path)
|
|
288
|
+
halt 404 unless File.basename(abs_path) == params[:filename]
|
|
289
|
+
etag Digest::MD5.hexdigest("#{File.mtime(abs_path).to_i}-#{File.size(abs_path)}")
|
|
290
|
+
content_type StreamWeaver::App::LOCAL_ASSET_MIME_TYPES[File.extname(abs_path).delete('.').downcase] || "application/octet-stream"
|
|
291
|
+
File.binread(abs_path)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Define routes
|
|
295
|
+
get '/' do
|
|
296
|
+
# For agentic mode, always start with fresh state
|
|
297
|
+
# For regular mode, preserve state across requests
|
|
298
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
299
|
+
if is_agentic
|
|
300
|
+
# Completely clear the session to avoid any stale data
|
|
301
|
+
session.clear
|
|
302
|
+
state = {}
|
|
303
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
304
|
+
elsif self.class.reset_state_pending?
|
|
305
|
+
session.clear
|
|
306
|
+
state = {}
|
|
307
|
+
session[:streamlit_state] = state
|
|
308
|
+
else
|
|
309
|
+
state = session[:streamlit_state] ||= {}
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Seed state from URL routing (e.g., `page :home, '/'` registers a parser for '/')
|
|
313
|
+
seed_route_state!(state, '/')
|
|
314
|
+
|
|
315
|
+
sync_params_to_state(state)
|
|
316
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
317
|
+
|
|
318
|
+
# Prevent browser caching for all forms to ensure fresh rendering
|
|
319
|
+
cache_control :no_cache, :no_store, :must_revalidate, max_age: 0
|
|
320
|
+
headers 'Pragma' => 'no-cache'
|
|
321
|
+
|
|
322
|
+
render_app(state)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Update state from form inputs
|
|
326
|
+
post '/update' do
|
|
327
|
+
begin
|
|
328
|
+
state = session[:streamlit_state] ||= {}
|
|
329
|
+
InteractionRunner.new(
|
|
330
|
+
app: settings.streamlit_app, state: state, params: params,
|
|
331
|
+
interaction: :update, adapter: settings.adapter,
|
|
332
|
+
agentic: settings.respond_to?(:result_container),
|
|
333
|
+
prepare_state: method(:inject_deck_state!),
|
|
334
|
+
persist: ->(value) { session[:streamlit_state] = session_safe_state(value) },
|
|
335
|
+
response_headers: ->(values) { headers values },
|
|
336
|
+
state_version: (session[:sw_state_version] || 0),
|
|
337
|
+
persist_state_version: ->(version) { session[:sw_state_version] = version }
|
|
338
|
+
).call
|
|
339
|
+
rescue => e
|
|
340
|
+
render_error("/update", e)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Button actions
|
|
345
|
+
post '/action/:button_id' do
|
|
346
|
+
begin
|
|
347
|
+
state = session[:streamlit_state] ||= {}
|
|
348
|
+
InteractionRunner.new(
|
|
349
|
+
app: settings.streamlit_app, state: state, params: params,
|
|
350
|
+
interaction: :action, target: params[:button_id], adapter: settings.adapter,
|
|
351
|
+
agentic: settings.respond_to?(:result_container),
|
|
352
|
+
prepare_state: method(:inject_deck_state!),
|
|
353
|
+
persist: ->(value) { session[:streamlit_state] = session_safe_state(value) },
|
|
354
|
+
action_manifest: Set.new(session[:sw_action_manifest] || []),
|
|
355
|
+
generation: (session[:sw_action_generation] ||= SecureRandom.hex(12)),
|
|
356
|
+
persist_manifest: ->(tokens) { session[:sw_action_manifest] = tokens.to_a },
|
|
357
|
+
result_container: (settings.result_container if settings.respond_to?(:result_container)),
|
|
358
|
+
auto_close: settings.respond_to?(:auto_close_window) && settings.auto_close_window,
|
|
359
|
+
response_headers: ->(values) { headers values },
|
|
360
|
+
state_version: (session[:sw_state_version] || 0),
|
|
361
|
+
persist_state_version: ->(version) { session[:sw_state_version] = version }
|
|
362
|
+
).call
|
|
363
|
+
rescue StaleActionDefinition => e
|
|
364
|
+
status 409
|
|
365
|
+
headers 'HX-Retarget' => '#app-container'
|
|
366
|
+
content = render_app(state, is_htmx: true)
|
|
367
|
+
# dev-loud-failure-overlay: production keeps the silent self-heal
|
|
368
|
+
# above completely unchanged; dev additionally prepends a visible,
|
|
369
|
+
# dismissible overlay naming the stale target + likely cause
|
|
370
|
+
# (StreamWeaver's analog of Hotwire's "content missing"). See
|
|
371
|
+
# docs/for_llms.md, "Dev loud, prod self-heal".
|
|
372
|
+
if sw_dev_mode?
|
|
373
|
+
StreamWeaver::DevFallbackOverlay.render(action: e.action, fragment: e.fragment, cause: e.cause) + content
|
|
374
|
+
else
|
|
375
|
+
content
|
|
376
|
+
end
|
|
377
|
+
rescue => e
|
|
378
|
+
render_error("/action/#{params[:button_id]}", e)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Submit endpoint for agentic mode
|
|
383
|
+
post '/submit' do
|
|
384
|
+
state = session[:streamlit_state] ||= {}
|
|
385
|
+
streamlit_app = settings.streamlit_app
|
|
386
|
+
|
|
387
|
+
input_keys = streamlit_app.with_render_lock do
|
|
388
|
+
streamlit_app.rebuild_with_state(state)
|
|
389
|
+
sync_params_to_state(state)
|
|
390
|
+
handle_unchecked_checkboxes(state, streamlit_app.components)
|
|
391
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
392
|
+
|
|
393
|
+
# Collect input keys for filtering the result
|
|
394
|
+
self.class.collect_input_keys(streamlit_app.components)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Filter result to only include keys from input components
|
|
398
|
+
filtered_result = {}
|
|
399
|
+
input_keys.each do |key|
|
|
400
|
+
filtered_result[key] = state[key] if state.key?(key)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Signal that result is ready (for run_once!)
|
|
404
|
+
if settings.respond_to?(:result_container)
|
|
405
|
+
settings.result_container[:result] = filtered_result
|
|
406
|
+
settings.result_container[:ready] = true
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Return confirmation page or auto-close
|
|
410
|
+
auto_close = settings.respond_to?(:auto_close_window) && settings.auto_close_window
|
|
411
|
+
if auto_close
|
|
412
|
+
# Auto-close window with JavaScript
|
|
413
|
+
<<~HTML
|
|
414
|
+
<html>
|
|
415
|
+
<head>
|
|
416
|
+
<title>Submitted</title>
|
|
417
|
+
</head>
|
|
418
|
+
<body>
|
|
419
|
+
<h1>✅ Submitted!</h1>
|
|
420
|
+
<p>Data has been sent to the agent. This window will close automatically...</p>
|
|
421
|
+
<script>
|
|
422
|
+
// Close window after brief delay to allow user to see confirmation
|
|
423
|
+
setTimeout(function() {
|
|
424
|
+
window.close();
|
|
425
|
+
}, 1000);
|
|
426
|
+
</script>
|
|
427
|
+
</body>
|
|
428
|
+
</html>
|
|
429
|
+
HTML
|
|
430
|
+
else
|
|
431
|
+
# Show confirmation message without auto-close
|
|
432
|
+
"<html><body><h1>✅ Submitted!</h1><p>Data has been sent to the agent. You can close this window.</p></body></html>"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Event callback endpoint for on_change/on_blur handlers
|
|
437
|
+
post '/event/:key' do
|
|
438
|
+
begin
|
|
439
|
+
state = session[:streamlit_state] ||= {}
|
|
440
|
+
InteractionRunner.new(
|
|
441
|
+
app: settings.streamlit_app, state: state, params: params,
|
|
442
|
+
interaction: :event, target: params[:key], adapter: settings.adapter,
|
|
443
|
+
agentic: settings.respond_to?(:result_container),
|
|
444
|
+
persist: ->(value) { session[:streamlit_state] = session_safe_state(value) },
|
|
445
|
+
response_headers: ->(values) { headers values },
|
|
446
|
+
state_version: (session[:sw_state_version] || 0),
|
|
447
|
+
persist_state_version: ->(version) { session[:sw_state_version] = version }
|
|
448
|
+
).call
|
|
449
|
+
rescue => e
|
|
450
|
+
render_error("/event/#{params[:key]}", e)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Form submission endpoint for deferred form blocks
|
|
455
|
+
# Receives Rails-style nested params (form_name[field]) and updates state
|
|
456
|
+
post '/form/:form_name' do
|
|
457
|
+
begin
|
|
458
|
+
state = session[:streamlit_state] ||= {}
|
|
459
|
+
InteractionRunner.new(
|
|
460
|
+
app: settings.streamlit_app, state: state, params: params,
|
|
461
|
+
interaction: :form, target: params[:form_name], adapter: settings.adapter,
|
|
462
|
+
agentic: settings.respond_to?(:result_container),
|
|
463
|
+
persist: ->(value) { session[:streamlit_state] = session_safe_state(value) },
|
|
464
|
+
response_headers: ->(values) { headers values },
|
|
465
|
+
state_version: (session[:sw_state_version] || 0),
|
|
466
|
+
persist_state_version: ->(version) { session[:sw_state_version] = version }
|
|
467
|
+
).call
|
|
468
|
+
rescue => e
|
|
469
|
+
render_error("/form/#{params[:form_name]}", e)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Toast dismiss endpoint (called when a toast is closed client-side)
|
|
474
|
+
post '/toast/dismiss/:toast_id' do
|
|
475
|
+
state = session[:streamlit_state] ||= {}
|
|
476
|
+
toast_id = params[:toast_id]
|
|
477
|
+
|
|
478
|
+
# Remove the toast from state
|
|
479
|
+
if state[:_toasts].is_a?(Array)
|
|
480
|
+
state[:_toasts].reject! { |t| t[:id] == toast_id }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
484
|
+
|
|
485
|
+
# Return empty response (swap: none means no DOM update needed)
|
|
486
|
+
status 204
|
|
487
|
+
""
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Theme switching endpoint (for runtime theme changes)
|
|
491
|
+
post '/theme/:theme_name' do
|
|
492
|
+
theme = params[:theme_name].to_sym
|
|
493
|
+
|
|
494
|
+
# Accept built-in themes or custom registered themes
|
|
495
|
+
if StreamWeaver.theme_exists?(theme)
|
|
496
|
+
session[:theme_override] = theme
|
|
497
|
+
status 200
|
|
498
|
+
content_type 'text/plain'
|
|
499
|
+
# Return the new body classes for Alpine.js to update
|
|
500
|
+
"sw-theme-#{theme} sw-layout-#{settings.streamlit_app.layout}"
|
|
501
|
+
else
|
|
502
|
+
status 400
|
|
503
|
+
content_type 'text/plain'
|
|
504
|
+
"Invalid theme: #{theme}. Available themes: #{StreamWeaver.available_themes.join(', ')}"
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# =========================================
|
|
509
|
+
# Deck State endpoints (T8)
|
|
510
|
+
# =========================================
|
|
511
|
+
|
|
512
|
+
# Select an option for a slide (radio semantics)
|
|
513
|
+
post '/deck/select' do
|
|
514
|
+
begin
|
|
515
|
+
payload = if request.content_type&.include?('application/json')
|
|
516
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
517
|
+
else
|
|
518
|
+
{ slide_id: params[:slide_id], option_label: params[:option_label] }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
slide_id = payload[:slide_id]
|
|
522
|
+
option_label = payload[:option_label]
|
|
523
|
+
|
|
524
|
+
unless slide_id && option_label
|
|
525
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
526
|
+
JSON.generate(error: "Missing slide_id or option_label")
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Get or create deck session
|
|
530
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
531
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
532
|
+
deck_state.select(slide_id, option_label)
|
|
533
|
+
|
|
534
|
+
content_type :json
|
|
535
|
+
JSON.generate(success: true, slide_id: slide_id, option_label: option_label)
|
|
536
|
+
rescue => e
|
|
537
|
+
render_error("/deck/select", e)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Save a note for an option
|
|
542
|
+
post '/deck/note' do
|
|
543
|
+
begin
|
|
544
|
+
payload = if request.content_type&.include?('application/json')
|
|
545
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
546
|
+
else
|
|
547
|
+
{ slide_id: params[:slide_id], option_label: params[:option_label], text: params[:text] }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
slide_id = payload[:slide_id]
|
|
551
|
+
option_label = payload[:option_label]
|
|
552
|
+
text = payload[:text] || ""
|
|
553
|
+
|
|
554
|
+
unless slide_id && option_label
|
|
555
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
556
|
+
JSON.generate(error: "Missing slide_id or option_label")
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
560
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
561
|
+
deck_state.set_note(slide_id, option_label, text)
|
|
562
|
+
|
|
563
|
+
content_type :json
|
|
564
|
+
JSON.generate(success: true, slide_id: slide_id, option_label: option_label)
|
|
565
|
+
rescue => e
|
|
566
|
+
render_error("/deck/note", e)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Read the full deck state (for debugging / agent access)
|
|
571
|
+
get '/deck/state' do
|
|
572
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
573
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
574
|
+
|
|
575
|
+
content_type :json
|
|
576
|
+
JSON.generate(deck_state.to_h)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Re-render app content with current deck state.
|
|
580
|
+
# Accepts ?slide=N to remember which slide the user is on.
|
|
581
|
+
get '/deck/refresh' do
|
|
582
|
+
state = session[:streamlit_state] ||= {}
|
|
583
|
+
state[:_deck_current_slide] = params[:slide].to_i if params[:slide]
|
|
584
|
+
inject_deck_state!(state)
|
|
585
|
+
streamlit_app = settings.streamlit_app
|
|
586
|
+
adapter = settings.adapter
|
|
587
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
588
|
+
streamlit_app.with_render_lock do
|
|
589
|
+
streamlit_app.rebuild_with_state(state)
|
|
590
|
+
Views::AppContentView.new(streamlit_app, state, adapter, is_agentic).call
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Save final notes on the summary slide (T9)
|
|
595
|
+
post '/deck/final_notes' do
|
|
596
|
+
begin
|
|
597
|
+
payload = if request.content_type&.include?('application/json')
|
|
598
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
599
|
+
else
|
|
600
|
+
{ text: params[:text] }
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
604
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
605
|
+
deck_state.set_final_notes(payload[:text] || "")
|
|
606
|
+
|
|
607
|
+
content_type :json
|
|
608
|
+
JSON.generate(success: true)
|
|
609
|
+
rescue => e
|
|
610
|
+
render_error("/deck/final_notes", e)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Submit the deck -- marks as submitted and sets _result for run_once! (T9)
|
|
615
|
+
post '/deck/submit' do
|
|
616
|
+
begin
|
|
617
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
618
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
619
|
+
deck_state.submit!
|
|
620
|
+
|
|
621
|
+
state = session[:streamlit_state] ||= {}
|
|
622
|
+
state[:_result] = {
|
|
623
|
+
deck_selections: deck_state.selections,
|
|
624
|
+
deck_notes: deck_state.notes,
|
|
625
|
+
deck_final_notes: deck_state.final_notes
|
|
626
|
+
}
|
|
627
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
628
|
+
|
|
629
|
+
# Signal completion for agentic mode (run_once!)
|
|
630
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
631
|
+
if is_agentic && settings.respond_to?(:result_container)
|
|
632
|
+
settings.result_container[:result] = state[:_result]
|
|
633
|
+
settings.result_container[:ready] = true
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
content_type :json
|
|
637
|
+
JSON.generate(success: true, submitted: true)
|
|
638
|
+
rescue => e
|
|
639
|
+
render_error("/deck/submit", e)
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# =========================================
|
|
644
|
+
# Model Selector endpoint (T14)
|
|
645
|
+
# =========================================
|
|
646
|
+
|
|
647
|
+
# Set the selected AI model for generate-more.
|
|
648
|
+
post '/deck/set_model' do
|
|
649
|
+
begin
|
|
650
|
+
payload = if request.content_type&.include?('application/json')
|
|
651
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
652
|
+
else
|
|
653
|
+
{ model_id: params[:model_id] }
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
model_id = payload[:model_id]
|
|
657
|
+
halt 400, JSON.generate(error: "model_id required") unless model_id
|
|
658
|
+
|
|
659
|
+
deck_state = get_or_create_deck_state
|
|
660
|
+
deck_state.set_model(model_id.to_s)
|
|
661
|
+
|
|
662
|
+
content_type :json
|
|
663
|
+
JSON.generate(success: true, model_id: model_id.to_s)
|
|
664
|
+
rescue => e
|
|
665
|
+
render_error("/deck/set_model", e)
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# =========================================
|
|
670
|
+
# Generate-More endpoints (T10)
|
|
671
|
+
# =========================================
|
|
672
|
+
|
|
673
|
+
# User requests more options for a slide.
|
|
674
|
+
# Queues a generate request and transitions to :generating.
|
|
675
|
+
post '/deck/generate' do
|
|
676
|
+
begin
|
|
677
|
+
payload = if request.content_type&.include?('application/json')
|
|
678
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
679
|
+
else
|
|
680
|
+
{ slide_id: params[:slide_id], count: params[:count], prompt: params[:prompt] }
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
slide_id = payload[:slide_id]
|
|
684
|
+
count = (payload[:count] || 2).to_i
|
|
685
|
+
prompt = payload[:prompt]
|
|
686
|
+
|
|
687
|
+
unless slide_id
|
|
688
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
689
|
+
JSON.generate(error: "Missing slide_id")
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
count = [[count, 1].max, 5].min # Clamp 1-5
|
|
693
|
+
|
|
694
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
695
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
696
|
+
request_id = deck_state.start_generate(slide_id, count, prompt: prompt)
|
|
697
|
+
|
|
698
|
+
# Trigger SSE re-render so skeletons appear
|
|
699
|
+
if settings.respond_to?(:streamer) && settings.streamer
|
|
700
|
+
state = session[:streamlit_state] ||= {}
|
|
701
|
+
inject_deck_state!(state)
|
|
702
|
+
streamlit_app = settings.streamlit_app
|
|
703
|
+
adapter = settings.adapter
|
|
704
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
705
|
+
html = streamlit_app.with_render_lock do
|
|
706
|
+
streamlit_app.rebuild_with_state(state)
|
|
707
|
+
Views::AppContentView.new(streamlit_app, state, adapter, is_agentic).call
|
|
708
|
+
end
|
|
709
|
+
settings.streamer.replace("#main", html)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
status 202
|
|
713
|
+
content_type :json
|
|
714
|
+
JSON.generate(
|
|
715
|
+
status: "generating",
|
|
716
|
+
request_id: request_id,
|
|
717
|
+
slide_id: slide_id,
|
|
718
|
+
count: count
|
|
719
|
+
)
|
|
720
|
+
rescue => e
|
|
721
|
+
render_error("/deck/generate", e)
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Agent polls for pending generate requests.
|
|
726
|
+
# Returns and removes all queued requests for the given session.
|
|
727
|
+
get '/deck/pending' do
|
|
728
|
+
begin
|
|
729
|
+
session_id = params[:session_id]
|
|
730
|
+
unless session_id
|
|
731
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
732
|
+
JSON.generate(error: "Missing session_id")
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
deck_state = Components::Deck::DeckState.new(session_id)
|
|
736
|
+
requests = deck_state.take_pending_requests!
|
|
737
|
+
|
|
738
|
+
content_type :json
|
|
739
|
+
JSON.generate(requests: requests)
|
|
740
|
+
rescue => e
|
|
741
|
+
render_error("/deck/pending", e)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Agent pushes a generated option into state.
|
|
746
|
+
# Increments received_count, transitions to :idle when all received.
|
|
747
|
+
# Triggers SSE re-render.
|
|
748
|
+
post '/deck/add_option' do
|
|
749
|
+
begin
|
|
750
|
+
payload = if request.content_type&.include?('application/json')
|
|
751
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
752
|
+
else
|
|
753
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
754
|
+
JSON.generate(error: "Request body must be JSON")
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
session_id = payload[:session_id]
|
|
758
|
+
slide_id = payload[:slide_id]
|
|
759
|
+
request_id = payload[:request_id]
|
|
760
|
+
option_data = payload[:option] || {}
|
|
761
|
+
|
|
762
|
+
unless session_id && slide_id && request_id
|
|
763
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
764
|
+
JSON.generate(error: "Missing session_id, slide_id, or request_id")
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
deck_state = Components::Deck::DeckState.new(session_id)
|
|
768
|
+
|
|
769
|
+
# Convert symbol keys to strings for storage
|
|
770
|
+
option_hash = {}
|
|
771
|
+
option_data.each { |k, v| option_hash[k.to_s] = v }
|
|
772
|
+
|
|
773
|
+
gen_state = deck_state.add_generated_option(slide_id, option_hash, request_id: request_id)
|
|
774
|
+
|
|
775
|
+
# Trigger SSE re-render so new option appears
|
|
776
|
+
if settings.respond_to?(:streamer) && settings.streamer
|
|
777
|
+
# We can't easily re-render for a different session from the agent endpoint.
|
|
778
|
+
# Instead, use replace on #main to trigger a page refresh via SSE.
|
|
779
|
+
# The connected browser will pick up new state on next render.
|
|
780
|
+
# Push a refresh-trigger event.
|
|
781
|
+
settings.streamer.replace(
|
|
782
|
+
"#sw-generate-area",
|
|
783
|
+
"<div id='sw-generate-area' data-refresh='#{Time.now.to_f}'></div>"
|
|
784
|
+
)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
content_type :json
|
|
788
|
+
JSON.generate(
|
|
789
|
+
success: true,
|
|
790
|
+
slide_id: slide_id,
|
|
791
|
+
received_count: gen_state["received_count"],
|
|
792
|
+
status: gen_state["status"]
|
|
793
|
+
)
|
|
794
|
+
rescue => e
|
|
795
|
+
render_error("/deck/add_option", e)
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# User cancels a pending generation.
|
|
800
|
+
post '/deck/cancel_generate' do
|
|
801
|
+
begin
|
|
802
|
+
session[:deck_session_id] ||= Components::Deck::DeckState.generate_session_id
|
|
803
|
+
deck_state = Components::Deck::DeckState.new(session[:deck_session_id])
|
|
804
|
+
deck_state.cancel_generate
|
|
805
|
+
# Immediately transition cancelled -> idle
|
|
806
|
+
deck_state.reset_generate
|
|
807
|
+
|
|
808
|
+
# Trigger SSE re-render
|
|
809
|
+
if settings.respond_to?(:streamer) && settings.streamer
|
|
810
|
+
state = session[:streamlit_state] ||= {}
|
|
811
|
+
inject_deck_state!(state)
|
|
812
|
+
streamlit_app = settings.streamlit_app
|
|
813
|
+
adapter = settings.adapter
|
|
814
|
+
is_agentic = settings.respond_to?(:result_container)
|
|
815
|
+
html = streamlit_app.with_render_lock do
|
|
816
|
+
streamlit_app.rebuild_with_state(state)
|
|
817
|
+
Views::AppContentView.new(streamlit_app, state, adapter, is_agentic).call
|
|
818
|
+
end
|
|
819
|
+
settings.streamer.replace("#main", html)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
content_type :json
|
|
823
|
+
JSON.generate(success: true, status: "idle")
|
|
824
|
+
rescue => e
|
|
825
|
+
render_error("/deck/cancel_generate", e)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# SSE endpoint - browser subscribes for real-time push updates.
|
|
830
|
+
# Uses Sinatra stream (without :keep_open) for Puma compatibility.
|
|
831
|
+
# The block keeps the connection alive with a heartbeat loop;
|
|
832
|
+
# the Streamer broadcasts to the `out` object from other threads.
|
|
833
|
+
get '/stream' do
|
|
834
|
+
content_type 'text/event-stream'
|
|
835
|
+
cache_control :no_cache
|
|
836
|
+
headers 'Connection' => 'keep-alive',
|
|
837
|
+
'X-Accel-Buffering' => 'no'
|
|
838
|
+
|
|
839
|
+
stream do |out|
|
|
840
|
+
streamer = settings.streamer
|
|
841
|
+
streamer.add_connection(out)
|
|
842
|
+
$stderr.puts "[SW] SSE connected (#{streamer.connection_count} total)" if ENV['SW_DEBUG']
|
|
843
|
+
out << "data: #{JSON.generate(type: "connected")}\n\n"
|
|
844
|
+
# Keep connection alive - Puma needs the block to stay open.
|
|
845
|
+
# Check shutdown flag so Ctrl+C can terminate cleanly.
|
|
846
|
+
until streamer.shutting_down?
|
|
847
|
+
sleep 1
|
|
848
|
+
out << ": heartbeat\n\n"
|
|
849
|
+
end
|
|
850
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
851
|
+
# Client disconnected
|
|
852
|
+
$stderr.puts "[SW] SSE disconnected (client gone)" if ENV['SW_DEBUG']
|
|
853
|
+
ensure
|
|
854
|
+
streamer.remove_connection(out)
|
|
855
|
+
$stderr.puts "[SW] SSE removed (#{streamer.connection_count} remaining)" if ENV['SW_DEBUG']
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Push endpoint - any external process can POST targeted DOM updates
|
|
860
|
+
post '/stream/push' do
|
|
861
|
+
payload = if request.content_type&.include?('application/json')
|
|
862
|
+
JSON.parse(request.body.read, symbolize_names: true)
|
|
863
|
+
else
|
|
864
|
+
params
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
target = payload[:target] || '#main'
|
|
868
|
+
action = (payload[:action] || 'replace').to_sym
|
|
869
|
+
html = payload[:html] || payload[:content] || ''
|
|
870
|
+
|
|
871
|
+
unless Streamer::ACTIONS.include?(action)
|
|
872
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
873
|
+
JSON.generate(error: "Invalid action: #{action}")
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
settings.streamer.public_send(action, target, html)
|
|
877
|
+
content_type :json
|
|
878
|
+
JSON.generate(success: true, target: target, action: action)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# ── StreamWeaver Debug Routes (/sw/*) ─────────────────────────────────
|
|
882
|
+
# /sw/session — dump current session state as JSON
|
|
883
|
+
# /sw/session/size — per-key byte breakdown + overflow flag
|
|
884
|
+
# /sw/reset — clear session and redirect to /
|
|
885
|
+
# /sw/sessions/cleanup — delete file sessions older than 7 days
|
|
886
|
+
|
|
887
|
+
get '/sw/session' do
|
|
888
|
+
content_type :json
|
|
889
|
+
state = session[:streamlit_state] || {}
|
|
890
|
+
JSON.pretty_generate(state.transform_keys(&:to_s))
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
get '/sw/session/size' do
|
|
894
|
+
content_type :json
|
|
895
|
+
state = session[:streamlit_state] || {}
|
|
896
|
+
serialized = JSON.dump(state)
|
|
897
|
+
by_key = state.map { |k, v| { key: k.to_s, bytes: JSON.dump(v).bytesize } }
|
|
898
|
+
.sort_by { |x| -x[:bytes] }
|
|
899
|
+
result = {
|
|
900
|
+
store: SW_SESSION_STORE_NAME,
|
|
901
|
+
total_bytes: serialized.bytesize,
|
|
902
|
+
keys: by_key
|
|
903
|
+
}
|
|
904
|
+
if SW_SESSION_STORE_NAME == 'cookie'
|
|
905
|
+
result[:limit_bytes] = 4096
|
|
906
|
+
result[:over_limit] = serialized.bytesize > 4096
|
|
907
|
+
end
|
|
908
|
+
JSON.pretty_generate(result)
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
get '/sw/reset' do
|
|
912
|
+
session.clear
|
|
913
|
+
redirect '/'
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
get '/sw/sessions/cleanup' do
|
|
917
|
+
content_type :json
|
|
918
|
+
unless SW_SESSION_STORE_NAME == 'file'
|
|
919
|
+
halt 400, JSON.generate({ error: 'cleanup only applies to file session store' })
|
|
920
|
+
end
|
|
921
|
+
dir = defined?(SW_SESSION_DIR) ? SW_SESSION_DIR : ''
|
|
922
|
+
unless ::File.directory?(dir)
|
|
923
|
+
halt 400, JSON.generate({ error: "session dir not found: #{dir}" })
|
|
924
|
+
end
|
|
925
|
+
cutoff = Time.now - (7 * 86_400)
|
|
926
|
+
deleted = Dir.glob("#{dir}/session_*").count do |f|
|
|
927
|
+
::File.mtime(f) < cutoff && ::File.delete(f) rescue false
|
|
928
|
+
end
|
|
929
|
+
remaining = Dir.glob("#{dir}/session_*").count
|
|
930
|
+
JSON.generate({ deleted: deleted, remaining: remaining, dir: dir })
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# =========================================
|
|
934
|
+
# Custom user-defined HTTP endpoints (App#endpoint DSL)
|
|
935
|
+
# =========================================
|
|
936
|
+
# These are defined AFTER every StreamWeaver-internal route above, so on a path
|
|
937
|
+
# collision the internal route always wins (Sinatra dispatches to the first route
|
|
938
|
+
# that matches, and `endpoint` warns at registration time about known collisions).
|
|
939
|
+
# GET custom endpoints are checked inside the '/*' catch-all below (Sinatra only
|
|
940
|
+
# dispatches one GET '/*' route, which URL-routing's fallback also needs); the other
|
|
941
|
+
# verbs get their own catch-all here since no other route claims them.
|
|
942
|
+
%i[post put patch delete].each do |verb|
|
|
943
|
+
send(verb, '/*') do
|
|
944
|
+
streamlit_app = settings.streamlit_app
|
|
945
|
+
path = "/#{params['splat'].first}"
|
|
946
|
+
ep = streamlit_app.find_endpoint(verb, path)
|
|
947
|
+
pass unless ep
|
|
948
|
+
status, headers, body = SinatraApp.normalize_endpoint_result(ep[:block].call(request))
|
|
949
|
+
halt status, headers, body
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# URL routing: catch-all GET for deep-linked paths
|
|
954
|
+
get '/*' do
|
|
955
|
+
streamlit_app = settings.streamlit_app
|
|
956
|
+
path = "/#{params['splat'].first}"
|
|
957
|
+
|
|
958
|
+
if (ep = streamlit_app.find_endpoint(:get, path))
|
|
959
|
+
status, headers, body = SinatraApp.normalize_endpoint_result(ep[:block].call(request))
|
|
960
|
+
halt status, headers, body
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
pass unless streamlit_app.routable?
|
|
964
|
+
|
|
965
|
+
route_state = streamlit_app.state_for_path(path)
|
|
966
|
+
pass unless route_state
|
|
967
|
+
|
|
968
|
+
state = session[:streamlit_state] ||= {}
|
|
969
|
+
route_state.each { |k, v| state[k] = v }
|
|
970
|
+
sync_params_to_state(state)
|
|
971
|
+
session[:streamlit_state] = session_safe_state(state)
|
|
972
|
+
|
|
973
|
+
cache_control :no_cache, :no_store, :must_revalidate, max_age: 0
|
|
974
|
+
headers 'Pragma' => 'no-cache'
|
|
975
|
+
|
|
976
|
+
render_app(state, is_htmx: request.env.key?('HTTP_HX_REQUEST'))
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# URL routing: push URL on POST responses when route key changes
|
|
980
|
+
after do
|
|
981
|
+
next unless request.post? && settings.streamlit_app.routable?
|
|
982
|
+
state = session[:streamlit_state] || {}
|
|
983
|
+
new_path = settings.streamlit_app.path_for_state(state)
|
|
984
|
+
headers['HX-Push-Url'] = new_path if new_path
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# Return the class itself (it's the Rack app)
|
|
988
|
+
self
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Convert an App#endpoint block's return value into a Rack triplet.
|
|
992
|
+
# Shared by SinatraApp (standalone mode) and Service (multi-app mode).
|
|
993
|
+
#
|
|
994
|
+
# @param result [Object] whatever the endpoint block returned
|
|
995
|
+
# @return [Array(Integer, Hash, Array<String>)] a Rack-compatible [status, headers, body]
|
|
996
|
+
def self.normalize_endpoint_result(result)
|
|
997
|
+
case result
|
|
998
|
+
when Array
|
|
999
|
+
result
|
|
1000
|
+
when Hash
|
|
1001
|
+
[200, { 'Content-Type' => 'application/json' }, [JSON.generate(result)]]
|
|
1002
|
+
when String
|
|
1003
|
+
[200, { 'Content-Type' => 'text/html' }, [result]]
|
|
1004
|
+
else
|
|
1005
|
+
[200, { 'Content-Type' => 'text/plain' }, [result.to_s]]
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Find a button (or menu item -- same click-dispatch contract: #id + #execute)
|
|
1010
|
+
# recursively in the component tree
|
|
1011
|
+
#
|
|
1012
|
+
# @param components [Array] Array of components
|
|
1013
|
+
# @param button_id [String] Button ID to find
|
|
1014
|
+
# @return [Components::Button, Components::MenuItem, nil] The button/menu item or nil
|
|
1015
|
+
def self.find_button_recursive(components, button_id)
|
|
1016
|
+
components.each do |component|
|
|
1017
|
+
if (component.is_a?(Components::Button) || component.is_a?(Components::MenuItem)) &&
|
|
1018
|
+
component.id == button_id
|
|
1019
|
+
return component
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
if component.respond_to?(:children) && component.children
|
|
1023
|
+
found = find_button_recursive(component.children, button_id)
|
|
1024
|
+
return found if found
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Also search modal footer if present
|
|
1028
|
+
if component.is_a?(Components::Modal) && component.footer_component&.children
|
|
1029
|
+
found = find_button_recursive(component.footer_component.children, button_id)
|
|
1030
|
+
return found if found
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
nil
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Find a form component recursively in the component tree
|
|
1037
|
+
#
|
|
1038
|
+
# @param components [Array] Array of components
|
|
1039
|
+
# @param form_name [Symbol] Form name to find
|
|
1040
|
+
# @return [Components::Form, nil] The form or nil
|
|
1041
|
+
def self.find_form_recursive(components, form_name)
|
|
1042
|
+
components.each do |component|
|
|
1043
|
+
return component if component.is_a?(Components::Form) && component.name == form_name
|
|
1044
|
+
|
|
1045
|
+
if component.respond_to?(:children) && component.children
|
|
1046
|
+
found = find_form_recursive(component.children, form_name)
|
|
1047
|
+
return found if found
|
|
1048
|
+
end
|
|
1049
|
+
end
|
|
1050
|
+
nil
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
# Collect all input component keys recursively
|
|
1054
|
+
#
|
|
1055
|
+
# @param components [Array] Array of components
|
|
1056
|
+
# @return [Array<Symbol>] Array of state keys
|
|
1057
|
+
def self.collect_input_keys(components)
|
|
1058
|
+
keys = []
|
|
1059
|
+
components.each do |comp|
|
|
1060
|
+
keys << comp.key if comp.respond_to?(:key) && comp.key
|
|
1061
|
+
keys += collect_input_keys(comp.children) if comp.respond_to?(:children) && comp.children
|
|
1062
|
+
end
|
|
1063
|
+
keys
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Find a component by its state key
|
|
1067
|
+
#
|
|
1068
|
+
# @param components [Array] Array of components
|
|
1069
|
+
# @param key [Symbol] State key to find
|
|
1070
|
+
# @return [Components::Base, nil] The component or nil
|
|
1071
|
+
def self.find_component_by_key(components, key)
|
|
1072
|
+
components.each do |comp|
|
|
1073
|
+
return comp if comp.respond_to?(:key) && comp.key == key
|
|
1074
|
+
if comp.respond_to?(:children) && comp.children
|
|
1075
|
+
found = find_component_by_key(comp.children, key)
|
|
1076
|
+
return found if found
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
nil
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# Find an available port starting from the given port
|
|
1083
|
+
#
|
|
1084
|
+
# @param start_port [Integer] Starting port number (default 4567)
|
|
1085
|
+
# @return [Integer] Available port number
|
|
1086
|
+
def self.find_available_port(start_port = 4567)
|
|
1087
|
+
port = start_port
|
|
1088
|
+
loop do
|
|
1089
|
+
begin
|
|
1090
|
+
# Check both 127.0.0.1 and 0.0.0.0 to avoid conflicts where
|
|
1091
|
+
# a wildcard listener already holds the port (macOS routes to it)
|
|
1092
|
+
s1 = TCPServer.new('127.0.0.1', port)
|
|
1093
|
+
s1.close
|
|
1094
|
+
s2 = TCPServer.new('0.0.0.0', port)
|
|
1095
|
+
s2.close
|
|
1096
|
+
return port
|
|
1097
|
+
rescue Errno::EADDRINUSE
|
|
1098
|
+
port += 1
|
|
1099
|
+
raise "No available ports found" if port > start_port + 100
|
|
1100
|
+
end
|
|
1101
|
+
end
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
# Open browser to the given URL (cross-platform)
|
|
1105
|
+
#
|
|
1106
|
+
# @param url [String] The URL to open
|
|
1107
|
+
# Guarded here, at the root, rather than trusting every call site to
|
|
1108
|
+
# remember `if auto_open` computed the SW_NO_OPEN default correctly --
|
|
1109
|
+
# an explicit options[:open_browser] override from a caller (or a test)
|
|
1110
|
+
# would otherwise bypass it entirely.
|
|
1111
|
+
def self.open_browser(url)
|
|
1112
|
+
return if ENV['SW_NO_OPEN']
|
|
1113
|
+
|
|
1114
|
+
Thread.new do
|
|
1115
|
+
sleep 1 # Wait for server to start
|
|
1116
|
+
case RbConfig::CONFIG['host_os']
|
|
1117
|
+
when /darwin|mac os/
|
|
1118
|
+
system("open", url)
|
|
1119
|
+
when /linux|bsd/
|
|
1120
|
+
system("xdg-open", url)
|
|
1121
|
+
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
|
1122
|
+
system("start", url)
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
# Resolve host and port from options, env vars, or defaults
|
|
1128
|
+
def self.resolve_host_and_port(options)
|
|
1129
|
+
port = options[:port] || ENV['STREAMWEAVER_PORT']&.to_i || ENV['PORT']&.to_i || find_available_port
|
|
1130
|
+
host = options[:host] || ENV['STREAMWEAVER_HOST'] || '127.0.0.1'
|
|
1131
|
+
[host, port]
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
# Configure Sinatra server settings for host, port, and host authorization
|
|
1135
|
+
def self.configure_server!(host, port)
|
|
1136
|
+
set :port, port
|
|
1137
|
+
set :bind, host
|
|
1138
|
+
set :server, :puma
|
|
1139
|
+
set :quiet, true if respond_to?(:quiet)
|
|
1140
|
+
# host_authorization is set at class level (permits all hosts)
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
def self.loopback_host?(host)
|
|
1144
|
+
%w[127.0.0.1 localhost ::1].include?(host)
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
# One-shot state reset: returns true once, then false thereafter
|
|
1148
|
+
def self.reset_state_pending?
|
|
1149
|
+
return false unless @reset_state_pending
|
|
1150
|
+
@reset_state_pending = false
|
|
1151
|
+
true
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
def self.display_url(host, port)
|
|
1155
|
+
display_host = (host == '0.0.0.0' || host == '::') ? 'localhost' : host
|
|
1156
|
+
"http://#{display_host}:#{port}"
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
# Start the internal stream thread if the app has a stream block.
|
|
1160
|
+
# Start timer thread if the app has periodic timers.
|
|
1161
|
+
# Assumes rebuild_with_state has already been called to evaluate the DSL.
|
|
1162
|
+
def self.start_stream_thread
|
|
1163
|
+
stream_block = settings.streamlit_app.stream_block
|
|
1164
|
+
timers = settings.streamlit_app.timers
|
|
1165
|
+
|
|
1166
|
+
# Capture real stderr before it gets suppressed
|
|
1167
|
+
real_stderr = $stderr
|
|
1168
|
+
|
|
1169
|
+
if stream_block
|
|
1170
|
+
Thread.new do
|
|
1171
|
+
# Wait for at least one SSE subscriber before pushing
|
|
1172
|
+
sleep 0.1 until settings.streamer.connection_count > 0
|
|
1173
|
+
|
|
1174
|
+
retries = 0
|
|
1175
|
+
begin
|
|
1176
|
+
stream_block.call(settings.streamer)
|
|
1177
|
+
rescue => e
|
|
1178
|
+
retries += 1
|
|
1179
|
+
real_stderr.puts "[StreamWeaver] Stream thread error (attempt #{retries}): #{e.class}: #{e.message}"
|
|
1180
|
+
real_stderr.puts e.backtrace.first(5).join("\n")
|
|
1181
|
+
sleep [2 ** retries, 30].min
|
|
1182
|
+
retry
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
if timers.any?
|
|
1188
|
+
Thread.new do
|
|
1189
|
+
# Wait for at least one SSE subscriber before firing timers
|
|
1190
|
+
sleep 0.1 until settings.streamer.connection_count > 0
|
|
1191
|
+
|
|
1192
|
+
loop do
|
|
1193
|
+
now = Time.now.to_f
|
|
1194
|
+
timers.each do |timer|
|
|
1195
|
+
next if timer[:last_run] && (now - timer[:last_run]) < timer[:interval]
|
|
1196
|
+
timer[:last_run] = now
|
|
1197
|
+
begin
|
|
1198
|
+
timer[:block].call(settings.streamer)
|
|
1199
|
+
rescue => e
|
|
1200
|
+
real_stderr.puts "[StreamWeaver] Timer error: #{e.class}: #{e.message}"
|
|
1201
|
+
real_stderr.puts e.backtrace.first(5).join("\n")
|
|
1202
|
+
end
|
|
1203
|
+
end
|
|
1204
|
+
sleep 1
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
# Custom run! method with auto-browser opening (persistent server)
|
|
1211
|
+
#
|
|
1212
|
+
# @param options [Hash] Options
|
|
1213
|
+
# @option options [Integer] :port Port number (default: auto-detect, or STREAMWEAVER_PORT env var)
|
|
1214
|
+
# @option options [String] :host Host to bind (default: '127.0.0.1', or STREAMWEAVER_HOST env var)
|
|
1215
|
+
# @option options [Boolean] :open_browser Auto-open browser (default: true, but false when PORT env var is set)
|
|
1216
|
+
def self.run!(options = {})
|
|
1217
|
+
if StreamWeaver.service_loading
|
|
1218
|
+
warn "StreamWeaver: skipping run! — this file is being loaded into a running " \
|
|
1219
|
+
"service (streamweaver serve), which serves it directly; starting a second " \
|
|
1220
|
+
"server here would take the service down."
|
|
1221
|
+
return self
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
host, port = resolve_host_and_port(options)
|
|
1225
|
+
auto_open = options.fetch(:open_browser, !ENV['PORT'] && !ENV['SW_NO_OPEN'])
|
|
1226
|
+
@reset_state_pending = ARGV.delete('--reset') ? true : false
|
|
1227
|
+
|
|
1228
|
+
configure_server!(host, port)
|
|
1229
|
+
# Force-kill after 1s on shutdown - SSE connections never complete gracefully
|
|
1230
|
+
# SSE connections hold threads indefinitely, so we need a generous pool
|
|
1231
|
+
set :server_settings, { force_shutdown_after: 1, Threads: '0:16' }
|
|
1232
|
+
|
|
1233
|
+
url = display_url(host, port)
|
|
1234
|
+
|
|
1235
|
+
# Evaluate the DSL block to detect stream configuration
|
|
1236
|
+
settings.streamlit_app.rebuild_with_state({})
|
|
1237
|
+
|
|
1238
|
+
# Portfile: write so feed scripts can discover this app by name
|
|
1239
|
+
Portfile.clean_stale!
|
|
1240
|
+
Portfile.write(settings.streamlit_app.title, url: url, pid: Process.pid)
|
|
1241
|
+
at_exit { Portfile.delete(settings.streamlit_app.title) }
|
|
1242
|
+
|
|
1243
|
+
# Custom startup banner
|
|
1244
|
+
sanitized = Portfile.sanitize(settings.streamlit_app.title)
|
|
1245
|
+
puts "\n"
|
|
1246
|
+
puts "╔═══════════════════════════════════════════════════════════╗"
|
|
1247
|
+
puts "║ StreamWeaver App Running ║"
|
|
1248
|
+
puts "╚═══════════════════════════════════════════════════════════╝"
|
|
1249
|
+
puts ""
|
|
1250
|
+
puts " 🌐 #{url}"
|
|
1251
|
+
puts " 📱 #{settings.streamlit_app.title}"
|
|
1252
|
+
puts " 📁 ~/.streamweaver/apps/#{sanitized}.port"
|
|
1253
|
+
if settings.streamlit_app.stream_block
|
|
1254
|
+
puts " 📡 SSE streaming enabled (GET /stream, POST /stream/push)"
|
|
1255
|
+
end
|
|
1256
|
+
if settings.streamlit_app.has_timers?
|
|
1257
|
+
puts " ⏱️ #{settings.streamlit_app.timers.size} periodic timer(s) registered"
|
|
1258
|
+
end
|
|
1259
|
+
if @reset_state_pending
|
|
1260
|
+
puts " 🔄 State will be cleared on first page load (--reset)"
|
|
1261
|
+
end
|
|
1262
|
+
puts ""
|
|
1263
|
+
puts " Press Ctrl+C to stop (may take a few seconds to drain SSE connections)"
|
|
1264
|
+
puts ""
|
|
1265
|
+
|
|
1266
|
+
open_browser(url) if auto_open
|
|
1267
|
+
|
|
1268
|
+
# Start internal stream thread if app defined a stream block
|
|
1269
|
+
start_stream_thread
|
|
1270
|
+
|
|
1271
|
+
# Suppress Sinatra/Puma startup messages
|
|
1272
|
+
original_stdout = $stdout
|
|
1273
|
+
original_stderr = $stderr
|
|
1274
|
+
unless ENV['DEBUG'] || ENV['SW_DEBUG']
|
|
1275
|
+
$stdout = StringIO.new
|
|
1276
|
+
$stderr = StringIO.new
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
begin
|
|
1280
|
+
super()
|
|
1281
|
+
ensure
|
|
1282
|
+
$stdout = original_stdout
|
|
1283
|
+
$stderr = original_stderr
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# Run once and return data (agentic mode)
|
|
1288
|
+
#
|
|
1289
|
+
# @param options [Hash] Options
|
|
1290
|
+
# @option options [Symbol] :output Output mode (:stdout or :file, default: :stdout)
|
|
1291
|
+
# @option options [String] :output_file File path to write JSON result
|
|
1292
|
+
# @option options [Integer] :timeout Timeout in seconds (default: 300)
|
|
1293
|
+
# @option options [Boolean] :auto_close_window Auto-close browser window after submit (default: false)
|
|
1294
|
+
# @return [Hash] The submitted state
|
|
1295
|
+
def self.run_once!(options = {})
|
|
1296
|
+
output_mode = options.fetch(:output, :stdout)
|
|
1297
|
+
output_file = options[:output_file]
|
|
1298
|
+
timeout = options.fetch(:timeout, 300)
|
|
1299
|
+
auto_close_window = options.fetch(:auto_close_window, false)
|
|
1300
|
+
|
|
1301
|
+
# Container for result (shared between server and main thread)
|
|
1302
|
+
result_container = { result: nil, ready: false }
|
|
1303
|
+
set :result_container, result_container
|
|
1304
|
+
set :auto_close_window, auto_close_window
|
|
1305
|
+
|
|
1306
|
+
host, port = resolve_host_and_port(options)
|
|
1307
|
+
auto_open = options.fetch(:open_browser, !ENV['SW_NO_OPEN'])
|
|
1308
|
+
|
|
1309
|
+
configure_server!(host, port)
|
|
1310
|
+
|
|
1311
|
+
url = display_url(host, port)
|
|
1312
|
+
|
|
1313
|
+
# Startup banner
|
|
1314
|
+
puts "\n"
|
|
1315
|
+
puts "╔═══════════════════════════════════════════════════════════╗"
|
|
1316
|
+
puts "║ StreamWeaver App (Agentic Mode) ║"
|
|
1317
|
+
puts "╚═══════════════════════════════════════════════════════════╝"
|
|
1318
|
+
puts ""
|
|
1319
|
+
puts " 🌐 #{url}"
|
|
1320
|
+
puts " 📱 #{settings.streamlit_app.title}"
|
|
1321
|
+
puts " ⏱️ Waiting for form submission (timeout: #{timeout}s)..."
|
|
1322
|
+
puts ""
|
|
1323
|
+
|
|
1324
|
+
open_browser(url) if auto_open
|
|
1325
|
+
|
|
1326
|
+
# Save original stdout/stderr before server thread
|
|
1327
|
+
original_stdout = $stdout
|
|
1328
|
+
original_stderr = $stderr
|
|
1329
|
+
|
|
1330
|
+
# Setup interrupt handler for clean exit
|
|
1331
|
+
interrupted = false
|
|
1332
|
+
trap('INT') do
|
|
1333
|
+
interrupted = true
|
|
1334
|
+
puts "\n\n👋 Shutting down StreamWeaver (agentic mode)..."
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# Start server in background thread
|
|
1338
|
+
server_container = { server: nil, error: nil, ready: false }
|
|
1339
|
+
|
|
1340
|
+
# Enable thread exception reporting
|
|
1341
|
+
Thread.abort_on_exception = true if ENV['DEBUG']
|
|
1342
|
+
|
|
1343
|
+
server_thread = Thread.new do
|
|
1344
|
+
begin
|
|
1345
|
+
puts "DEBUG: Starting server thread..." if ENV['DEBUG']
|
|
1346
|
+
|
|
1347
|
+
# Don't suppress output - we need to see errors
|
|
1348
|
+
require 'puma'
|
|
1349
|
+
puts "DEBUG: Puma loaded" if ENV['DEBUG']
|
|
1350
|
+
|
|
1351
|
+
# Create Puma server directly with thread pool configuration
|
|
1352
|
+
# Puma 6.x+ requires threads to be configured in constructor
|
|
1353
|
+
puts "DEBUG: Creating Puma::Server..." if ENV['DEBUG']
|
|
1354
|
+
puma_server = Puma::Server.new(self, nil, {min_threads: 0, max_threads: 4})
|
|
1355
|
+
puts "DEBUG: Puma::Server created" if ENV['DEBUG']
|
|
1356
|
+
|
|
1357
|
+
puma_server.add_tcp_listener(host, port)
|
|
1358
|
+
puts "DEBUG: TCP listener added on #{host}:#{port}" if ENV['DEBUG']
|
|
1359
|
+
|
|
1360
|
+
# Store server reference
|
|
1361
|
+
server_container[:server] = puma_server
|
|
1362
|
+
server_container[:ready] = true
|
|
1363
|
+
Thread.current[:server_started] = true
|
|
1364
|
+
puts "DEBUG: Server marked as ready" if ENV['DEBUG']
|
|
1365
|
+
|
|
1366
|
+
# Suppress Puma output only after initialization
|
|
1367
|
+
unless ENV['DEBUG']
|
|
1368
|
+
$stdout = StringIO.new
|
|
1369
|
+
$stderr = StringIO.new
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
puts "DEBUG: Starting puma_server.run..." if ENV['DEBUG']
|
|
1373
|
+
# Run server - this spawns threads but doesn't block
|
|
1374
|
+
puma_server.run
|
|
1375
|
+
puts "DEBUG: puma_server.run returned, server running: #{puma_server.running}" if ENV['DEBUG']
|
|
1376
|
+
|
|
1377
|
+
# Keep thread alive while server is running
|
|
1378
|
+
while puma_server.running
|
|
1379
|
+
sleep 0.1
|
|
1380
|
+
end
|
|
1381
|
+
puts "DEBUG: Server stopped running" if ENV['DEBUG']
|
|
1382
|
+
rescue => e
|
|
1383
|
+
# Store error for display in main thread
|
|
1384
|
+
server_container[:error] = e
|
|
1385
|
+
puts "Server thread error: #{e.class}: #{e.message}"
|
|
1386
|
+
puts e.backtrace.first(10).join("\n")
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
# Wait for server to start and verify it's responding
|
|
1391
|
+
sleep 1
|
|
1392
|
+
|
|
1393
|
+
# Check if server thread crashed
|
|
1394
|
+
unless server_thread.alive?
|
|
1395
|
+
$stdout = original_stdout
|
|
1396
|
+
$stderr = original_stderr
|
|
1397
|
+
if server_container[:error]
|
|
1398
|
+
puts "\n❌ Server failed to start:"
|
|
1399
|
+
puts " #{server_container[:error].class}: #{server_container[:error].message}"
|
|
1400
|
+
puts "\n Backtrace:"
|
|
1401
|
+
server_container[:error].backtrace.first(10).each do |line|
|
|
1402
|
+
puts " #{line}"
|
|
1403
|
+
end
|
|
1404
|
+
else
|
|
1405
|
+
puts "\n❌ Server failed to start. Check error messages above."
|
|
1406
|
+
end
|
|
1407
|
+
exit(1)
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# Try to ping the server to make sure it's responding
|
|
1411
|
+
begin
|
|
1412
|
+
require 'net/http'
|
|
1413
|
+
uri = URI(url)
|
|
1414
|
+
response = Net::HTTP.get_response(uri)
|
|
1415
|
+
unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
|
|
1416
|
+
puts "\n⚠️ Server started but returned unexpected response: #{response.code}"
|
|
1417
|
+
end
|
|
1418
|
+
rescue => e
|
|
1419
|
+
$stdout = original_stdout
|
|
1420
|
+
$stderr = original_stderr
|
|
1421
|
+
puts "\n❌ Server not responding: #{e.message}"
|
|
1422
|
+
puts " Make sure port #{port} is available and try again."
|
|
1423
|
+
if server_container[:server]
|
|
1424
|
+
server_container[:server].stop(true) rescue nil
|
|
1425
|
+
end
|
|
1426
|
+
server_thread.kill if server_thread.alive?
|
|
1427
|
+
exit(1)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
# Wait for result, timeout, or interrupt
|
|
1431
|
+
start_time = Time.now
|
|
1432
|
+
until result_container[:ready] || (Time.now - start_time > timeout) || interrupted
|
|
1433
|
+
sleep 0.1
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
# Shutdown server gracefully
|
|
1437
|
+
if server_container[:server]
|
|
1438
|
+
begin
|
|
1439
|
+
server_container[:server].stop(true) # true = graceful shutdown
|
|
1440
|
+
rescue => e
|
|
1441
|
+
# Ignore shutdown errors
|
|
1442
|
+
end
|
|
1443
|
+
end
|
|
1444
|
+
server_thread.kill if server_thread.alive?
|
|
1445
|
+
|
|
1446
|
+
# If interrupted, exit immediately
|
|
1447
|
+
if interrupted
|
|
1448
|
+
$stdout = original_stdout
|
|
1449
|
+
$stderr = original_stderr
|
|
1450
|
+
exit(0)
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
result = result_container[:result] || {}
|
|
1454
|
+
|
|
1455
|
+
# Ensure stdout is restored before outputting
|
|
1456
|
+
$stdout = original_stdout
|
|
1457
|
+
$stderr = original_stderr
|
|
1458
|
+
|
|
1459
|
+
# Output result
|
|
1460
|
+
if output_mode == :stdout
|
|
1461
|
+
puts JSON.generate(result)
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
if output_file
|
|
1465
|
+
File.write(output_file, JSON.generate(result))
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
result
|
|
1469
|
+
end
|
|
1470
|
+
end
|
|
1471
|
+
end
|