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.
Files changed (381) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +77 -0
  3. data/.beads/README.md +81 -0
  4. data/.beads/config.yaml +54 -0
  5. data/.beads/metadata.json +7 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +91 -0
  8. data/CHANGELOG.md +294 -0
  9. data/CLAUDE.md +67 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +565 -0
  12. data/Rakefile +13 -0
  13. data/assets/streamweaver-hero.jpg +0 -0
  14. data/bench/baselines/ledger.rb +58 -0
  15. data/bench/baselines/warroom.rb +51 -0
  16. data/bench/fixtures/ledger.rb +76 -0
  17. data/bench/fixtures/warroom.rb +60 -0
  18. data/bench/results/6daf8b8.md +102 -0
  19. data/bench/results/c8d749b.md +102 -0
  20. data/bench/results/eb06ab9.md +102 -0
  21. data/bench/run.rb +151 -0
  22. data/bench/support.rb +84 -0
  23. data/copy_this/dto_run_sample_run_7631.html +220 -0
  24. data/docs/SERVICE_MODE.md +302 -0
  25. data/docs/architecture/how_streamweaver_works.md +772 -0
  26. data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
  27. data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
  28. data/docs/canvas-ipc-session-summary.md +127 -0
  29. data/docs/canvas-panel-workflow.md +172 -0
  30. data/docs/canvas-read.md +147 -0
  31. data/docs/canvas-roadmap.md +275 -0
  32. data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
  33. data/docs/claude-code-companion-skill-spec.md +567 -0
  34. data/docs/components_reference.md +1223 -0
  35. data/docs/crud-patterns.md +265 -0
  36. data/docs/endpoints.md +122 -0
  37. data/docs/for_llms.md +1 -0
  38. data/docs/form-patterns.md +165 -0
  39. data/docs/frontend-only.md +169 -0
  40. data/docs/html-artifact-audit.md +238 -0
  41. data/docs/ideas/2025-01-01-as-a-service.md +203 -0
  42. data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
  43. data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
  44. data/docs/ideas/dhh-review-future-refactors.md +142 -0
  45. data/docs/opal-jamstack.md +47 -0
  46. data/docs/opal-npm-direction.md +184 -0
  47. data/docs/opal-spike-findings.md +194 -0
  48. data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
  49. data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
  50. data/docs/plans/2026-01-02-charts-design.md +288 -0
  51. data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
  52. data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
  53. data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
  54. data/docs/plans/canvas-claude-project-design.md +238 -0
  55. data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
  56. data/docs/plans/org-doc-preview-surfaces.md +293 -0
  57. data/docs/plans/shadcn-polish-plan.md +417 -0
  58. data/docs/porting-artifacts.md +252 -0
  59. data/docs/reference/agent-skills-comparison.md +369 -0
  60. data/docs/reference/travel-state-prd.artifact.html +780 -0
  61. data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
  62. data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
  63. data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
  64. data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
  65. data/docs/research/frontend-only-matrix.md +278 -0
  66. data/docs/research/streamweaver-way-spike-findings.md +529 -0
  67. data/docs/resource-dsl.md +342 -0
  68. data/docs/routing.md +302 -0
  69. data/docs/ruby-ui-comparison.md +319 -0
  70. data/docs/shared-dsl-fragments.md +356 -0
  71. data/docs/streamweaver-for-ai-agents.md +401 -0
  72. data/docs/streamweaver-frontend-vision.md +123 -0
  73. data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
  74. data/docs/streamweaver_canvas/save_example.rb +9 -0
  75. data/docs/templates.md +213 -0
  76. data/docs/testing.md +155 -0
  77. data/docs/theming-hooks.md +281 -0
  78. data/docs/tutorials/the-streamweaver-way.md +882 -0
  79. data/docs/university/capability-inventory.md +137 -0
  80. data/docs/university/dependency-survey.md +114 -0
  81. data/docs/university/design-spec.md +217 -0
  82. data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
  83. data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
  84. data/docs/university/mockups/course-list-dark.png +0 -0
  85. data/docs/university/mockups/course-list-light.png +0 -0
  86. data/docs/university/mockups/course_canvas_mockup.rb +741 -0
  87. data/docs/university/mockups/step-screen-dark.png +0 -0
  88. data/docs/university/mockups/step-screen-light.png +0 -0
  89. data/docs/university/roadmap.md +72 -0
  90. data/docs/university/send-to-coworker.md +77 -0
  91. data/docs/university/worker-session-mining.md +109 -0
  92. data/docs/visual/browser-rendering-spike.md +173 -0
  93. data/docs/visual/builder-visual-plan-analysis.md +378 -0
  94. data/docs/visual/sw-plan-format-exploration.md +511 -0
  95. data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
  96. data/docs/visual-skills/PROGRESS.md +60 -0
  97. data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
  98. data/docs/visual-skills/analysis/components.md +317 -0
  99. data/docs/visual-skills/analysis/overlap.md +360 -0
  100. data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
  101. data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
  102. data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
  103. data/docs/visual-skills/analysis/visual-explainer.md +964 -0
  104. data/docs/visual-skills/blog/blog-series-outline.md +39 -0
  105. data/docs/visual-skills/blog/token-efficiency.md +73 -0
  106. data/docs/visual-skills/design/architecture.md +1725 -0
  107. data/docs/visual-skills/design/codex-review.md +340 -0
  108. data/docs/visual-skills/design/dhh-review.md +209 -0
  109. data/docs/visual-skills/design/evolution.md +100 -0
  110. data/docs/visual-skills/design/gemini-review.md +55 -0
  111. data/docs/visual-skills/design/review-synthesis.md +149 -0
  112. data/docs/visual-skills/implementation/STATE.md +22 -0
  113. data/docs/visual-skills/implementation/plan.md +347 -0
  114. data/docs/visual-skills/implementation/spike-findings.md +237 -0
  115. data/docs/visual-skills/implementation/tasks.md +702 -0
  116. data/docs/visual-skills/lessons-learned/process.md +145 -0
  117. data/examples/README.md +96 -0
  118. data/examples/advanced/all_components.rb +177 -0
  119. data/examples/advanced/examples_browser.rb +394 -0
  120. data/examples/advanced/teachables_browser.rb +261 -0
  121. data/examples/advanced/theme_tweaker.rb +366 -0
  122. data/examples/advanced/tutorial.rb +1528 -0
  123. data/examples/agentic/agentic_form.rb +30 -0
  124. data/examples/agentic/agentic_form_autoclose.rb +30 -0
  125. data/examples/agentic/cultivation_tracker.rb +69 -0
  126. data/examples/basic/hello_world.rb +22 -0
  127. data/examples/basic/opal_tabs_table.rb +44 -0
  128. data/examples/basic/todo_list.rb +39 -0
  129. data/examples/button_loading_test.rb +36 -0
  130. data/examples/canvas/mermaid_canvas_demo.sh +151 -0
  131. data/examples/charts/bar_chart_demo.rb +57 -0
  132. data/examples/charts/line_chart_demo.rb +73 -0
  133. data/examples/charts/pie_area_demo.rb +87 -0
  134. data/examples/charts/stacked_bar_chart_demo.rb +69 -0
  135. data/examples/claude_code/README.md +85 -0
  136. data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
  137. data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
  138. data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
  139. data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
  140. data/examples/claude_code/codebreaker/README.md +213 -0
  141. data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
  142. data/examples/claude_code/tutorial/README.md +102 -0
  143. data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
  144. data/examples/claude_code/verification_flow/README.md +126 -0
  145. data/examples/components/annotated_code_demo.rb +54 -0
  146. data/examples/components/callout_demo.rb +33 -0
  147. data/examples/components/checkbox_group_demo.rb +46 -0
  148. data/examples/components/design_review.css +468 -0
  149. data/examples/components/design_review_demo.rb +44 -0
  150. data/examples/components/design_review_dsl.rb +300 -0
  151. data/examples/components/diff_block_demo.rb +112 -0
  152. data/examples/components/events_demo.rb +184 -0
  153. data/examples/components/form_demo.rb +62 -0
  154. data/examples/components/lesson_demo.rb +53 -0
  155. data/examples/components/markdown_demo.rb +110 -0
  156. data/examples/components/mermaid_demo.rb +166 -0
  157. data/examples/components/pareto_set.rb +64 -0
  158. data/examples/components/prd_demo.rb +21 -0
  159. data/examples/components/prd_dsl.rb +385 -0
  160. data/examples/components/quiz_demo.rb +47 -0
  161. data/examples/components/run_viewer_demo.rb +100 -0
  162. data/examples/components/score_and_collapsible_demo.rb +82 -0
  163. data/examples/components/select_stale_value_smoke_test.rb +39 -0
  164. data/examples/components/table_demo.rb +247 -0
  165. data/examples/components/timer_with_state_demo.rb +58 -0
  166. data/examples/components/todo_due_dates.rb +63 -0
  167. data/examples/components/uat_gaps_demo.rb +112 -0
  168. data/examples/dashboard/feed_simulator.rb +60 -0
  169. data/examples/dashboard/live_dashboard.rb +80 -0
  170. data/examples/dashboard_components.rb +108 -0
  171. data/examples/deferred_fragments_demo.rb +62 -0
  172. data/examples/generate_more_spike/README.md +63 -0
  173. data/examples/generate_more_spike/app.rb +441 -0
  174. data/examples/git_health.sh +401 -0
  175. data/examples/layout/layout_components_demo.rb +174 -0
  176. data/examples/layout/modal_demo.rb +215 -0
  177. data/examples/layout/navigation_demo.rb +227 -0
  178. data/examples/layout/route_tabs_demo.rb +52 -0
  179. data/examples/layout/routing_demo.rb +52 -0
  180. data/examples/layout/scroll_box_demo.rb +154 -0
  181. data/examples/lazy_fragments_demo.rb +151 -0
  182. data/examples/my_todos/README.md +29 -0
  183. data/examples/my_todos/my_todos.rb +312 -0
  184. data/examples/my_todos/store.rb +86 -0
  185. data/examples/opal/reactive_demo.rb +62 -0
  186. data/examples/opal/scenarios/s1_counter.rb +15 -0
  187. data/examples/opal/scenarios/s2_search_filter.rb +17 -0
  188. data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
  189. data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
  190. data/examples/opal/scenarios/s5_watch.rb +39 -0
  191. data/examples/opal/scenarios/s6_on_start.rb +20 -0
  192. data/examples/opal/scenarios/s7_wizard.rb +33 -0
  193. data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
  194. data/examples/opal/scenarios/s9_dashboard.rb +27 -0
  195. data/examples/operations_dashboard_demo.rb +167 -0
  196. data/examples/panel_demo.sh +447 -0
  197. data/examples/parity/assets/tyrion_slice.css +86 -0
  198. data/examples/parity/rivet_people_slice.rb +182 -0
  199. data/examples/parity/tyrion_components.css +777 -0
  200. data/examples/parity/tyrion_warroom_components.rb +414 -0
  201. data/examples/parity/tyrion_warroom_slice.rb +549 -0
  202. data/examples/puma_dev/README.md +124 -0
  203. data/examples/puma_dev/config.ru +34 -0
  204. data/examples/puma_dev/standalone_app.rb +34 -0
  205. data/examples/scaffolding/blog.rb +49 -0
  206. data/examples/scaffolding/utf_lite.rb +105 -0
  207. data/examples/styling/feedback_demo.rb +229 -0
  208. data/examples/styling/style_showcase.rb +172 -0
  209. data/examples/styling/theme_demo.rb +508 -0
  210. data/examples/timer_health_checker.rb +111 -0
  211. data/examples/timer_showcase.rb +131 -0
  212. data/examples/tutorials/render_markdown.rb +112 -0
  213. data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
  214. data/examples/tutorials/tutorial_content.rb +1082 -0
  215. data/examples/visual_skills/design_deck_demo.rb +314 -0
  216. data/examples/visual_skills/explainer_demo.rb +499 -0
  217. data/exe/streamweaver +9 -0
  218. data/gsd/ROADMAP-1.0.md +167 -0
  219. data/gsd/STATE.md +13 -0
  220. data/gsd/research/market-positioning-research.md +139 -0
  221. data/gsd/research/production-patterns-research.md +270 -0
  222. data/gsd/research/repo-audit-1.0.md +274 -0
  223. data/lib/stream_weaver/action_token.rb +58 -0
  224. data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
  225. data/lib/stream_weaver/adapter/base.rb +591 -0
  226. data/lib/stream_weaver/adapter/opal.rb +334 -0
  227. data/lib/stream_weaver/adapter/static.rb +1118 -0
  228. data/lib/stream_weaver/admin.rb +176 -0
  229. data/lib/stream_weaver/app.rb +1821 -0
  230. data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
  231. data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
  232. data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
  233. data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
  234. data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
  235. data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
  236. data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
  237. data/lib/stream_weaver/canvas/bridge.rb +239 -0
  238. data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
  239. data/lib/stream_weaver/canvas/client.rb +298 -0
  240. data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
  241. data/lib/stream_weaver/canvas/doc_store.rb +252 -0
  242. data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
  243. data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
  244. data/lib/stream_weaver/canvas/gist_store.rb +135 -0
  245. data/lib/stream_weaver/canvas/helpers.rb +109 -0
  246. data/lib/stream_weaver/canvas/history.rb +90 -0
  247. data/lib/stream_weaver/canvas/protocol.rb +86 -0
  248. data/lib/stream_weaver/canvas/reader.rb +853 -0
  249. data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
  250. data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
  251. data/lib/stream_weaver/canvas/session.rb +132 -0
  252. data/lib/stream_weaver/cli.rb +3235 -0
  253. data/lib/stream_weaver/component_assets.rb +70 -0
  254. data/lib/stream_weaver/component_registry.rb +67 -0
  255. data/lib/stream_weaver/component_renderer.rb +48 -0
  256. data/lib/stream_weaver/components/annotated_code.rb +53 -0
  257. data/lib/stream_weaver/components/api_endpoint.rb +42 -0
  258. data/lib/stream_weaver/components/callout.rb +59 -0
  259. data/lib/stream_weaver/components/chart.rb +84 -0
  260. data/lib/stream_weaver/components/code_block.rb +77 -0
  261. data/lib/stream_weaver/components/comparison.rb +39 -0
  262. data/lib/stream_weaver/components/decision.rb +38 -0
  263. data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
  264. data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
  265. data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
  266. data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
  267. data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
  268. data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
  269. data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
  270. data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
  271. data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
  272. data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
  273. data/lib/stream_weaver/components/diff_block.rb +125 -0
  274. data/lib/stream_weaver/components/doc_header.rb +57 -0
  275. data/lib/stream_weaver/components/image_block.rb +70 -0
  276. data/lib/stream_weaver/components/implementation_map.rb +33 -0
  277. data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
  278. data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
  279. data/lib/stream_weaver/components/mermaid.rb +79 -0
  280. data/lib/stream_weaver/components/pipeline.rb +63 -0
  281. data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
  282. data/lib/stream_weaver/components/slide_container.rb +141 -0
  283. data/lib/stream_weaver/components/timeline_event.rb +58 -0
  284. data/lib/stream_weaver/components/wireframe.rb +29 -0
  285. data/lib/stream_weaver/components/wireframe_block.rb +34 -0
  286. data/lib/stream_weaver/components.rb +2711 -0
  287. data/lib/stream_weaver/css.rb +230 -0
  288. data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
  289. data/lib/stream_weaver/display_dsl.rb +1004 -0
  290. data/lib/stream_weaver/export/html_exporter.rb +478 -0
  291. data/lib/stream_weaver/feed.rb +34 -0
  292. data/lib/stream_weaver/feed_builder.rb +31 -0
  293. data/lib/stream_weaver/fonts.rb +33 -0
  294. data/lib/stream_weaver/interaction_runner.rb +487 -0
  295. data/lib/stream_weaver/iterm.rb +460 -0
  296. data/lib/stream_weaver/layout_registry.rb +92 -0
  297. data/lib/stream_weaver/opal/bridge.rb +52 -0
  298. data/lib/stream_weaver/opal/builder.rb +193 -0
  299. data/lib/stream_weaver/opal/env.rb +29 -0
  300. data/lib/stream_weaver/opal/reactive_state.rb +75 -0
  301. data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
  302. data/lib/stream_weaver/opal/renderer.rb +77 -0
  303. data/lib/stream_weaver/opal/runtime.rb +250 -0
  304. data/lib/stream_weaver/opal/shell.rb +145 -0
  305. data/lib/stream_weaver/opal/string_bridge.rb +50 -0
  306. data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
  307. data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
  308. data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
  309. data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
  310. data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
  311. data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
  312. data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
  313. data/lib/stream_weaver/opal_entry.rb +136 -0
  314. data/lib/stream_weaver/org/inline.rb +95 -0
  315. data/lib/stream_weaver/org/reader.rb +561 -0
  316. data/lib/stream_weaver/org/recording_context.rb +86 -0
  317. data/lib/stream_weaver/org/source_splitter.rb +63 -0
  318. data/lib/stream_weaver/org/writer.rb +300 -0
  319. data/lib/stream_weaver/page_shell.rb +530 -0
  320. data/lib/stream_weaver/portfile.rb +79 -0
  321. data/lib/stream_weaver/pushable.rb +42 -0
  322. data/lib/stream_weaver/resource/default_views.rb +111 -0
  323. data/lib/stream_weaver/resource/field_input.rb +25 -0
  324. data/lib/stream_weaver/resource/state_keys.rb +16 -0
  325. data/lib/stream_weaver/resource/store.rb +18 -0
  326. data/lib/stream_weaver/resource.rb +104 -0
  327. data/lib/stream_weaver/server.rb +1471 -0
  328. data/lib/stream_weaver/service.rb +1240 -0
  329. data/lib/stream_weaver/service_client.rb +104 -0
  330. data/lib/stream_weaver/session_store.rb +186 -0
  331. data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
  332. data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
  333. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
  334. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
  335. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
  336. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
  337. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
  338. data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
  339. data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
  340. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
  341. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
  342. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
  343. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
  344. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
  345. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
  346. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
  347. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
  348. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
  349. data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
  350. data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
  351. data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
  352. data/lib/stream_weaver/streamer.rb +63 -0
  353. data/lib/stream_weaver/templates/choices.rb +149 -0
  354. data/lib/stream_weaver/templates/code.rb +157 -0
  355. data/lib/stream_weaver/templates/confirm.rb +110 -0
  356. data/lib/stream_weaver/templates/diff.rb +195 -0
  357. data/lib/stream_weaver/templates/info.rb +142 -0
  358. data/lib/stream_weaver/templates/table.rb +169 -0
  359. data/lib/stream_weaver/templates/wizard.rb +271 -0
  360. data/lib/stream_weaver/theme/auto_mode.rb +116 -0
  361. data/lib/stream_weaver/theme/presets.rb +555 -0
  362. data/lib/stream_weaver/theme.rb +639 -0
  363. data/lib/stream_weaver/university/canvas.rb +924 -0
  364. data/lib/stream_weaver/university/course.rb +699 -0
  365. data/lib/stream_weaver/university/demos/counter.rb +33 -0
  366. data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
  367. data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
  368. data/lib/stream_weaver/university/demos.rb +50 -0
  369. data/lib/stream_weaver/university/listener.rb +467 -0
  370. data/lib/stream_weaver/university/progress.rb +201 -0
  371. data/lib/stream_weaver/university/runner.rb +138 -0
  372. data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
  373. data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
  374. data/lib/stream_weaver/utils.rb +31 -0
  375. data/lib/stream_weaver/version.rb +5 -0
  376. data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
  377. data/lib/stream_weaver/views.rb +3940 -0
  378. data/lib/stream_weaver.rb +122 -0
  379. data/llms.txt +1269 -0
  380. data/sig/stream_weaver.rbs +4 -0
  381. 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