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,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require 'stream_weaver/canvas/client'
|
|
6
|
+
require 'stream_weaver/canvas/scroll_top_hint'
|
|
7
|
+
require 'stream_weaver/university/course'
|
|
8
|
+
require 'stream_weaver/university/progress'
|
|
9
|
+
require 'stream_weaver/university/runner'
|
|
10
|
+
require 'stream_weaver/university/scripts/growing_doc_state'
|
|
11
|
+
|
|
12
|
+
module StreamWeaver
|
|
13
|
+
module University
|
|
14
|
+
# Turns one canvas click event into a ledger write plus a re-push.
|
|
15
|
+
# Mark-done writes the ledger directly; Run/Repeat hands off to
|
|
16
|
+
# Runner, which sends the step's prompt to the recorded worker session
|
|
17
|
+
# (or records why it wouldn't), warms up that step's own demo canvas
|
|
18
|
+
# the moment (and only if) the send actually lands (see `.warm_up!`),
|
|
19
|
+
# and leaves the outcome in the ledger for the re-push to render.
|
|
20
|
+
#
|
|
21
|
+
# Button ids come from lib/stream_weaver/university/canvas.rb's `id:`
|
|
22
|
+
# scheme: "mark-done-N", "run-N" / "repeat-N", "hero-run-N" /
|
|
23
|
+
# "hero-repeat-N" on the course list; "view-N" (a row's Details/Hide
|
|
24
|
+
# button) toggles that row's inline expansion; "reset-course" (the
|
|
25
|
+
# recap screen and the course-list footer) clears the whole course.
|
|
26
|
+
# The rendered `id:` (e.g. "mark-done-3") becomes a
|
|
27
|
+
# `btn_<label-slug>_<id>` DOM/dispatch id (app.rb `button`), so every
|
|
28
|
+
# pattern below is anchored to the string's end, not its start.
|
|
29
|
+
module Listener
|
|
30
|
+
SESSION = 'university'
|
|
31
|
+
|
|
32
|
+
# How long run! waits before reconnecting to a bridge that went away.
|
|
33
|
+
RECONNECT_DELAY = 1
|
|
34
|
+
|
|
35
|
+
# name/theme pair for one step's own demo canvas session.
|
|
36
|
+
DemoSession = Struct.new(:name, :theme)
|
|
37
|
+
|
|
38
|
+
# Which of the course's own canvas demo sessions each step opens, and
|
|
39
|
+
# the theme its prompt creates that session with -- mined from
|
|
40
|
+
# course.rb's step prompts, and named in
|
|
41
|
+
# features/university-getting-started.context.md ("Course session
|
|
42
|
+
# names"). The theme matters here specifically because
|
|
43
|
+
# `Bridge#create_session` is `@sessions[name] ||= Session.new(...)`
|
|
44
|
+
# (canvas/bridge.rb): a session's theme is fixed by whichever `create`
|
|
45
|
+
# reaches the bridge FIRST, and every later `create` for that name --
|
|
46
|
+
# including the worker's own, e.g. `streamweaver panel doc-demo
|
|
47
|
+
# --theme=doc` (course.rb) or `growing_doc.rb`'s `create(...,
|
|
48
|
+
# theme: :doc)` -- is a no-op. `warm_up!` runs before any of that, so
|
|
49
|
+
# it has to create with the SAME theme the real demo will, not the
|
|
50
|
+
# bridge's `:default`, or the worker's own theme call would silently
|
|
51
|
+
# never take effect for the rest of that get-started run. Steps 2 and
|
|
52
|
+
# 5 never open a demo canvas of their own (a standalone `ruby app.rb`,
|
|
53
|
+
# and gists/org-export respectively), so neither appears here: a Run
|
|
54
|
+
# click on either has nothing to warm up, and "Reset course" has
|
|
55
|
+
# nothing of theirs to close.
|
|
56
|
+
STEP_DEMO_SESSIONS = {
|
|
57
|
+
1 => DemoSession.new('dashboard', :default),
|
|
58
|
+
3 => DemoSession.new('decision', :default),
|
|
59
|
+
4 => DemoSession.new('doc-demo', :doc)
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# "Reset course" closes exactly these, by name, and nothing else:
|
|
63
|
+
# never the controller session (SESSION, above) and never a session
|
|
64
|
+
# the user opened on their own that just happens to still exist.
|
|
65
|
+
# Derived from STEP_DEMO_SESSIONS so the two lists can never drift.
|
|
66
|
+
DEMO_SESSION_NAMES = STEP_DEMO_SESSIONS.values.map(&:name).freeze
|
|
67
|
+
|
|
68
|
+
# Applies one dispatched button token to the ledger. Returns the step
|
|
69
|
+
# number acted on, true for a whole-course action with no step of its
|
|
70
|
+
# own (reset-course), or nil if the token didn't match a known
|
|
71
|
+
# action.
|
|
72
|
+
def self.handle_token(token, progress)
|
|
73
|
+
case token.to_s
|
|
74
|
+
when /mark-done-(\d+)\z/
|
|
75
|
+
step = Regexp.last_match(1).to_i
|
|
76
|
+
mark_step_done!(progress, step)
|
|
77
|
+
step
|
|
78
|
+
when /(?:run|repeat)-(\d+)\z/ # also catches hero-run-N / hero-repeat-N
|
|
79
|
+
step = Regexp.last_match(1).to_i
|
|
80
|
+
# Dispatches to the worker session and records the outcome
|
|
81
|
+
# (including the refusals) in the ledger; the re-push in `step!`
|
|
82
|
+
# then renders whatever it wrote. The block runs once, only after
|
|
83
|
+
# Runner confirms the send actually landed -- warm_up! paints
|
|
84
|
+
# something instantly (Forrest's UAT round 5 clocked the worker's
|
|
85
|
+
# own first push at ~5 minutes) without ever promising "your
|
|
86
|
+
# agent is preparing" on a click that turned out refused or
|
|
87
|
+
# degraded (no recorded worker, a closed session, or the RPC
|
|
88
|
+
# itself failing) -- Runner is the one place that already knows
|
|
89
|
+
# which of those this click was.
|
|
90
|
+
Runner.run_step!(step, progress: progress) { warm_up!(step) }
|
|
91
|
+
step
|
|
92
|
+
when /view-(\d+)\z/
|
|
93
|
+
# Toggles: Details on the already-expanded row hides it again
|
|
94
|
+
# (Hide), Details on any other row expands that one instead --
|
|
95
|
+
# at most one row is ever expanded, so this is also what makes
|
|
96
|
+
# "expanding one collapses others" true.
|
|
97
|
+
step = Regexp.last_match(1).to_i
|
|
98
|
+
if progress.expanded_step == step
|
|
99
|
+
progress.collapse!
|
|
100
|
+
else
|
|
101
|
+
progress.expand_step!(step)
|
|
102
|
+
end
|
|
103
|
+
step
|
|
104
|
+
when /reset-course\z/
|
|
105
|
+
# Same effect as `streamweaver university-reset -y`: back up +
|
|
106
|
+
# clear the ledger, close the demo sessions the course itself
|
|
107
|
+
# opened. The trailing repush in `handle_event` below is what
|
|
108
|
+
# then renders the zero-state list -- this branch does not push.
|
|
109
|
+
progress.reset!
|
|
110
|
+
close_demo_sessions!
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The ledger write a "step is done" action makes: stamps `last_done`
|
|
116
|
+
# (rendered as an inline confirmation band) and collapses whichever
|
|
117
|
+
# row was expanded, so a stale expansion never sits open under the
|
|
118
|
+
# confirmation. Shared by handle_token's mark-done branch above
|
|
119
|
+
# (whose own caller, handle_event, does its own repush after every
|
|
120
|
+
# token) and `university_done!` below (which has no such caller and
|
|
121
|
+
# does its own repush) -- one write, two callers, so neither can drift
|
|
122
|
+
# from what "a step just got marked done" actually means.
|
|
123
|
+
def self.mark_step_done!(progress, step_number)
|
|
124
|
+
progress.mark_done!(step_number)
|
|
125
|
+
progress.collapse!
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# True when `progress`'s ledger flips from "not all done" to "all
|
|
129
|
+
# done" somewhere inside the block -- the exact moment canvas.rb's
|
|
130
|
+
# completion recap (`all_done`) first appears in the very next
|
|
131
|
+
# repush, regardless of which step's write did it or whether the
|
|
132
|
+
# steps finished in order. Shared by `handle_event` (every canvas
|
|
133
|
+
# click, including a manual Mark-done) and `university_done!` (the
|
|
134
|
+
# CLI door a worker's closing ritual runs) so BOTH doors ask the same
|
|
135
|
+
# question the same way -- round-9 UAT first wired the scroll-to-top
|
|
136
|
+
# decision off the step-NUMBER argument, scoped to the CLI door alone,
|
|
137
|
+
# and missed the manual-click path: clicking Mark done on the last
|
|
138
|
+
# step from partway down the page reproduces the exact "recap appears
|
|
139
|
+
# below where you're looking" bug this exists to fix (code review).
|
|
140
|
+
def self.course_just_completed?(progress)
|
|
141
|
+
was_complete = progress.done_count >= Course::TOTAL_STEPS
|
|
142
|
+
yield
|
|
143
|
+
!was_complete && progress.done_count >= Course::TOTAL_STEPS
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The terminal door onto exactly what a Mark-done click does, for
|
|
147
|
+
# `streamweaver university-done` (CLI): the same ledger write plus the
|
|
148
|
+
# same repush, with no button event in the loop. Round-8 UAT: every
|
|
149
|
+
# step's closing ritual used to hand the "click Mark done" click back
|
|
150
|
+
# to the user; asking a worker to run this instead removes that
|
|
151
|
+
# hand-off entirely -- the manual button still works exactly as
|
|
152
|
+
# before, this is just a second door onto the same effect.
|
|
153
|
+
def self.university_done!(step_number, session_name: SESSION)
|
|
154
|
+
progress = Progress.load
|
|
155
|
+
just_completed = course_just_completed?(progress) { mark_step_done!(progress, step_number) }
|
|
156
|
+
repush(session_name: session_name, scroll_top: just_completed)
|
|
157
|
+
step_number
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Applies one event to the ledger and re-pushes the app so the
|
|
161
|
+
# rendered page reflects the new state. The re-push is not optional
|
|
162
|
+
# bookkeeping: a click swaps the page for the canvas_continue spinner
|
|
163
|
+
# (see canvas.rb), and this push is what puts the real page back --
|
|
164
|
+
# and, for Mark-done specifically, is what makes the row visibly flip
|
|
165
|
+
# to done AND shows the "Step N done" confirmation, both read
|
|
166
|
+
# straight off the ledger this same push just wrote.
|
|
167
|
+
# Returns the button token handled, or nil if the event carried none.
|
|
168
|
+
def self.handle_event(event, session_name: SESSION)
|
|
169
|
+
token = event.dig(:data, :button)
|
|
170
|
+
return nil unless token
|
|
171
|
+
|
|
172
|
+
progress = Progress.load
|
|
173
|
+
just_completed = course_just_completed?(progress) { handle_token(token, progress) }
|
|
174
|
+
repush(session_name: session_name, scroll_top: just_completed)
|
|
175
|
+
token
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# "Reset course": closes DEMO_SESSION_NAMES, one `close` message each.
|
|
179
|
+
# Best-effort per session -- a session that was never opened (most
|
|
180
|
+
# courses never get all three names created) or an already-closed
|
|
181
|
+
# one just gets "Session not found" back, which is not a reason to
|
|
182
|
+
# skip the rest. Shared by the canvas's own Reset button
|
|
183
|
+
# (`handle_token`, above) and `streamweaver university-reset`.
|
|
184
|
+
def self.close_demo_sessions!
|
|
185
|
+
DEMO_SESSION_NAMES.each do |name|
|
|
186
|
+
begin
|
|
187
|
+
::StreamWeaver::Canvas::Client.send_message(
|
|
188
|
+
::StreamWeaver::Canvas::Protocol::Messages.close(name)
|
|
189
|
+
)
|
|
190
|
+
rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
# growing_doc.rb's own persisted --extend keys (round-7 UAT) --
|
|
194
|
+
# otherwise a reset course still remembers last run's picks the
|
|
195
|
+
# next time its script runs. A no-op (FileUtils.rm_f) for every
|
|
196
|
+
# name but doc-demo's, which never had state to begin with.
|
|
197
|
+
::StreamWeaver::University::Scripts::GrowingDocState.clear(name)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Pushes a deterministic, no-LLM placeholder card to step `step_number`'s
|
|
202
|
+
# own demo canvas session -- called by Runner.run_step!'s block, which
|
|
203
|
+
# only runs once a send has actually landed, so the pane paints
|
|
204
|
+
# something in well under a second instead of sitting empty for
|
|
205
|
+
# however long the worker takes to reach its own first push
|
|
206
|
+
# (Forrest's UAT round 5: ~5 minutes), and never on a refused or
|
|
207
|
+
# degraded send. Creates the session first if it doesn't exist yet
|
|
208
|
+
# (canvas `push` errors on a session it can't find), WITH the same
|
|
209
|
+
# theme the step's own demo uses (STEP_DEMO_SESSIONS) -- a session's
|
|
210
|
+
# theme is fixed by whichever `create` reaches the bridge first, so
|
|
211
|
+
# creating with the wrong one here would permanently strand the
|
|
212
|
+
# worker's own later `--theme=doc` as a no-op for the rest of this
|
|
213
|
+
# get-started run. The worker's own first real push against the same
|
|
214
|
+
# session name replaces this outright -- nothing here persists.
|
|
215
|
+
# No-op for a step with no demo session of its own (steps 2 and 5).
|
|
216
|
+
def self.warm_up!(step_number)
|
|
217
|
+
demo = STEP_DEMO_SESSIONS[step_number.to_i] or return
|
|
218
|
+
step = Course.step(step_number) or return
|
|
219
|
+
|
|
220
|
+
::StreamWeaver::Canvas::Client.send_message(
|
|
221
|
+
::StreamWeaver::Canvas::Protocol::Messages.create(demo.name, theme: demo.theme)
|
|
222
|
+
)
|
|
223
|
+
::StreamWeaver::Canvas::Client.send_message(
|
|
224
|
+
::StreamWeaver::Canvas::Protocol::Messages.push(demo.name, warm_up_dsl(step), source_dir: nil)
|
|
225
|
+
)
|
|
226
|
+
rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The warm-up card's own DSL source, instance_eval'd bridge-side --
|
|
231
|
+
# same "one string of Ruby, eval'd fresh" contract canvas.rb's own
|
|
232
|
+
# file header describes. Deliberately small: a title, one line of
|
|
233
|
+
# what's coming, a tasteful mini stat row plus a one-sentence
|
|
234
|
+
# callout, and the "preparing" line -- everything a terminal-only
|
|
235
|
+
# tool cannot show while a real worker is still thinking.
|
|
236
|
+
def self.warm_up_dsl(step)
|
|
237
|
+
<<~RUBY
|
|
238
|
+
use_theme :doc
|
|
239
|
+
use_layout :default
|
|
240
|
+
card(depth: :elevated) do
|
|
241
|
+
card_header { header2 #{step[:title].inspect} }
|
|
242
|
+
card_body do
|
|
243
|
+
phrase #{"Coming up: #{step[:payoff]}".inspect}
|
|
244
|
+
div(style: "display:flex; gap:14px; margin:16px 0;") do
|
|
245
|
+
stat_display value: #{step[:number].to_s.inspect}, label: "this step"
|
|
246
|
+
stat_display value: #{StreamWeaver::University::Course::GETTING_STARTED_STEPS.size.to_s.inspect}, label: "steps in the course"
|
|
247
|
+
end
|
|
248
|
+
callout "Your agent is preparing the live demo…", variant: :info
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
RUBY
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# An invisible marker element, appended to canvas.rb's own pushed text
|
|
255
|
+
# only when `scroll_top:` is true -- never part of the file itself.
|
|
256
|
+
# bridge_server.rb's polling script looks for this element's id in the
|
|
257
|
+
# HTML a poll just swapped in and, when present, scrolls the viewer to
|
|
258
|
+
# the TOP of the new page instead of running its ordinary "follow the
|
|
259
|
+
# viewer's own near-bottom scroll position" logic (built for a growing
|
|
260
|
+
# doc continuing in place, not a full page reshape -- round-9 UAT).
|
|
261
|
+
# Carries no ledger state and needs no cleanup: it only exists in the
|
|
262
|
+
# one push's HTML that asked for it, and the very next poll response
|
|
263
|
+
# replaces the whole DOM regardless, so "honored once" is true by
|
|
264
|
+
# construction rather than something this code has to enforce.
|
|
265
|
+
#
|
|
266
|
+
# The id itself is interpolated from Canvas::SCROLL_TOP_HINT_ID (a
|
|
267
|
+
# tiny standalone file both this module and BridgeServer require --
|
|
268
|
+
# see its own comment for why it isn't just defined on BridgeServer)
|
|
269
|
+
# rather than repeated here as a second literal -- two copies of the
|
|
270
|
+
# same string is exactly the drift this marker's own contract cannot
|
|
271
|
+
# survive.
|
|
272
|
+
SCROLL_TOP_HINT_DSL =
|
|
273
|
+
%(div(id: #{Canvas::SCROLL_TOP_HINT_ID.inspect}, style: "display:none") {})
|
|
274
|
+
|
|
275
|
+
def self.repush(session_name: SESSION, scroll_top: false)
|
|
276
|
+
canvas_path = File.expand_path('canvas.rb', __dir__)
|
|
277
|
+
dsl = File.read(canvas_path)
|
|
278
|
+
dsl = "#{dsl}\n\n#{SCROLL_TOP_HINT_DSL}" if scroll_top
|
|
279
|
+
::StreamWeaver::Canvas::Client.send_message(
|
|
280
|
+
::StreamWeaver::Canvas::Protocol::Messages.push(session_name, dsl, source_dir: nil)
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Blocks for exactly one canvas event, applies it, and re-pushes.
|
|
285
|
+
# Returns the button token handled, or nil on timeout / no bridge.
|
|
286
|
+
# Kept as the single-shot form for manual verification; `run!` uses
|
|
287
|
+
# the persistent stream instead.
|
|
288
|
+
def self.step!(session_name: SESSION, timeout: 300)
|
|
289
|
+
result = ::StreamWeaver::Canvas::Client.send_and_wait(
|
|
290
|
+
{ type: 'subscribe', name: session_name },
|
|
291
|
+
event_type: 'event',
|
|
292
|
+
timeout: timeout
|
|
293
|
+
)
|
|
294
|
+
return nil unless result
|
|
295
|
+
|
|
296
|
+
handle_event(result, session_name: session_name)
|
|
297
|
+
rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Runs until killed, over ONE held-open connection at a time. Not a
|
|
302
|
+
# loop of `step!`: that reconnects after every event, and the gap
|
|
303
|
+
# lands exactly where the user is most likely to click again -- while
|
|
304
|
+
# the re-push is in flight.
|
|
305
|
+
#
|
|
306
|
+
# The outer loop is not optional robustness. `each_event` returns when
|
|
307
|
+
# the bridge closes the socket, which a `canvas-restart` does
|
|
308
|
+
# routinely; without reconnecting, the listener would exit and the
|
|
309
|
+
# canvas would go permanently dead -- and worse than before this
|
|
310
|
+
# story, because the canvas_continue marker means a click with no
|
|
311
|
+
# re-push leaves "Working..." on screen forever and sets
|
|
312
|
+
# _swFeedbackActive, which makes the adapter swallow every later
|
|
313
|
+
# click. Same reason a not-yet-running bridge waits rather than
|
|
314
|
+
# exiting: `university-listener start` must not report a pid for a
|
|
315
|
+
# process that is already gone.
|
|
316
|
+
def self.run!(session_name: SESSION)
|
|
317
|
+
complained = false
|
|
318
|
+
|
|
319
|
+
loop do
|
|
320
|
+
begin
|
|
321
|
+
::StreamWeaver::Canvas::Client.each_event(session_name) do |event|
|
|
322
|
+
handle_event(event, session_name: session_name)
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
# One bad click must not take down a listener nobody can see.
|
|
325
|
+
warn "university-listener: #{e.class}: #{e.message}"
|
|
326
|
+
end
|
|
327
|
+
complained = false
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
# Deliberately every StandardError, not just the two bridge
|
|
330
|
+
# errors: this process must not die anywhere the user can't see
|
|
331
|
+
# it, and RECONNECT_DELAY below already rules out a hot spin, so
|
|
332
|
+
# there is no failure here from which exiting beats retrying.
|
|
333
|
+
#
|
|
334
|
+
# Logged once per outage, not once per second: the log is
|
|
335
|
+
# append-only with nothing rotating it, and a bridge left down
|
|
336
|
+
# overnight would otherwise put ~86k lines in a file the user
|
|
337
|
+
# doesn't know exists.
|
|
338
|
+
unless complained
|
|
339
|
+
warn "university-listener: waiting for the canvas bridge (#{e.class}: #{e.message})"
|
|
340
|
+
complained = true
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
sleep RECONNECT_DELAY
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# --- Process lifecycle ------------------------------------------------
|
|
349
|
+
# `get-started` starts this in the background; without it every button
|
|
350
|
+
# on the canvas does nothing at all (UAT 2026-08-29). The env
|
|
351
|
+
# overrides mirror Progress/Runner so specs never touch the real files.
|
|
352
|
+
|
|
353
|
+
def self.pid_path
|
|
354
|
+
ENV['STREAMWEAVER_UNIVERSITY_LISTENER_PID'] ||
|
|
355
|
+
File.expand_path('~/.streamweaver/university/listener.pid')
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def self.log_path
|
|
359
|
+
ENV['STREAMWEAVER_UNIVERSITY_LISTENER_LOG'] ||
|
|
360
|
+
File.expand_path('~/.streamweaver/university/listener.log')
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The pid recorded by `start!`, or nil if there isn't a usable one.
|
|
364
|
+
def self.recorded_pid
|
|
365
|
+
pid = File.read(pid_path).to_i
|
|
366
|
+
pid.positive? ? pid : nil
|
|
367
|
+
rescue SystemCallError, IOError
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def self.running?
|
|
372
|
+
pid = recorded_pid or return false
|
|
373
|
+
ours?(pid)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Alive AND plausibly the listener we spawned. `start!` uses
|
|
377
|
+
# `pgroup: true`, so our listener is its own process-group leader;
|
|
378
|
+
# a pid recycled onto some unrelated process almost never is. Without
|
|
379
|
+
# this check, a crash plus enough pid churn has `get-started` sending
|
|
380
|
+
# TERM to whatever the user happens to be running.
|
|
381
|
+
def self.ours?(pid)
|
|
382
|
+
Process.kill(0, pid)
|
|
383
|
+
Process.getpgid(pid) == pid
|
|
384
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
385
|
+
false
|
|
386
|
+
end
|
|
387
|
+
private_class_method :ours?
|
|
388
|
+
|
|
389
|
+
# How long start! waits for the listener it just TERMed to actually go.
|
|
390
|
+
EXIT_POLL = 0.05
|
|
391
|
+
EXIT_WAIT = 40 # * EXIT_POLL = 2s
|
|
392
|
+
|
|
393
|
+
# Stops any listener already recorded, waits for it to actually exit,
|
|
394
|
+
# then spawns a fresh detached one. Stopping first is what keeps a
|
|
395
|
+
# second `get-started` (or a canvas-restart) from leaving two
|
|
396
|
+
# listeners racing to re-push -- whichever push lands second wins, so
|
|
397
|
+
# the loser shows stale state. Returns the new pid.
|
|
398
|
+
def self.start!(session_name: SESSION)
|
|
399
|
+
previous = recorded_pid
|
|
400
|
+
stop!
|
|
401
|
+
await_exit(previous)
|
|
402
|
+
|
|
403
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
404
|
+
FileUtils.mkdir_p(File.dirname(pid_path))
|
|
405
|
+
|
|
406
|
+
pid = Process.spawn(
|
|
407
|
+
RbConfig.ruby,
|
|
408
|
+
"-I#{File.expand_path('../..', __dir__)}",
|
|
409
|
+
'-r', 'stream_weaver/university/listener',
|
|
410
|
+
'-e', "StreamWeaver::University::Listener.run!(session_name: #{session_name.inspect})",
|
|
411
|
+
out: [log_path, 'a'], err: %i[child out], pgroup: true
|
|
412
|
+
)
|
|
413
|
+
Process.detach(pid)
|
|
414
|
+
File.write(pid_path, "#{pid}\n")
|
|
415
|
+
pid
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# TERM is asynchronous, so "stopped" is a request, not a fact. Spawning
|
|
419
|
+
# the replacement while the old one still holds its connection gives
|
|
420
|
+
# two listeners racing to re-push, and whichever push lands second
|
|
421
|
+
# wins -- so the canvas can settle on state the user did not ask for.
|
|
422
|
+
def self.await_exit(pid)
|
|
423
|
+
return unless pid
|
|
424
|
+
|
|
425
|
+
EXIT_WAIT.times do
|
|
426
|
+
return unless ours?(pid)
|
|
427
|
+
|
|
428
|
+
sleep EXIT_POLL
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Falling through here is the very race this method exists to
|
|
432
|
+
# prevent, so escalate rather than spawn a rival quietly.
|
|
433
|
+
warn "university-listener: pid #{pid} ignored TERM, sending KILL"
|
|
434
|
+
Process.kill('KILL', pid)
|
|
435
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
436
|
+
nil
|
|
437
|
+
end
|
|
438
|
+
private_class_method :await_exit
|
|
439
|
+
|
|
440
|
+
# Terminates the recorded listener. Always clears the pid file, even
|
|
441
|
+
# when the process was already gone -- a stale pid file left by a
|
|
442
|
+
# crash must not look like a running listener forever.
|
|
443
|
+
def self.stop!
|
|
444
|
+
pid = recorded_pid
|
|
445
|
+
return false unless pid && ours?(pid)
|
|
446
|
+
|
|
447
|
+
Process.kill('TERM', pid)
|
|
448
|
+
true
|
|
449
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
450
|
+
false
|
|
451
|
+
ensure
|
|
452
|
+
FileUtils.rm_f(pid_path)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def self.status
|
|
456
|
+
alive = running?
|
|
457
|
+
{ running: alive, pid: alive ? recorded_pid : nil, log: log_path }
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if __FILE__ == $PROGRAM_NAME
|
|
464
|
+
$LOAD_PATH.unshift(File.expand_path('../..', __dir__))
|
|
465
|
+
require 'stream_weaver'
|
|
466
|
+
StreamWeaver::University::Listener.run!
|
|
467
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module StreamWeaver
|
|
8
|
+
module University
|
|
9
|
+
# Getting Started's per-step completion ledger, persisted as YAML at
|
|
10
|
+
# `~/.streamweaver/university/progress.yml`. A plain file, not in-memory
|
|
11
|
+
# session state, so "survives a bridge restart" just means every write
|
|
12
|
+
# hits disk immediately and a later re-push re-reads it (progress-ledger
|
|
13
|
+
# criterion 5).
|
|
14
|
+
#
|
|
15
|
+
# STREAMWEAVER_UNIVERSITY_PROGRESS overrides the path -- used by specs
|
|
16
|
+
# (never touch the developer's real ledger) and by anyone running a
|
|
17
|
+
# second, isolated University instance.
|
|
18
|
+
class Progress
|
|
19
|
+
DEFAULT_PATH = '~/.streamweaver/university/progress.yml'
|
|
20
|
+
|
|
21
|
+
# Expanded per call, not at the constant, so a spec that redirects
|
|
22
|
+
# HOME is redirected here too -- and so this mirrors
|
|
23
|
+
# University::Runner.worker_path exactly, as its comment claims.
|
|
24
|
+
def self.path
|
|
25
|
+
ENV['STREAMWEAVER_UNIVERSITY_PROGRESS'] || File.expand_path(DEFAULT_PATH)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Loads the ledger at the current path (honoring the env override at
|
|
29
|
+
# call time, not at class-load time).
|
|
30
|
+
def self.load
|
|
31
|
+
new(path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(path = self.class.path)
|
|
35
|
+
@path = path
|
|
36
|
+
@data = read
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :path
|
|
40
|
+
|
|
41
|
+
def done?(step_number)
|
|
42
|
+
!!@data['done'][step_number.to_s]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Clears `last_run` along the way: that field reports what the user's
|
|
46
|
+
# last click did, and marking a step done IS a later click. Without
|
|
47
|
+
# this, a run notice (in degraded mode, a whole copy-this-prompt
|
|
48
|
+
# block for step 1) stays pinned above the step list for days.
|
|
49
|
+
#
|
|
50
|
+
# Stamps `last_done` for the SAME reason `record_run!` stamps
|
|
51
|
+
# `last_run`: the next canvas render needs to say what a click just
|
|
52
|
+
# did, and it needs to say so reliably. An earlier version of this
|
|
53
|
+
# feedback used a bridge toast instead -- broken by construction,
|
|
54
|
+
# because the toast and this same write's re-push land in the same
|
|
55
|
+
# ~500ms poll response, and the client unconditionally clears any
|
|
56
|
+
# toast the instant new HTML arrives (bridge_server.rb's poll()).
|
|
57
|
+
# Putting the message IN the re-pushed HTML has no such race.
|
|
58
|
+
def mark_done!(step_number)
|
|
59
|
+
@data['done'][step_number.to_s] = true
|
|
60
|
+
@data['last_run'] = nil
|
|
61
|
+
@data['last_done'] = { 'step' => step_number.to_i, 'at' => Time.now.utc.iso8601 }
|
|
62
|
+
write
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Undoes a mark-done -- not exercised by the current UI, but the
|
|
67
|
+
# natural inverse and cheap to keep correct for tests/future use.
|
|
68
|
+
# Clears `last_run`/`last_done` for the same reason mark_done! sets
|
|
69
|
+
# them: this instance no longer reflects what either field claims.
|
|
70
|
+
def unmark_done!(step_number)
|
|
71
|
+
@data['done'].delete(step_number.to_s)
|
|
72
|
+
@data['last_run'] = nil
|
|
73
|
+
@data['last_done'] = nil
|
|
74
|
+
write
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def done_steps
|
|
79
|
+
@data['done'].select { |_k, v| v }.keys.map(&:to_i).sort
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def done_count
|
|
83
|
+
done_steps.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Records the outcome of one Run/Repeat click (Runner#run_step!).
|
|
87
|
+
#
|
|
88
|
+
# `requested` only gains a timestamp when the prompt actually reached
|
|
89
|
+
# the worker session -- a click that found no worker, or a closed
|
|
90
|
+
# tab, is not a send, and a `requested_at` for it would be a lie the
|
|
91
|
+
# ledger tells forever. `last_run` always updates: it is what the
|
|
92
|
+
# next canvas render reads to report what happened, including the
|
|
93
|
+
# failures that need a copy-the-prompt fallback.
|
|
94
|
+
def record_run!(step_number, status:)
|
|
95
|
+
now = Time.now.utc.iso8601
|
|
96
|
+
@data['requested'][step_number.to_s] = now if status.to_s == 'sent'
|
|
97
|
+
@data['last_run'] = { 'step' => step_number.to_i, 'status' => status.to_s, 'at' => now }
|
|
98
|
+
@data['last_done'] = nil
|
|
99
|
+
write
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def requested_at(step_number)
|
|
104
|
+
@data['requested'][step_number.to_s]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The last Run/Repeat outcome: {'step' =>, 'status' =>, 'at' =>}, or
|
|
108
|
+
# nil before the first click.
|
|
109
|
+
def last_run
|
|
110
|
+
@data['last_run']
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The step a Mark-done click just finished: {'step' =>, 'at' =>}, or
|
|
114
|
+
# nil. Mutually exclusive with `last_run` -- each write clears the
|
|
115
|
+
# other, so the canvas only ever has one "what just happened" band to
|
|
116
|
+
# show, whichever action was more recent.
|
|
117
|
+
def last_done
|
|
118
|
+
@data['last_done']
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Which step's row is expanded inline on the course list, or nil if
|
|
122
|
+
# none is. canvas-push instance_evals canvas.rb fresh on every render
|
|
123
|
+
# (no in-memory app state survives between pushes -- see canvas.rb's
|
|
124
|
+
# file header), so which row (if any) renders expanded has to live
|
|
125
|
+
# somewhere that does survive: this ledger, same as done/last_run.
|
|
126
|
+
# Also what makes a deep link work -- whoever set this before the
|
|
127
|
+
# canvas was last (re)pushed gets that row auto-expanded on load,
|
|
128
|
+
# with no separate navigation step to get there.
|
|
129
|
+
#
|
|
130
|
+
# Still the `'viewing'` key on disk -- deliberately not renamed to
|
|
131
|
+
# `'expanded'` alongside this method (single-mode, 2026-09-03): only
|
|
132
|
+
# the meaning of "which row" changed (a screen to navigate to versus
|
|
133
|
+
# a row to expand in place), not what's being tracked, and a silent
|
|
134
|
+
# key rename would orphan whatever any already-running canvas last
|
|
135
|
+
# wrote. Renaming the key belongs to a real migration, not this diff.
|
|
136
|
+
def expanded_step
|
|
137
|
+
@data['viewing']
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Expands step `step_number`'s row inline on the next render --
|
|
141
|
+
# "Details" on a step row. At most one step is ever expanded: this
|
|
142
|
+
# simply overwrites whichever was expanded before, which is what
|
|
143
|
+
# makes "expanding one collapses others" true by construction.
|
|
144
|
+
def expand_step!(step_number)
|
|
145
|
+
@data['viewing'] = step_number.to_i
|
|
146
|
+
write
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Collapses whichever row is expanded -- "Hide" on an expanded row's
|
|
151
|
+
# own Details button, and what mark_done! calls so a Mark-done click
|
|
152
|
+
# never leaves a stale expansion open under the confirmation band.
|
|
153
|
+
def collapse!
|
|
154
|
+
@data['viewing'] = nil
|
|
155
|
+
write
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# "Reset course": backs up whatever was on disk to `<path>.bak`
|
|
160
|
+
# (overwriting any earlier backup -- one reset's worth of undo, not a
|
|
161
|
+
# history) and returns to the zero-state. Deletes rather than
|
|
162
|
+
# rewrites the file, so this in-memory instance and a freshly loaded
|
|
163
|
+
# one agree the same way every other zero-state case already does
|
|
164
|
+
# (`#read`, below, and the "does not create the file just by reading
|
|
165
|
+
# it" contract) -- there is exactly one representation of "nothing
|
|
166
|
+
# done yet", not two that both mean it.
|
|
167
|
+
def reset!
|
|
168
|
+
FileUtils.cp(@path, "#{@path}.bak") if File.exist?(@path)
|
|
169
|
+
FileUtils.rm_f(@path)
|
|
170
|
+
@data = blank_data
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def read
|
|
177
|
+
return blank_data unless File.exist?(@path)
|
|
178
|
+
|
|
179
|
+
loaded = YAML.safe_load(File.read(@path)) || {}
|
|
180
|
+
{
|
|
181
|
+
'done' => loaded['done'] || {},
|
|
182
|
+
'requested' => loaded['requested'] || {},
|
|
183
|
+
'last_run' => loaded['last_run'],
|
|
184
|
+
'last_done' => loaded['last_done'],
|
|
185
|
+
'viewing' => loaded['viewing']
|
|
186
|
+
}
|
|
187
|
+
rescue Psych::SyntaxError
|
|
188
|
+
blank_data
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def blank_data
|
|
192
|
+
{ 'done' => {}, 'requested' => {}, 'last_run' => nil, 'last_done' => nil, 'viewing' => nil }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def write
|
|
196
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
197
|
+
File.write(@path, YAML.dump(@data))
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|