feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo)
Shell con paridad zsh/fish, multiplexado y sesiones remotas nativas, en chasis Llimphi de 4 slots. Front-door limpio: solo crates shuma-*/matilda-*; Llimphi y lo fundacional (chasqui/minga discovery, pata-host, arje, hojas shared) por git-dep del monorepo gioser.git — cero vendoring. shuma-discern se trae del monorepo (lo necesita chasqui, evita doble-fuente). cargo check pasa (0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico · 1683
02_ruway/shuma/baremetal/matilda-app/src/main.rs
- nueva struct Cli
- nueva enum Cmd
- nueva fn apply_remote
- nueva fn current_inventory
- nueva fn example_inventory
- nueva fn load
- nueva fn main
- nueva fn print_report
- nueva fn run
02_ruway/shuma/baremetal/matilda-apply/src/lib.rs
- nueva struct ApplyStep pub
- nueva struct FileWrite pub
- nueva def NGINX_SITES
- nueva fn desired
- nueva fn empty_plan_yields_no_steps
- nueva fn fresh_inventory_produces_create_steps
- nueva fn host_actions_produce_no_steps
- nueva fn plan_to_steps pub
- nueva fn removal_steps_clean_up
- nueva fn script_emits_heredocs_and_commands
- nueva fn steps_to_script pub
- nueva mod tests
- nueva fn update_recreates_the_container
- nueva fn vhost_path
02_ruway/shuma/baremetal/matilda-config/src/docker.rs
- nueva fn compose_service pub
- nueva fn compose_service_indents_under_services
- nueva fn docker_run_command pub
- nueva fn minimal_container_omits_empty_sections
- nueva fn run_command_has_all_flags
- nueva fn run_command_is_deterministic
- nueva fn sample
- nueva mod tests
02_ruway/shuma/baremetal/matilda-config/src/lib.rs
- nueva fn compose_file pub
- nueva fn compose_file_lists_every_container
- nueva mod docker pub
- nueva mod nginx pub
- nueva fn nginx_sites pub
- nueva fn nginx_sites_renders_every_vhost
- nueva mod tests
02_ruway/shuma/baremetal/matilda-config/src/nginx.rs
- nueva fn aliases_join_the_server_name
- nueva fn nginx_server_block pub
- nueva fn plain_vhost_listens_on_80
- nueva fn proxy_target
- nueva fn render_is_deterministic
- nueva mod tests
- nueva fn tls_vhost_adds_443_and_redirect
02_ruway/shuma/baremetal/matilda-core/src/container.rs
- nueva struct Container pub
- nueva struct PortMap pub
- nueva enum RestartPolicy pub
- nueva fn docker_flag pub
- nueva fn env_order_does_not_affect_equality
- nueva fn new pub
- nueva fn new pub
- nueva fn restart_flags_match_docker
- nueva mod tests
- nueva fn with_env pub
- nueva fn with_env_overwrites_same_key
- nueva fn with_port pub
- nueva fn with_restart pub
- nueva fn with_volume pub
02_ruway/shuma/baremetal/matilda-core/src/host.rs
02_ruway/shuma/baremetal/matilda-core/src/inventory.rs
- nueva struct Inventory pub
- nueva fn add_and_query_each_kind
- nueva fn add_container pub
- nueva fn add_host pub
- nueva fn add_vhost pub
- nueva fn broken_vhosts pub
- nueva fn broken_vhosts_point_to_missing_containers
- nueva fn container pub
- nueva fn containers pub
- nueva fn host pub
- nueva fn hosts pub
- nueva fn is_empty pub
- nueva fn iteration_is_ordered_by_name
- nueva fn new pub
- nueva mod tests
- nueva fn vhost pub
- nueva fn vhost_with_present_container_is_not_broken
- nueva fn vhosts pub
02_ruway/shuma/baremetal/matilda-core/src/lib.rs
02_ruway/shuma/baremetal/matilda-core/src/vhost.rs
- nueva enum Upstream pub
- nueva struct VHost pub
- nueva fn address_upstream_has_no_container_dependency
- nueva fn container_upstream_reports_its_dependency
- nueva fn depends_on_container pub
- nueva mod tests
- nueva fn to_address pub
- nueva fn to_container pub
- nueva fn with_alias pub
- nueva fn with_tls pub
02_ruway/shuma/baremetal/matilda-discover/src/lib.rs
- nueva struct DockerConfig
- nueva struct DockerHostConfig
- nueva struct DockerInspect
- nueva struct DockerRestart
- nueva def INSPECT_WEB
- nueva struct PortBinding
- nueva struct ServerState pub
- nueva fn container_drift pub
- nueva fn create_and_remove_together
- nueva fn discover_inventory pub
- nueva fn discover_local pub
- nueva fn drift_when_a_declared_env_is_missing
- nueva fn drift_when_a_declared_port_is_missing
- nueva fn drift_when_image_changed
- nueva fn drift_when_restart_policy_differs
- nueva fn missing_desired_resource_becomes_a_creation
- nueva fn no_drift_when_running_matches_the_spec
- nueva fn observed_inventory pub
- nueva fn observed_orphan_becomes_a_removal
- nueva fn observed_present_and_desired_diffs_clean
- nueva fn parse_docker_names pub
- nueva fn parse_nginx_sites pub
- nueva fn parses_docker_names
- nueva fn parses_nginx_sites_stripping_conf
- nueva fn run_local
- nueva mod tests
- nueva fn unreadable_json_is_not_treated_as_drift
- nueva fn web_spec
02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs
- nueva struct ApplyReport pub
- nueva struct StepResult pub
- nueva fn all_ok pub
- nueva fn applied pub
- nueva fn apply pub
- nueva fn apply_step
- nueva fn apply_stops_at_the_first_failure
- nueva fn apply_writes_files_and_runs_commands
- nueva fn dry_run pub
- nueva fn dry_run_touches_nothing
- nueva fn empty_plan_applies_cleanly
- nueva fn failed pub
- nueva fn nonzero_exit_marks_the_step_failed
- nueva fn run_command
- nueva fn step
- nueva fn temp
- nueva mod tests
02_ruway/shuma/baremetal/matilda-linker/src/lib.rs
- nueva def HEREDOC
- nueva struct Linker pub
- nueva fn apply pub
- nueva fn apply_step
- nueva fn connect pub
- nueva fn exec pub
- nueva fn file_write_command
- nueva fn file_write_command_uses_a_heredoc
- nueva fn ssh_config_is_re_exported
- nueva mod tests
02_ruway/shuma/baremetal/matilda-plan/src/lib.rs
- nueva struct Action pub
- nueva enum Op pub
- nueva struct Plan pub
- nueva enum Resource pub
- nueva fn changed_image_is_an_update
- nueva fn container_creation_precedes_vhost_creation
- nueva fn count pub
- nueva fn describe pub
- nueva fn describe_is_human_readable
- nueva fn dropped_resources_become_removes
- nueva fn empty_to_empty_is_a_noop
- nueva fn fresh_inventory_is_all_creates
- nueva fn is_empty pub
- nueva fn label
- nueva fn len pub
- nueva fn new
- nueva fn plan pub
- nueva fn plan_is_deterministic
- nueva mod tests
- nueva fn unchanged_inventory_yields_no_actions
- nueva fn verb
- nueva fn vhost_removal_precedes_container_removal
02_ruway/shuma/sandbox/shuma-card/src/lib.rs
- nueva struct CommandRef pub
- nueva enum CompileError pub
- nueva struct DiscernPolicy pub
- nueva enum ExitPolicy pub
- nueva enum FlowDirection pub
- nueva struct FlowEdge pub
- nueva enum FlowScope pub
- nueva struct FlowSlot pub
- nueva enum LoadError pub
- nueva struct PipelineId pub
- nueva struct PipelineSpec pub
- nueva enum QuotaAction pub
- nueva struct QuotaEnforcement pub
- nueva struct WorkspaceId pub
- nueva struct WorkspaceSpec pub
- nueva fn default
- nueva fn default
- nueva fn default
- nueva fn default_oneshot
- nueva fn default_replay_chunks
- nueva fn default_restart_backoff
- nueva fn default_restart_max_backoff
- nueva fn default_sample_bytes
- nueva fn default_true
- nueva fn deserialize pub
- nueva fn empty_label_rejected
- nueva fn fmt
- nueva fn fmt
- nueva fn intersect_soma
- nueva fn intersect_soma_takes_more_restrictive
- nueva fn load_pipeline_spec pub
- nueva fn load_workspace_spec pub
- nueva fn min_opt
- nueva fn new pub
- nueva fn new pub
- nueva mod opt_duration_millis
- nueva fn pipeline_validates_edges
- nueva fn sample_workspace
- nueva fn serialize pub
- nueva fn shell_intent pub
- nueva fn shell_intent_is_native_and_inherits_workspace_soma
- nueva fn subst_str
- nueva mod subst_tests
- nueva fn substitute_in_argv_and_label
- nueva fn substitute_vars pub
- nueva mod tests
- nueva fn to_card pub
- nueva fn to_card pub
- nueva fn to_intent pub
- nueva fn unknown_var_left_intact
- nueva fn validate pub
- nueva fn walk_subst
- nueva fn workspace_compiles_to_card
- nueva fn workspace_compiles_to_intent_virtual
- nueva fn workspace_json_roundtrip
- nueva fn workspace_shell_runs_isolated_via_sandokan_engine
- nueva fn workspace_toml_roundtrip
02_ruway/shuma/sandbox/shuma-config/src/lib.rs
- nueva struct CaptureConfig pub
- nueva struct CommandCompletion pub
- nueva struct Config pub
- nueva enum ConfigError pub
- nueva enum DedupPolicy pub
- nueva struct HistoryConfig pub
- nueva struct PromptConfig pub
- nueva fn alias_does_not_recurse
- nueva fn alias_expansion_replaces_first_word_only
- nueva fn alias_preserves_leading_whitespace
- nueva fn apply_env pub
- nueva fn completion_loads_all_in_dir
- nueva fn completion_loads_per_command_file
- nueva fn completions_dir pub
- nueva fn corrupt_completion_file_is_skipped
- nueva fn default
- nueva fn default
- nueva fn default_limit_mb
- nueva fn default_path pub
- nueva fn default_segments
- nueva fn expand_aliases pub
- nueva fn expand_env pub
- nueva fn expand_env_keeps_dollar_alone
- nueva fn expand_env_substitutes_vars
- nueva fn fmt
- nueva fn from
- nueva fn load pub
- nueva fn load pub
- nueva fn load_all pub
- nueva fn load_default pub
- nueva fn missing_file_yields_default
- nueva fn parses_a_full_example
- nueva fn partial_toml_falls_back_to_defaults
- nueva mod tests
02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs
- nueva struct AbortOnDrop
- nueva def BROADCAST_CAP
- nueva def DEFAULT_REPLAY_CHUNKS pub
- nueva struct FlowChannel pub
- nueva struct FlowMeter pub
- nueva struct FlowSender pub
- nueva def RATE_WINDOW_SAMPLES
- nueva struct ReplayCaps pub
- nueva fn bytes_per_sec pub
- nueva fn channel_delivers_to_subscriber
- nueva fn chunks_only pub
- nueva fn default_flow_socket_path pub
- nueva fn drop
- nueva fn drop
- nueva fn drop_removes_socket
- nueva fn evict_for_incoming
- nueva fn fmt
- nueva fn meter pub
- nueva fn new
- nueva fn new pub
- nueva fn new pub
- nueva fn record
- nueva fn replay_buffer_serves_late_subscriber
- nueva fn replay_evicts_by_bytes_cap
- nueva fn send pub
- nueva fn send pub
- nueva fn sender_handle pub
- nueva fn socket_path pub
- nueva fn subscriber_count pub
- nueva mod tests
- nueva fn total_bytes pub
- nueva fn with_replay_cap pub
- nueva fn with_replay_caps pub
02_ruway/shuma/sandbox/shuma-core/src/lib.rs
- nueva struct CommandInfo pub
- nueva struct CommandState pub
- nueva struct CommandSummary pub
- nueva enum CoreError pub
- nueva struct HealthCounts pub
- nueva struct Inner
- nueva enum LogStream pub
- nueva trait OwnedFdFromRawCompat
- nueva struct PipelineSupervisor pub
- nueva struct RestartSpec
- nueva def STATS_HISTORY_CAP
- nueva struct WorkspaceManager pub
- nueva struct WorkspaceSnapshot pub
- nueva struct WorkspaceState pub
- nueva mod flow_channel pub
- nueva fn from_raw_fd_compat
- nueva mod logbuf pub
- nueva mod persist pub
- nueva mod pipeline pub
- nueva mod pipelines
- nueva fn read_proc_rss
- nueva mod runtime
- nueva fn short_ulid
- nueva fn spawn_log_drainer
- nueva mod stats pub
- nueva mod tests
- nueva mod workspaces
02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs
- nueva struct Inner
- nueva struct LogBuf pub
- nueva def MAX_LOG_BYTES pub
- nueva fn append pub
- nueva fn append_and_tail_basic
- nueva fn cap_drops_oldest
- nueva fn default
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn new pub
- nueva fn tail pub
- nueva mod tests
- nueva fn with_cap pub
- nueva fn written_total pub
- nueva fn written_total_tracks_all
02_ruway/shuma/sandbox/shuma-core/src/persist.rs
- nueva struct LivePipelineEntry pub
- nueva def PERSIST_STATS_CAP
- nueva struct PersistedStats pub
- nueva struct PipelineEntry pub
- nueva struct RestoreOutcome pub
- nueva def SNAPSHOT_VERSION pub
- nueva struct ShipoteSnapshot pub
- nueva struct WorkspaceEntry pub
- nueva fn default_path_ends_with_state_json
- nueva fn default_snapshot_path pub
- nueva fn from
- nueva fn now_ms
- nueva fn read pub
- nueva fn restore_snapshot pub
- nueva fn roundtrip_snapshot_preserves_ulids
- nueva fn sample_ws
- nueva fn save_snapshot pub
- nueva fn save_snapshot_skips_when_clean
- nueva fn snapshot pub
- nueva fn snapshot_includes_saved_pipelines
- nueva mod tests
- nueva fn write pub
02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs
- nueva struct EdgeDiscernment pub
- nueva struct EdgeMeta
- nueva struct MergerSpec
- nueva trait OwnedFdFromRawCompat
- nueva struct PipelineLaunch pub
- nueva struct SplitterHandle
- nueva struct SplitterSpec
- nueva struct TokenBucket
- nueva fn _keep_raw
- nueva fn async_read
- nueva fn async_write_all
- nueva fn broadcast_chunk
- nueva fn cmd
- nueva fn from_raw_fd_compat
- nueva fn new
- nueva fn pipeline_fanin_two_to_one
- nueva fn pipeline_fanout_one_to_two
- nueva fn pipeline_isolated_echo_to_cat_runs
- nueva fn pipeline_isolated_with_tap_captures_discernment
- nueva fn reserve
- nueva fn run_pipeline pub
- nueva fn set_nonblocking
- nueva fn short_ulid
- nueva fn spawn_merger
- nueva fn spawn_splitter
- nueva mod tests
02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs
- nueva fn drop_pipeline_flows pub
- nueva fn drop_saved_pipeline pub
- nueva fn flow_throughput pub
- nueva fn get_saved_pipeline pub
- nueva fn health_counts pub
- nueva fn incarnator pub
- nueva fn incarnator_handle pub
- nueva fn is_dirty pub
- nueva fn list_flow_pipelines pub
- nueva fn list_saved_pipelines pub
- nueva def mark_dirty
- nueva fn new pub
- nueva fn register_pipeline_commands pub
- nueva fn register_pipeline_supervisor pub
- nueva fn register_pipeline_supervisor_with_state pub
- nueva fn retain_pipeline_flows pub
- nueva fn save_pipeline pub
- nueva fn stop_pipeline pub
- nueva fn take_pending_restarts pub
02_ruway/shuma/sandbox/shuma-core/src/runtime.rs
- nueva fn reap_dead pub
- nueva fn run_pipeline pub
02_ruway/shuma/sandbox/shuma-core/src/stats.rs
- nueva def CACHED
- nueva struct CgroupStats
- nueva struct QuotaReport pub
- nueva struct WorkspaceStats pub
- nueva fn cpu_percent_total pub
- nueva fn measure pub
- nueva fn measure_self_pid_returns_data
- nueva fn measure_with_no_pids_returns_zero
- nueva fn online_cores
- nueva fn read_cgroup_stats
- nueva fn read_proc_pid
- nueva mod tests
02_ruway/shuma/sandbox/shuma-core/src/tests.rs
- nueva fn create_and_list_workspace
- nueva fn pipeline_supervisor_queues_restart_on_failure
- nueva fn quota_enforce_nproc_kill_terminates_commands
- nueva fn restart_on_failure_relaunches_failing_command
- nueva fn run_captures_stderr_separately
- nueva fn run_captures_stdout_to_log
- nueva fn run_true_in_workspace
- nueva fn ttl_auto_stops_workspace
- nueva fn workspace_stats_history_accumulates
02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs
- nueva fn create pub
- nueva fn create_with_id pub
- nueva fn get_command_logs pub
- nueva fn list pub
- nueva fn list_commands pub
- nueva fn run pub
- nueva fn run_with_options pub
- nueva fn stop pub
- nueva fn stop_with_grace pub
- nueva fn workspace_label pub
- nueva fn workspace_quota pub
- nueva fn workspace_stats pub
- nueva fn workspace_stats_history pub
02_ruway/shuma/sandbox/shuma-exec/src/lib.rs
- nueva struct CommandSpec pub
- nueva enum Exec pub
- nueva struct Killer pub
- nueva struct PtyControl pub
- nueva enum RunEvent pub
- nueva struct RunHandle pub
- nueva struct Spawned
- nueva struct StageSpec pub
- nueva struct StageTee
- nueva fn capture_limit_truncates_but_process_finishes
- nueva fn capture_stages_intercepts_each_stage_stdout
- nueva fn cont pub
- nueva fn direct pub
- nueva fn direct
- nueva fn direct_missing_program_fails_gracefully
- nueva fn direct_nonzero_exit_is_the_last_stage
- nueva fn direct_runs_a_single_program
- nueva fn direct_three_stage_pipeline
- nueva fn direct_wires_a_pipeline_itself
- nueva fn is_finished pub
- nueva fn is_terminal pub
- nueva fn kill pub
- nueva fn kill pub
- nueva fn kill_stops_a_long_running_pipeline
- nueva fn killer pub
- nueva fn killer_can_stop_and_continue_a_process
- nueva fn next_event pub
- nueva fn pids pub
- nueva fn pipe
- nueva fn pty_control pub
- nueva fn pty_resize_changes_dimensions_in_running_child
- nueva fn pty_run_emits_bytes_for_a_tty_aware_command
- nueva fn pty_write_input_reaches_the_child_stdin
- nueva fn resize pub
- nueva fn resize pub
- nueva fn run pub
- nueva fn shell pub
- nueva fn shell_mode_still_works_for_complex_syntax
- nueva fn signal
- nueva fn spawn_direct
- nueva fn spawn_pty_thread
- nueva fn spawn_reader
- nueva fn spawn_shell
- nueva fn spill_rest
- nueva fn spill_writes_overflow_to_a_file
- nueva fn stage_lines
- nueva fn stdin_data_reprocessed_by_a_filter
- nueva fn stdout_of
- nueva fn stop pub
- nueva fn tee_pump
- nueva fn term pub
- nueva fn terminal_event_detection
- nueva mod tests
- nueva fn try_events pub
- nueva fn wait_all pub
- nueva fn with_limit pub
- nueva fn with_spill pub
- nueva fn with_stage_capture pub
- nueva fn with_stdin pub
- nueva fn without_capture_stages_there_are_no_stage_events
- nueva fn write_input pub
- nueva fn write_input pub
02_ruway/shuma/sandbox/shuma-history/src/lib.rs
- nueva enum DedupPolicy pub
- nueva struct Entry pub
- nueva struct History pub
- nueva enum Nav pub
- nueva fn append pub
- nueva fn append_persists_across_reopen
- nueva fn corrupt_lines_are_skipped_not_fatal
- nueva fn dedup pub
- nueva fn default
- nueva fn default_path pub
- nueva fn empty_history_round_trip
- nueva fn empty_line_is_ignored
- nueva fn empty_query_returns_most_recent_first
- nueva fn entries pub
- nueva fn erase_dups_purges_prior_copies
- nueva fn finalize_last pub
- nueva fn finalize_writes_exit_and_duration
- nueva fn fuzzy_search pub
- nueva fn fuzzy_search_ranks_matches
- nueva fn h
- nueva fn ignore_consecutive_dedup_skips_repeats
- nueva fn is_empty pub
- nueva fn last pub
- nueva fn len pub
- nueva fn navigate pub
- nueva fn navigate_walks_from_newest_to_oldest
- nueva fn new pub
- nueva fn open pub
- nueva fn path pub
- nueva fn rewrite_file
- nueva fn set_dedup pub
- nueva fn skipped_on_load pub
- nueva mod tests
- nueva fn write_one
02_ruway/shuma/sandbox/shuma-infer/src/lib.rs
- nueva struct CommandRecord pub
- nueva struct EmergingPattern pub
- nueva struct InferConfig pub
- nueva struct PatternStep pub
- nueva enum StepArgs pub
- nueva fn a_failed_command_breaks_the_pattern
- nueva fn abstracts_varying_arguments
- nueva fn build_pattern
- nueva fn contains_subslice
- nueva fn default
- nueva fn detect_patterns pub
- nueva fn detects_a_repeated_sequence
- nueva fn directories_are_collected
- nueva fn example_is_the_most_recent_occurrence
- nueva fn longer_pattern_subsumes_its_subsequences
- nueva fn no_prediction_when_nothing_matches
- nueva fn no_repetition_yields_no_patterns
- nueva fn ok
- nueva fn parse pub
- nueva fn parse_splits_binary_and_args
- nueva fn pattern_world
- nueva fn predict_next pub
- nueva fn prediction_shrinks_as_the_pattern_advances
- nueva fn predicts_the_rest_after_a_cd
- nueva fn render pub
- nueva fn score pub
- nueva fn score_ranks_longer_and_more_frequent_higher
- nueva fn suggested_name pub
- nueva fn suggested_name_drops_the_cd
- nueva mod tests
02_ruway/shuma/sandbox/shuma-intent/src/graph.rs
- nueva struct CommandNode pub
- nueva enum NodeStatus pub
- nueva struct SessionGraph pub
- nueva fn collapse_only_retracts_successful_nodes
- nueva fn collapse_succeeded pub
- nueva fn commands pub
- nueva fn complete pub
- nueva fn complete_assigns_buffer_and_status
- nueva fn dangling_refs pub
- nueva fn dangling_refs_validates_an_intention
- nueva fn default
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn new pub
- nueva fn record pub
- nueva fn record_assigns_increasing_command_ids
- nueva fn resolve pub
- nueva mod tests
02_ruway/shuma/sandbox/shuma-intent/src/lib.rs
02_ruway/shuma/sandbox/shuma-intent/src/macros.rs
- nueva struct Macro pub
- nueva struct MacroBook pub
- nueva fn all pub
- nueva fn bind pub
- nueva fn book_lookup_by_key_and_name
- nueva fn by_key pub
- nueva fn by_name pub
- nueva fn insert pub
- nueva fn insert_replaces_by_name
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn macro_builder_composes
- nueva fn new pub
- nueva fn new pub
- nueva fn step pub
- nueva mod tests
02_ruway/shuma/sandbox/shuma-intent/src/parse.rs
- nueva struct Intention pub
- nueva enum Ref pub
- nueva enum Stage pub
- nueva fn empty_line_is_empty_intention
- nueva fn is_empty pub
- nueva fn parse pub
- nueva fn parse pub
- nueva fn parses_ref_tokens
- nueva fn parses_the_spec_example
- nueva fn refs pub
- nueva fn refs_extracts_only_injections
- nueva mod tests
02_ruway/shuma/sandbox/shuma-line/src/ansi.rs
- nueva enum AnsiColor pub
- nueva struct AnsiSpan pub
- nueva struct AnsiStyle pub
- nueva fn apply_sgr
- nueva fn bg_color_is_offset_from_fg
- nueva fn bold_underline_combo
- nueva fn bright_colors_high_range
- nueva fn cr_keeps_trailing_chars_when_overwrite_is_shorter
- nueva fn cr_overwrites_previous_chars
- nueva fn empty_input_yields_no_spans
- nueva fn from_bg_code
- nueva fn from_fg_code
- nueva fn is_plain pub
- nueva fn parse_ansi_line pub
- nueva fn plain
- nueva fn red_text_picks_up_fg
- nueva fn reset_at_end_clears_style
- nueva fn strip_ansi pub
- nueva fn strip_ansi_drops_all_sgr
- nueva mod tests
- nueva fn text_without_escapes_is_one_plain_span
- nueva fn truecolor_sequences_dont_corrupt_subsequent_parsing
- nueva fn unknown_escape_terminator_is_skipped_gracefully
02_ruway/shuma/sandbox/shuma-line/src/complete.rs
- nueva struct Completion pub
- nueva enum CompletionKind pub
- nueva trait CompletionSource pub
- nueva struct CustomSource
- nueva struct StaticSource pub
- nueva def UNIVERSAL_FLAGS
- nueva fn after_pipe_help_flag_works_for_new_stage_command
- nueva fn commands
- nueva fn commands
- nueva fn complete pub
- nueva fn complete_at
- nueva fn completes_command_after_a_pipe
- nueva fn completes_command_names_from_prefix
- nueva fn completes_flags_for_the_stage_command
- nueva fn completes_paths_in_argument_position
- nueva fn completing_in_whitespace_starts_a_fresh_word
- nueva fn cursor_past_end_is_clamped
- nueva fn empty_line_offers_all_commands
- nueva fn equals_in_flag_switches_to_path_completion
- nueva fn extend_with_universal
- nueva fn flag_completion_knows_the_command
- nueva fn flag_hints pub
- nueva fn flags
- nueva fn flags
- nueva fn is_empty pub
- nueva fn paths
- nueva fn paths
- nueva fn source
- nueva fn source_can_override_flag_db
- nueva mod tests
- nueva fn universal_help_flags_always_suggested
02_ruway/shuma/sandbox/shuma-line/src/continuation.rs
- nueva enum TrailingOp
- nueva fn comment_does_not_open_anything
- nueva fn heredoc_open_needs_continuation_until_tag_seen
- nueva fn heredoc_with_dash_strips_tabs_on_close
- nueva fn needs_continuation pub
- nueva fn quoted_pipe_does_not_count
- nueva fn simple_complete_line_does_not_need_continuation
- nueva fn single_quote_makes_backslash_literal
- nueva mod tests
- nueva fn trailing_backslash_needs_continuation
- nueva fn trailing_pipe_needs_continuation
- nueva fn unbalanced_paren_needs_continuation
- nueva fn unclosed_double_quote_needs_continuation
- nueva fn unclosed_single_quote_needs_continuation
02_ruway/shuma/sandbox/shuma-line/src/decorate.rs
- nueva struct Decoration pub
- nueva enum DecorationKind pub
- nueva def URL_PREFIXES
- nueva fn absolute_paths_work_too
- nueva fn block_elements_count_as_box_draw
- nueva fn box_drawing_chars_are_detected_as_one_run
- nueva fn box_drawing_runs_are_contiguous_only
- nueva fn cargo_arrow_ref_is_picked_up
- nueva fn decorate_line pub
- nueva fn detects_existing_files_in_a_line
- nueva fn directory_is_marked_as_dir
- nueva fn empty_line_yields_nothing
- nueva fn executable_bit_detected_on_unix
- nueva fn find_box_draw
- nueva fn find_git_shas
- nueva fn find_grep_refs
- nueva fn find_issue_refs
- nueva fn find_paths
- nueva fn find_urls
- nueva fn git_sha_is_detected_with_hex_and_min_length
- nueva fn git_sha_long_form
- nueva fn grep_ref_at_start_of_line
- nueva fn grep_ref_without_column
- nueva fn is_boundary
- nueva fn is_box_draw
- nueva fn is_end_boundary
- nueva fn is_exec_unix
- nueva fn issue_ref_is_picked_up
- nueva fn nonexistent_paths_are_not_decorated
- nueva fn overlaps_any
- nueva fn overlaps_dont_double_decorate
- nueva fn paths_in
- nueva fn pound_inside_a_word_is_not_an_issue_ref
- nueva fn punctuation_around_path_is_trimmed
- nueva fn pure_letters_are_not_a_sha
- nueva fn ranges_point_at_the_token_in_the_line
- nueva fn resolve_path
- nueva mod tests
- nueva fn tokens_with_ranges
- nueva fn trim_punctuation
- nueva fn url_strips_trailing_punctuation
- nueva fn urls_are_detected
02_ruway/shuma/sandbox/shuma-line/src/editor.rs
- nueva struct LineState pub
- nueva fn apply_completion pub
- nueva fn apply_completion_replaces_the_prefix
- nueva fn backspace pub
- nueva fn backspace_removes_the_char_before_cursor
- nueva fn clear pub
- nueva fn complete pub
- nueva fn completion_after_text_keeps_the_rest
- nueva fn cursor pub
- nueva fn delete pub
- nueva fn delete_removes_char_at_cursor
- nueva fn dialect pub
- nueva fn editing_is_utf8_safe
- nueva fn insert pub
- nueva fn insert_advances_the_cursor
- nueva fn insert_char pub
- nueva fn is_empty pub
- nueva fn move_end pub
- nueva fn move_home pub
- nueva fn move_left pub
- nueva fn move_right pub
- nueva fn move_word_left pub
- nueva fn move_word_right pub
- nueva fn new pub
- nueva fn pipeline pub
- nueva fn set_dialect pub
- nueva fn set_text pub
- nueva mod tests
- nueva fn text pub
- nueva fn tokens pub
- nueva fn tokens_reflect_the_current_text
- nueva fn word_motions_jump_between_words
02_ruway/shuma/sandbox/shuma-line/src/ghost.rs
- nueva fn corpus_order_is_priority
- nueva fn empty_line_yields_none
- nueva fn exact_line_is_not_a_suggestion
- nueva fn ghost_suggestion pub
- nueva fn no_match_yields_none
- nueva fn suggests_the_remainder_of_a_known_line
- nueva mod tests
02_ruway/shuma/sandbox/shuma-line/src/icon.rs
- nueva enum FileKind pub
- nueva fn dir_and_symlink_win_over_extension
- nueva fn file_icon pub
- nueva fn file_kind pub
- nueva fn file_kind_classifies_by_type_then_extension
- nueva fn icon_for_ext
- nueva fn kind_for_ext
- nueva fn known_extensions_get_their_icon
- nueva mod tests
- nueva fn unknown_extension_falls_back_by_exec_bit
02_ruway/shuma/sandbox/shuma-line/src/lexer.rs
- nueva fn classify
- nueva fn comment_runs_to_end_of_line
- nueva fn empty_line_yields_no_tokens
- nueva fn first_word_is_the_command
- nueva fn handles_unicode_without_panicking
- nueva fn is_word_break
- nueva fn kinds
- nueva fn operators_reset_the_command_position
- nueva fn pipe_distinct_from_logical_or
- nueva fn quotes_are_single_string_tokens
- nueva fn redirects_with_descriptors
- nueva fn scan_bash
- nueva mod tests
- nueva fn tokenize pub
- nueva fn tokens_cover_the_whole_line
- nueva fn try_redirect
- nueva fn variables_are_recognized
- nueva fn word_after_pipe_is_a_command_again
02_ruway/shuma/sandbox/shuma-line/src/lib.rs
02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs
- nueva struct Pipeline pub
- nueva struct Stage pub
- nueva fn empty_line_has_no_stages
- nueva fn from_tokens
- nueva fn is_empty pub
- nueva fn is_piped pub
- nueva fn len pub
- nueva fn pipe_creates_two_stages
- nueva fn pipeline
- nueva fn single_command_is_one_stage
- nueva fn split_pipeline pub
- nueva mod tests
- nueva fn three_stage_pipeline
- nueva fn trailing_pipe_leaves_an_empty_stage
02_ruway/shuma/sandbox/shuma-line/src/token.rs
02_ruway/shuma/sandbox/shuma-link/src/channel.rs
- nueva enum FrameError pub
- nueva struct FramedChannel pub
- nueva struct FramedReader pub
- nueva struct FramedWriter pub
- nueva def MAX_PAYLOAD pub
- nueva fn into_inner pub
- nueva fn new pub
- nueva fn recv pub
- nueva fn recv pub
- nueva fn recv_postcard pub
- nueva fn recv_postcard pub
- nueva fn send pub
- nueva fn send pub
- nueva fn send_postcard pub
- nueva fn send_postcard pub
- nueva fn split pub
02_ruway/shuma/sandbox/shuma-link/src/handshake.rs
- nueva def HANDSHAKE_FRAME_MAX
- nueva enum HandshakeError pub
- nueva struct Msg
- nueva fn client_handshake pub
- nueva fn multiple_messages_keep_counters_in_sync
- nueva fn peer_close_translates_to_closed_error
- nueva fn postcard_round_trips_over_encrypted_channel
- nueva fn read_msg
- nueva fn server_handshake pub
- nueva mod tests
- nueva fn write_msg
- nueva fn wrong_server_key_aborts_handshake
- nueva fn xk_handshake_round_trip
02_ruway/shuma/sandbox/shuma-link/src/identity.rs
- nueva def KEY_LEN pub
- nueva struct Keypair pub
- nueva enum KeypairError pub
- nueva struct PublicKey pub
- nueva fn as_bytes pub
- nueva fn default_path pub
- nueva fn fmt
- nueva fn from_bytes pub
- nueva fn from_hex pub
- nueva fn generate pub
- nueva fn generate_yields_different_keypairs
- nueva fn invalid_hex_returns_error
- nueva fn load pub
- nueva fn load_or_generate pub
- nueva fn load_or_generate_is_idempotent
- nueva fn malformed_file_fails_load
- nueva def noise_pattern
- nueva fn private_bytes pub
- nueva fn public pub
- nueva fn public_bytes pub
- nueva fn public_key_hex_round_trip
- nueva fn save pub
- nueva fn save_and_load_round_trip
- nueva fn saved_file_is_owner_only_on_unix
- nueva mod tests
- nueva fn to_hex pub
02_ruway/shuma/sandbox/shuma-link/src/lib.rs
02_ruway/shuma/sandbox/shuma-link/src/peers.rs
- nueva struct KnownPeers pub
- nueva fn add pub
- nueva fn add_contains_remove
- nueva fn comments_and_blanks_are_skipped
- nueva fn contains pub
- nueva fn default_path pub
- nueva fn is_empty pub
- nueva fn iter pub
- nueva fn len pub
- nueva fn load pub
- nueva fn malformed_lines_are_silently_dropped
- nueva fn missing_file_loads_empty
- nueva fn new pub
- nueva fn remove pub
- nueva fn round_trip_through_disk
- nueva fn save pub
- nueva mod tests
02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs
- nueva def COLLAPSED_H
- nueva def COL_GAP
- nueva def ID pub
- nueva struct LaidBox
- nueva struct LaidEdge
- nueva enum Msg pub
- nueva def NODE_H
- nueva def NODE_W
- nueva def ORIGIN_X
- nueva def ORIGIN_Y
- nueva def ROW_GAP
- nueva struct State pub
- nueva fn clone
- nueva fn collapse_msg_retracts_successful_nodes
- nueva fn complete_msg_assigns_status_and_buffer
- nueva fn contributions pub
- nueva fn default
- nueva fn demo pub
- nueva fn demo_state_has_four_nodes
- nueva fn dispatch pub
- nueva fn focus_detail
- nueva fn hit_test_box
- nueva fn hit_test_finds_box_under_cursor
- nueva fn hit_test_respects_scroll_offset
- nueva fn id_is_stable
- nueva fn insert_ref_msg_is_noop_on_canvas_state
- nueva fn layout
- nueva fn layout_places_dependents_in_higher_columns
- nueva fn new pub
- nueva fn node_clicked_focuses_and_second_click_toggles
- nueva fn node_clicked_outside_clears_focus
- nueva fn record_msg_adds_a_node
- nueva fn reset_msg_empties_graph
- nueva fn sync_graph_clears_focus_if_node_dropped
- nueva fn sync_graph_replaces_state
- nueva mod tests
- nueva fn truncate_to_fit
- nueva fn truncate_to_fit_respects_limit
- nueva fn update pub
- nueva fn view pub
02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs
- nueva struct Activation pub
- nueva struct CommandEntry pub
- nueva enum CommandKind pub
- nueva def DEFAULT_PLACEMENT pub
- nueva def ID pub
- nueva enum Mode pub
- nueva enum Msg pub
- nueva struct State pub
- nueva fn activation_for pub
- nueva fn arrow_down_moves_selection
- nueva fn clear_after_activation pub
- nueva fn clear_after_activation_resets_text_keeps_catalog
- nueva fn contributions pub
- nueva fn default_placement_is_bottombar
- nueva fn dispatch pub
- nueva fn enter_in_launcher_activates_selected
- nueva fn enter_in_shell_returns_exec_with_text
- nueva fn escape_clears_text
- nueva fn ev
- nueva fn fixture_catalog
- nueva fn id_is_stable
- nueva fn label pub
- nueva fn matches pub
- nueva fn matches_returns_all_with_empty_query
- nueva fn prompt pub
- nueva fn set_catalog pub
- nueva mod tests
- nueva fn toggle pub
- nueva fn toggle_mode_flips_only_mode
- nueva fn typing_filters_catalog
- nueva fn update pub
- nueva fn view pub
02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs
- nueva def DEFAULT_PLACEMENT pub
- nueva def ID pub
- nueva struct LauncherEntry pub
- nueva enum Msg pub
- nueva struct State pub
- nueva fn _accent_unused
- nueva fn apps_dir
- nueva fn contributions pub
- nueva fn contributions_expose_apps_shortcut
- nueva fn default_placement_is_topbar
- nueva fn demo pub
- nueva fn demo_state_has_three_entries
- nueva fn dispatch pub
- nueva fn entry_button
- nueva fn entry_clicked_message_carries_action_id
- nueva fn from_apps_dir pub
- nueva fn id_is_stable
- nueva fn load_entries_from_dir pub
- nueva fn load_entries_from_dir_reads_toml_manifests
- nueva fn new pub
- nueva fn spawn_exec pub
- nueva mod tests
- nueva fn update pub
- nueva fn view pub
- nueva fn with_exec pub
02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs
- nueva def ID pub
- nueva def MAX
- nueva enum Msg pub
- nueva struct State pub
- nueva fn apply_remote_blocking pub
- nueva fn apply_remote_blocking_local_returns_lines
- nueva fn apply_report_with_new_current_resets_plan
- nueva fn apply_report_without_new_current_keeps_plan
- nueva fn apply_with_remote_source_defers_to_chassis
- nueva fn blocking_runtime
- nueva fn cap_log
- nueva fn contributions pub
- nueva fn contributions_expose_monitor_and_five_shortcuts
- nueva fn current_or_empty pub
- nueva fn default_ssh_key
- nueva fn describe_upstream
- nueva fn discover_remote_blocking pub
- nueva fn discover_remote_blocking_local_falls_back_to_local
- nueva fn dispatch pub
- nueva fn dispatch_maps_action_ids
- nueva fn dry_run_appends_log_lines
- nueva fn dry_run_remote_blocking pub
- nueva fn dry_run_remote_blocking_local_returns_lines
- nueva fn dry_run_report_appends_lines_to_log
- nueva fn dry_run_with_empty_plan_says_nothing_to_apply
- nueva fn dry_run_with_remote_source_defers_to_chassis
- nueva fn example_inventory pub
- nueva fn example_inventory_has_expected_shape
- nueva fn fetch_remote_inventory
- nueva fn fresh_state_has_no_plan_no_current
- nueva fn id_is_stable
- nueva fn inv_row
- nueva fn inventory_pane
- nueva fn log_line_appends_and_caps_at_200
- nueva fn make_plan_against_empty_current_creates_all
- nueva fn matilda_header
- nueva fn monitor_sampler_reflects_pending_steps
- nueva fn new pub
- nueva fn pending_count pub
- nueva fn plan_and_log_pane
- nueva fn push_apply_log
- nueva fn remote_discover_is_delegated_to_the_chassis
- nueva fn resize_split_clamps_to_range
- nueva fn section_label
- nueva fn set_current_updates_state_and_logs
- nueva fn set_desired_replaces_inventory_and_invalidates_plan
- nueva fn ssh_config_for
- nueva mod tests
- nueva fn text_row
- nueva fn update pub
- nueva fn view pub
- nueva fn with_inventory pub
- nueva fn with_inventory_path pub
- nueva fn with_inventory_path_records_the_path
- nueva fn with_inventory_uses_provided_desired
02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs
- nueva def ID pub
- nueva enum Msg pub
- nueva def RECENT_LIMIT pub
- nueva def REPO_SUBDIR
- nueva struct RepoSnapshot pub
- nueva struct RootRow pub
- nueva struct State pub
- nueva fn contributions pub
- nueva fn counts_row
- nueva fn dispatch pub
- nueva fn dispatch_known_refresh
- nueva fn dispatch_unknown_returns_none
- nueva fn header_row
- nueva fn id_is_stable
- nueva fn load_root_source pub
- nueva fn load_snapshot pub
- nueva fn load_snapshot_errors_on_missing_repo
- nueva fn load_snapshot_marks_retracted_roots
- nueva fn minga_vfs_render_source
- nueva fn new pub
- nueva fn root_row
- nueva fn roots_count pub
- nueva fn selected_source_panel
- nueva fn separator
- nueva fn snapshot_error_sets_error_only
- nueva fn snapshot_ready_updates_counts_and_clears_error
- nueva mod tests
- nueva fn text_row
- nueva fn update pub
- nueva fn verify_all_blocking pub
- nueva fn view pub
- nueva fn with_repo_path pub
02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs
02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs
02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs
- nueva struct ActiveRun pub
- nueva enum AppSkin pub
- nueva enum BackendHandle pub
- nueva struct CommandGroup pub
- nueva struct Fixed
- nueva struct HistorySearch pub
- nueva def ID pub
- nueva def MAX_OUTPUT_LINES pub
- nueva enum Msg pub
- nueva enum OutputKind pub
- nueva struct OutputLine pub
- nueva def PTY_COLS
- nueva def PTY_ROWS
- nueva struct ShellSource pub
- nueva struct State pub
- nueva def TUI_ALLOWLIST
- nueva struct TuiSession pub
- nueva struct VimSel pub
- nueva fn ampersand_suffix_starts_background_job
- nueva fn ansi_idx_palette_matches_expected_basics
- nueva fn app_skin_for pub
- nueva fn arrow_right_at_end_accepts_ghost
- nueva fn arrow_up_walks_history_backwards
- nueva fn build_spec_pipe_with_glob_falls_back_to_shell
- nueva fn build_spec_pipe_with_quotes_falls_back_to_shell
- nueva fn build_spec_routes_known_tui_command_to_pty
- nueva fn build_spec_routes_plain_command_to_shell
- nueva fn build_spec_routes_simple_pipe_to_direct_with_capture
- nueva fn build_spec_tui_prefix_overrides_default
- nueva fn builtin_does_not_register_in_graph
- nueva fn cancel_terminates_active_run
- nueva fn cd_to_nonexistent_logs_error
- nueva fn cd_to_root_changes_cwd
- nueva fn clear_builtin_empties_output
- nueva fn clear_msg_empties_output
- nueva def clear_output
- nueva fn clear_output_also_drops_collapsed_set
- nueva fn commands
- nueva fn commands
- nueva fn common_prefix_returns_longest_shared_start
- nueva fn completion_arrows_cycle_both_ways
- nueva fn completion_enter_accepts_without_submitting
- nueva fn completion_escape_closes_without_change
- nueva fn completion_tab_cycles_then_wraps
- nueva fn contributions pub
- nueva fn contributions_expose_clear_and_cancel_shortcuts
- nueva fn ctrl_r_opens_search_overlay
- nueva fn dispatch_maps_clear
- nueva fn drain_until_idle
- nueva fn empty_submit_does_nothing_but_clears_input
- nueva fn enter_with_open_quote_inserts_newline_instead_of_submit
- nueva fn ev
- nueva fn external_command_captures_stdout
- nueva fn external_command_failure_writes_exit_nonzero
- nueva fn external_command_records_intention_in_graph
- nueva fn failed_command_records_failed_status
- nueva fn fake_completion
- nueva fn fkey_runs_saved_group
- nueva fn fmt
- nueva fn ghost_extends_from_history_when_prefix_matches
- nueva fn ghost_uses_prediction_before_history
- nueva fn git_branch_none_outside_repo
- nueva fn git_branch_reads_head_ref
- nueva fn id_is_stable
- nueva fn infer_predicts_next_command_in_a_repeated_sequence
- nueva fn insert_at_cursor_appends_into_input
- nueva fn intent_graph pub
- nueva fn is_finished pub
- nueva fn is_running pub
- nueva fn jobs_builtin_empty_shows_notice
- nueva fn jobs_builtin_lists_background_jobs
- nueva fn key_to_pty_bytes_handles_special_keys
- nueva fn kill pub
- nueva fn limit_builtin_sets_capture_bytes
- nueva fn long_running_command_does_not_block_update
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn notice pub
- nueva def open_block
- nueva fn open_decoration_cd_into_a_directory
- nueva fn open_decoration_git_sha_prefills_input
- nueva fn open_decoration_path_executable_prefills_input
- nueva fn open_history
- nueva fn output_buffer_caps_at_max
- nueva fn output_len pub
- nueva fn partition_line_segments_a_line_with_a_url
- nueva fn paste_key_event_is_recognized
- nueva fn paths
- nueva fn paths
- nueva fn piped_command_captures_intermediate_stage_output
- nueva fn placeholder_state_constructs
- nueva fn prompt pub
- nueva def push_in_block
- nueva fn push_in_block_keeps_async_output_out_of_foreground_card
- nueva def push_output
- nueva fn push_output_groups_lines_into_command_blocks
- nueva fn pwd_builtin_writes_cwd
- nueva fn reprocess_feeds_block_stdout_as_stdin
- nueva fn resize pub
- nueva fn run_group_msg_executes_group
- nueva fn save_group_captures_recent_commands
- nueva fn scroll_clamps_between_zero_and_overflow
- nueva fn second_enter_queues_while_busy
- nueva fn set_reprocess_toggles_off_same_block
- nueva fn set_size pub
- nueva fn shift_enter_always_inserts_newline
- nueva fn simple_pipe_stages_rejects_single_command
- nueva fn simple_pipe_stages_rejects_trailing_pipe
- nueva fn source_daemon_failure_surfaces_as_notice
- nueva fn spill_builtin_toggles_flag
- nueva fn stage_stdout pub
- nueva fn stderr pub
- nueva fn stdout pub
- nueva fn tab_completion_inserts_unique_candidate
- nueva mod tests
- nueva fn toggle_block_flips_collapsed_set
- nueva fn toggle_stage_flips_expanded_set
- nueva fn try_events pub
- nueva fn typing_appends_to_input
- nueva fn typing_closes_completion_popup
- nueva mod update
- nueva mod view
- nueva fn write_input pub
02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs
- nueva def JobSignal
- nueva def PROJECT_MARKERS
- nueva def accept_completion
- nueva def apply_capture_limit
- nueva def apply_cd
- nueva def apply_completion_msg
- nueva def apply_groups_list
- nueva def apply_jobs_list
- nueva def apply_jobs_signal
- nueva def apply_key_to_line
- nueva def apply_spill
- nueva def build_spec
- nueva def cancel_running
- nueva def close_completion
- nueva def common_prefix
- nueva def copy_vim_selection
- nueva def current_ghost
- nueva def cycle_completion
- nueva fn dispatch pub
- nueva def drain_bg_jobs
- nueva def drain_run
- nueva def fail_pending_intent
- nueva def fkey_index
- nueva def forward_key_to_pty
- nueva def forward_paste_to_pty
- nueva def gather_block_stdout
- nueva def git_branch
- nueva def handle_search_key
- nueva fn infer_records
- nueva def is_tui_active
- nueva def key_to_pty_bytes
- nueva def load_or_create_identity
- nueva fn markers_in
- nueva def navigate_history
- nueva def open_decoration
- nueva def parse_pub_hex
- nueva fn pattern_trigger
- nueva def predicted_sequence
- nueva def push_line
- nueva def read_clipboard
- nueva def refresh_patterns
- nueva def run_group
- nueva def run_submitted
- nueva def save_group
- nueva def set_clipboard
- nueva def simple_pipe_stages
- nueva def spawn_detached
- nueva def split_first_word
- nueva def start_bg
- nueva def start_run
- nueva fn update pub
02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs
- nueva def BASIC
- nueva def BORDER_INNER_H
- nueva def CARD_GAP
- nueva def CARD_PAD_V
- nueva def HEADER_H
- nueva def LINE_H
- nueva def LinePiece
- nueva def MAX_ROWS
- nueva def MAX_VISIBLE
- nueva def PANEL_W
- nueva def PANE_GAP
- nueva def PANE_PAD_V
- nueva def PROBE
- nueva def ROW
- nueva def ROW_H
- nueva def STAGES_H
- nueva def STAGE_PALETTE
- nueva def TuiCell
- nueva def TuiSnapshot
- nueva def VIM_CHAR_W
- nueva def VIM_FONT_PX
- nueva def VIM_LINE_H
- nueva def VIM_PAD
- nueva def ansi_idx_to_color
- nueva def build_span_children
- nueva def capture_tui
- nueva def command_card
- nueva def completion_popup
- nueva def extract_command
- nueva def generic_grid_panel
- nueva def groups_panel
- nueva def history_search_panel
- nueva fn humanize_bytes
- nueva fn kind_icon
- nueva def output_pane
- nueva def partition_line
- nueva def pipe_stages_row
- nueva def pretty_path
- nueva def render_output_line
- nueva fn row_text
- nueva def shell_header
- nueva def shell_input_view
- nueva def stage_capture_rows
- nueva def stage_color
- nueva fn stage_color_dim
- nueva def state_dialect_default
- nueva def status_color
- nueva def token_color
- nueva def tui_panel
- nueva fn view pub
- nueva def vim_panel
- nueva def vim_px_to_cell
- nueva def vt_color
02_ruway/shuma/sandbox/shuma-module/src/lib.rs
- nueva enum BarBehavior pub
- nueva struct DrawerTrigger pub
- nueva struct ModuleConfig pub
- nueva struct ModuleContributions pub
- nueva type ModuleId pub
- nueva struct MonitorSpec pub
- nueva enum Placement pub
- nueva struct Rgb pub
- nueva struct Sample pub
- nueva enum ShortcutAction pub
- nueva struct ShortcutSpec pub
- nueva enum Source pub
- nueva fn allows_multiple pub
- nueva fn bar_behavior_default_is_fixed
- nueva fn command pub
- nueva fn default
- nueva fn default
- nueva fn default_drawer_height
- nueva fn default_ssh_port
- nueva fn drawer_trigger_default_is_f12_no_hover
- nueva fn effective_label pub
- nueva fn empty pub
- nueva fn fmt
- nueva fn focus_tab pub
- nueva fn is_remote pub
- nueva fn label pub
- nueva fn module_action pub
- nueva fn module_config_effective_label_prefers_explicit
- nueva fn module_config_round_trips_toml
- nueva fn monitor_spec_holds_a_callable_sampler
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn only_drawer_tab_allows_multiple_instances
- nueva fn placement_default_is_main
- nueva fn placement_round_trips_snake_case_toml
- nueva fn remote_source_label_falls_back_to_user_at_host
- nueva fn remote_source_label_uses_override_when_set
- nueva fn shortcut_constructors
- nueva fn source_default_is_local
- nueva mod tests
- nueva fn with_hint pub
02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs
- nueva struct CommandInfo pub
- nueva def DEFAULT_SOCK_NAME pub
- nueva struct EdgeDiscernmentInfo pub
- nueva enum ExecKind pub
- nueva struct ExecStage pub
- nueva struct FlowInfo pub
- nueva struct FlowThroughputInfo pub
- nueva def MAX_FRAME pub
- nueva enum ProtocolError pub
- nueva struct QuotaReportInfo pub
- nueva enum Request pub
- nueva enum Response pub
- nueva struct WorkspaceStatsInfo pub
- nueva struct WorkspaceSummary pub
- nueva fn default_cpu_cores
- nueva fn default_grace_ms
- nueva fn default_socket_path pub
- nueva fn default_socket_path_uses_runtime_dir
- nueva fn exec_stream_event_frames_round_trip
- nueva fn exec_stream_request_round_trips
- nueva fn exec_terminal_detector
- nueva fn is_exec_terminal pub
- nueva fn ping_roundtrip
- nueva fn read_frame pub
- nueva mod tests
- nueva fn workspace_create_roundtrip
- nueva fn write_frame pub
02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs
- nueva enum RemoteExecError pub
- nueva struct RemoteRunHandle pub
- nueva fn connect_error_surfaces_to_caller
- nueva fn echo_round_trips_through_encrypted_tcp_client
- nueva fn echo_round_trips_through_remote_client
- nueva fn is_finished pub
- nueva fn kill pub
- nueva fn kill_propagates_to_remote_process
- nueva fn next_event pub
- nueva fn one_shot_pty_server
- nueva fn one_shot_server
- nueva fn pty_fields
- nueva fn pty_forwards_input_to_remote
- nueva fn pty_round_trips_over_encrypted_tcp
- nueva fn pty_spec
- nueva fn pty_streams_output_and_exits
- nueva fn resize pub
- nueva fn response_to_event
- nueva fn run pub
- nueva fn run_default pub
- nueva fn run_pty pub
- nueva fn run_pty_tcp pub
- nueva fn run_tcp pub
- nueva fn serve_exec_stream
- nueva fn serve_one_exec_enc
- nueva fn serve_one_pty
- nueva fn serve_one_pty_enc
- nueva fn stage_capture_round_trips_through_remote_client
- nueva fn temp_socket
- nueva mod tests
- nueva fn try_events pub
- nueva fn write_input pub
- nueva fn wrong_server_pubkey_surfaces_as_failed_event
02_ruway/shuma/sandbox/shuma-session/src/lib.rs
- nueva struct CapturePolicy pub
- nueva struct CommandGroup pub
- nueva struct CommandRun pub
- nueva struct OutputLine pub
- nueva type RunId pub
- nueva enum RunStatus pub
- nueva enum Stream pub
- nueva struct WorkSession pub
- nueva fn a_run_records_its_directory
- nueva fn append_output pub
- nueva fn begin_run pub
- nueva fn capture pub
- nueva fn capture_policy_is_per_session
- nueva fn clear_history pub
- nueva fn count_of pub
- nueva fn cwd pub
- nueva fn default
- nueva fn finish_run pub
- nueva fn fnv1a
- nueva fn group pub
- nueva fn groups pub
- nueva fn has_stderr pub
- nueva fn history pub
- nueva fn is_running pub
- nueva fn isolation_id pub
- nueva fn isolation_id_follows_the_directory
- nueva fn line_count pub
- nueva fn lines_of pub
- nueva fn mark_truncated pub
- nueva fn new pub
- nueva fn nonzero_exit_marks_failed
- nueva fn output_accumulates_and_run_finishes
- nueva fn output_separates_stdout_from_stderr
- nueva fn remove_group pub
- nueva fn remove_group
- nueva fn run pub
- nueva fn run_mut
- nueva fn running pub
- nueva fn running_lists_unfinished_commands
- nueva fn save_and_recall_a_group
- nueva fn save_group pub
- nueva fn save_recent_as_group pub
- nueva fn save_recent_history_as_a_group
- nueva fn set_capture pub
- nueva fn set_capture_limit pub
- nueva fn set_cwd pub
- nueva fn set_spill pub
- nueva mod tests
02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs
- nueva struct CanvasPlan pub
- nueva struct Edge pub
- nueva struct LayoutParams pub
- nueva struct NodeBox pub
- nueva fn chained_session
- nueva fn default
- nueva fn independent_commands_share_column_zero
- nueva fn layout pub
- nueva fn layout_creates_an_edge_for_the_buffer_flow
- nueva fn layout_places_dependent_in_a_later_column
- nueva fn node pub
- nueva fn paint pub
- nueva fn paint_emits_commands_to_a_recorder
- nueva fn status_color
- nueva mod tests
02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs
- nueva struct CpuStat pub
- nueva struct History pub
- nueva def MEMINFO
- nueva struct MemStat pub
- nueva def STAT_1
- nueva def STAT_2
- nueva struct Snapshot pub
- nueva struct SystemSampler pub
- nueva fn capacity pub
- nueva fn cpu_history pub
- nueva fn first_sample_has_zero_cpu_then_delta
- nueva fn history_fills_both_curves
- nueva fn history_is_a_bounded_ring
- nueva fn invalid
- nueva fn invalid_proc_yields_invalid_snapshot
- nueva fn is_empty pub
- nueva fn last pub
- nueva fn len pub
- nueva fn mem_history pub
- nueva fn memory_percent_uses_available
- nueva fn new pub
- nueva fn new pub
- nueva fn parse_cpu_stat pub
- nueva fn parse_meminfo pub
- nueva fn parses_cpu_aggregate_line
- nueva fn parses_meminfo
- nueva fn push pub
- nueva fn rejects_malformed_proc_text
- nueva fn sample pub
- nueva fn sample_from pub
- nueva mod tests
- nueva fn values pub
02_ruway/shuma/shuma-cli/src/main.rs
- nueva struct Cli
- nueva enum Cmd
- nueva enum FlowCmd
- nueva enum PipeCmd
- nueva enum WsCmd
- nueva fn main
- nueva fn parse_kv
- nueva fn parse_ws_id
- nueva fn print_pipeline_started_returning_socket
- nueva fn print_unexpected
- nueva fn round_trip
- nueva fn tail_socket
02_ruway/shuma/shuma-daemon/src/main.rs
- nueva def AUDIT_LOG_MAX_BYTES
- nueva fn acquire_lockfile
- nueva fn announce_edges_to_broker
- nueva fn append_audit_line
- nueva fn audit_request
- nueva fn build_daemon_card
- nueva fn build_workspace_card
- nueva fn default_audit_log_path
- nueva fn dispatch
- nueva fn exec_event_to_response
- nueva fn exec_stream_echoes_a_line
- nueva fn exec_stream_kills_child_on_client_disconnect
- nueva fn exec_stream_supports_shell_mode
- nueva fn exec_stream_tees_intermediate_stage
- nueva fn handle_client
- nueva fn handle_enc_client
- nueva fn handle_exec_stream
- nueva fn handle_exec_stream_enc
- nueva fn handle_pty_stream
- nueva fn handle_pty_stream_enc
- nueva fn init_tracing
- nueva fn lockfile_drop_del_primer_handle_libera_el_lock
- nueva fn lockfile_se_adquiere_y_escribe_el_pid_actual
- nueva fn lockfile_segundo_lock_en_el_mismo_archivo_falla
- nueva fn main
- nueva fn map_edge_to_info
- nueva fn peer_uid
- nueva fn pid_path_es_sock_con_extension_pid
- nueva fn pid_path_for
- nueva fn pty_event_to_response
- nueva fn pty_spec
- nueva fn short_ulid
- nueva fn short_workspace_id
- nueva fn socket_in_use
- nueva fn socket_in_use_false_para_path_inexistente
- nueva mod tests
- nueva fn type_label
02_ruway/shuma/shuma-gateway/src/main.rs
- nueva def DEFAULT_LISTEN
- nueva fn find_double_crlf
- nueva fn handle_http
- nueva fn init_tracing
- nueva fn main
- nueva fn round_trip_daemon
- nueva fn write_error
- nueva fn write_response
- nueva fn write_text
02_ruway/shuma/shuma-shell-llimphi/src/config.rs
- nueva struct ShumaConfig pub
- nueva struct SlotEntry pub
- nueva struct TabEntry pub
- nueva fn broken_toml_returns_default_without_panic
- nueva fn default_path pub
- nueva fn inventory_field_parses_on_main_and_tabs
- nueva fn load pub
- nueva fn load_default pub
- nueva fn missing_file_yields_default
- nueva fn parses_a_full_shumarc
- nueva fn partial_config_falls_back_to_defaults_per_field
- nueva mod tests
02_ruway/shuma/shuma-shell-llimphi/src/main.rs
- nueva def HISTORY
- nueva struct Instance
- nueva enum Kind
- nueva def MONITORS_INITIAL_WIDTH
- nueva def MONITORS_TOOTH
- nueva struct Model
- nueva type Model
- nueva enum ModuleMsg
- nueva enum ModuleState
- nueva enum Msg
- nueva type Msg
- nueva def SHELL_TICK
- nueva struct Shell
- nueva enum Slot
- nueva def TICK
- nueva fn app_id
- nueva fn canvas
- nueva fn command_bar
- nueva mod config
- nueva fn default_shell_source
- nueva fn host_teeth
- nueva fn id
- nueva fn init
- nueva fn initial_size
- nueva fn launcher
- nueva fn main
- nueva fn matilda
- nueva fn matilda_with_inventory
- nueva mod menu
- nueva fn minga
- nueva fn on_key
- nueva fn on_wheel
- nueva fn shell
- nueva fn shuma_host
- nueva fn title
- nueva fn tooth_icon
- nueva fn update
- nueva mod update
- nueva fn view
- nueva mod view
- nueva fn view_overlay
02_ruway/shuma/shuma-shell-llimphi/src/menu.rs
- nueva def FocusInfo
- nueva def app_menu
- nueva fn clear_input
- nueva def focused_shell
- nueva def handle_command
- nueva def intercept_key
- nueva def menubar_row
- nueva def menubar_spec
- nueva def overlay
- nueva fn route_to_shell
- nueva fn shell_paste_key
- nueva fn terminal_context_menu
- nueva fn viewport
02_ruway/shuma/shuma-shell-llimphi/src/update.rs
- nueva def apply_module_msg
- nueva def collect_contributions
- nueva def dispatch_to_module
- nueva def drain_shell_instances
- nueva def example_inventory_fallback
- nueva def find_primary_shell_graph
- nueva def first_shell_slot
- nueva def forward_key_to_focused_shell
- nueva def forward_wheel_to_focused_shell
- nueva def handle_shortcut
- nueva def load_matilda_inventory
- nueva def matilda_inventory_path
- nueva def minga_repo_path
- nueva def minga_visible_alphas
- nueva def monitor_key
- nueva def remote_matilda_inputs
- nueva def resolve_instance
- nueva def resolve_slot
- nueva def resolve_tab
- nueva def route_to_instance
- nueva def sample_extra_monitors
- nueva def sync_canvas_from_primary_shell
- nueva fn tick_one
02_ruway/shuma/shuma-shell-llimphi/src/view.rs
- nueva def curve_view
- nueva def empty_bar
- nueva def monitor_card
- nueva def monitor_stack
- nueva def placeholder
- nueva def render_bottombar
- nueva def render_main_area
- nueva def render_main_full
- nueva def render_tabs_with_monitors
- nueva def render_topbar
- nueva def shortcut_button
- nueva def tab_content
- nueva def tabs_toolbar
182 archivos · +42292 −0
- .gitignore +3 −0
- 02_ruway/shuma/LEEME.md +72 −0
- 02_ruway/shuma/README.md +27 −0
- 02_ruway/shuma/README.qu.md +29 −0
- 02_ruway/shuma/REPORTE.md +367 −0
- 02_ruway/shuma/baremetal/matilda-app/Cargo.toml +25 −0
- 02_ruway/shuma/baremetal/matilda-app/LEEME.md +16 −0
- 02_ruway/shuma/baremetal/matilda-app/README.md +16 −0
- 02_ruway/shuma/baremetal/matilda-app/src/main.rs +238 −0
- 02_ruway/shuma/baremetal/matilda-apply/Cargo.toml +14 −0
- 02_ruway/shuma/baremetal/matilda-apply/LEEME.md +10 −0
- 02_ruway/shuma/baremetal/matilda-apply/README.md +10 −0
- 02_ruway/shuma/baremetal/matilda-apply/src/lib.rs +208 −0
- 02_ruway/shuma/baremetal/matilda-config/Cargo.toml +11 −0
- 02_ruway/shuma/baremetal/matilda-config/LEEME.md +9 −0
- 02_ruway/shuma/baremetal/matilda-config/README.md +9 −0
- 02_ruway/shuma/baremetal/matilda-config/src/docker.rs +105 −0
- 02_ruway/shuma/baremetal/matilda-config/src/lib.rs +63 −0
- 02_ruway/shuma/baremetal/matilda-config/src/nginx.rs +94 −0
- 02_ruway/shuma/baremetal/matilda-core/Cargo.toml +11 −0
- 02_ruway/shuma/baremetal/matilda-core/LEEME.md +9 −0
- 02_ruway/shuma/baremetal/matilda-core/README.md +9 −0
- 02_ruway/shuma/baremetal/matilda-core/src/container.rs +132 −0
- 02_ruway/shuma/baremetal/matilda-core/src/host.rs +46 −0
- 02_ruway/shuma/baremetal/matilda-core/src/inventory.rs +131 −0
- 02_ruway/shuma/baremetal/matilda-core/src/lib.rs +26 −0
- 02_ruway/shuma/baremetal/matilda-core/src/vhost.rs +88 −0
- 02_ruway/shuma/baremetal/matilda-discover/Cargo.toml +16 −0
- 02_ruway/shuma/baremetal/matilda-discover/LEEME.md +10 −0
- 02_ruway/shuma/baremetal/matilda-discover/README.md +10 −0
- 02_ruway/shuma/baremetal/matilda-discover/src/lib.rs +349 −0
- 02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml +12 −0
- 02_ruway/shuma/baremetal/matilda-ghost/LEEME.md +9 −0
- 02_ruway/shuma/baremetal/matilda-ghost/README.md +9 −0
- 02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs +222 −0
- 02_ruway/shuma/baremetal/matilda-linker/Cargo.toml +13 −0
- 02_ruway/shuma/baremetal/matilda-linker/LEEME.md +9 −0
- 02_ruway/shuma/baremetal/matilda-linker/README.md +9 −0
- 02_ruway/shuma/baremetal/matilda-linker/src/lib.rs +154 −0
- 02_ruway/shuma/baremetal/matilda-plan/Cargo.toml +12 −0
- 02_ruway/shuma/baremetal/matilda-plan/LEEME.md +9 −0
- 02_ruway/shuma/baremetal/matilda-plan/README.md +9 −0
- 02_ruway/shuma/baremetal/matilda-plan/src/lib.rs +268 −0
- 02_ruway/shuma/sandbox/shuma-card/Cargo.toml +25 −0
- 02_ruway/shuma/sandbox/shuma-card/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-card/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-card/src/lib.rs +774 −0
- 02_ruway/shuma/sandbox/shuma-config/Cargo.toml +17 −0
- 02_ruway/shuma/sandbox/shuma-config/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-config/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml +29 −0
- 02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml +62 −0
- 02_ruway/shuma/sandbox/shuma-config/src/lib.rs +474 −0
- 02_ruway/shuma/sandbox/shuma-core/Cargo.toml +27 −0
- 02_ruway/shuma/sandbox/shuma-core/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-core/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs +478 −0
- 02_ruway/shuma/sandbox/shuma-core/src/lib.rs +261 −0
- 02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs +122 −0
- 02_ruway/shuma/sandbox/shuma-core/src/persist.rs +383 −0
- 02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs +808 −0
- 02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs +318 −0
- 02_ruway/shuma/sandbox/shuma-core/src/runtime.rs +236 −0
- 02_ruway/shuma/sandbox/shuma-core/src/stats.rs +210 −0
- 02_ruway/shuma/sandbox/shuma-core/src/tests.rs +308 −0
- 02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs +414 −0
- 02_ruway/shuma/sandbox/shuma-exec/Cargo.toml +17 −0
- 02_ruway/shuma/sandbox/shuma-exec/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-exec/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-exec/src/lib.rs +1269 −0
- 02_ruway/shuma/sandbox/shuma-history/Cargo.toml +18 −0
- 02_ruway/shuma/sandbox/shuma-history/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-history/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-history/src/lib.rs +430 −0
- 02_ruway/shuma/sandbox/shuma-infer/Cargo.toml +11 −0
- 02_ruway/shuma/sandbox/shuma-infer/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-infer/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-infer/src/lib.rs +460 −0
- 02_ruway/shuma/sandbox/shuma-intent/Cargo.toml +11 −0
- 02_ruway/shuma/sandbox/shuma-intent/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-intent/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-intent/src/graph.rs +175 −0
- 02_ruway/shuma/sandbox/shuma-intent/src/lib.rs +21 −0
- 02_ruway/shuma/sandbox/shuma-intent/src/macros.rs +114 −0
- 02_ruway/shuma/sandbox/shuma-intent/src/parse.rs +118 −0
- 02_ruway/shuma/sandbox/shuma-line/Cargo.toml +14 −0
- 02_ruway/shuma/sandbox/shuma-line/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-line/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-line/src/ansi.rs +347 −0
- 02_ruway/shuma/sandbox/shuma-line/src/complete.rs +462 −0
- 02_ruway/shuma/sandbox/shuma-line/src/continuation.rs +309 −0
- 02_ruway/shuma/sandbox/shuma-line/src/decorate.rs +762 −0
- 02_ruway/shuma/sandbox/shuma-line/src/dialect.rs +25 −0
- 02_ruway/shuma/sandbox/shuma-line/src/editor.rs +281 −0
- 02_ruway/shuma/sandbox/shuma-line/src/ghost.rs +63 −0
- 02_ruway/shuma/sandbox/shuma-line/src/icon.rs +207 −0
- 02_ruway/shuma/sandbox/shuma-line/src/lexer.rs +341 −0
- 02_ruway/shuma/sandbox/shuma-line/src/lib.rs +48 −0
- 02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs +127 −0
- 02_ruway/shuma/sandbox/shuma-line/src/token.rs +76 −0
- 02_ruway/shuma/sandbox/shuma-link/Cargo.toml +21 −0
- 02_ruway/shuma/sandbox/shuma-link/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-link/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-link/src/channel.rs +231 −0
- 02_ruway/shuma/sandbox/shuma-link/src/handshake.rs +313 −0
- 02_ruway/shuma/sandbox/shuma-link/src/identity.rs +264 −0
- 02_ruway/shuma/sandbox/shuma-link/src/lib.rs +51 −0
- 02_ruway/shuma/sandbox/shuma-link/src/peers.rs +202 −0
- 02_ruway/shuma/sandbox/shuma-module-canvas/Cargo.toml +14 −0
- 02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs +768 −0
- 02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml +14 −0
- 02_ruway/shuma/sandbox/shuma-module-commandbar/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-commandbar/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs +512 −0
- 02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml +15 −0
- 02_ruway/shuma/sandbox/shuma-module-launcher/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-launcher/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs +372 −0
- 02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml +22 −0
- 02_ruway/shuma/sandbox/shuma-module-matilda/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-matilda/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs +1128 −0
- 02_ruway/shuma/sandbox/shuma-module-minga/Cargo.toml +19 −0
- 02_ruway/shuma/sandbox/shuma-module-minga/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-minga/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs +720 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml +29 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs +138 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs +181 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs +1733 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs +1737 −0
- 02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs +2052 −0
- 02_ruway/shuma/sandbox/shuma-module/Cargo.toml +14 −0
- 02_ruway/shuma/sandbox/shuma-module/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-module/src/lib.rs +572 −0
- 02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml +19 −0
- 02_ruway/shuma/sandbox/shuma-protocol/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-protocol/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs +644 −0
- 02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml +16 −0
- 02_ruway/shuma/sandbox/shuma-remote-exec/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-remote-exec/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs +1205 −0
- 02_ruway/shuma/sandbox/shuma-session/Cargo.toml +11 −0
- 02_ruway/shuma/sandbox/shuma-session/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-session/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-session/src/lib.rs +420 −0
- 02_ruway/shuma/sandbox/shuma-shell-render/Cargo.toml +12 −0
- 02_ruway/shuma/sandbox/shuma-shell-render/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-shell-render/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs +220 −0
- 02_ruway/shuma/sandbox/shuma-sysmon/Cargo.toml +11 −0
- 02_ruway/shuma/sandbox/shuma-sysmon/LEEME.md +9 −0
- 02_ruway/shuma/sandbox/shuma-sysmon/README.md +9 −0
- 02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs +297 −0
- 02_ruway/shuma/shuma-cli/Cargo.toml +23 −0
- 02_ruway/shuma/shuma-cli/LEEME.md +15 −0
- 02_ruway/shuma/shuma-cli/README.md +15 −0
- 02_ruway/shuma/shuma-cli/src/main.rs +740 −0
- 02_ruway/shuma/shuma-daemon/Cargo.toml +34 −0
- 02_ruway/shuma/shuma-daemon/LEEME.md +15 −0
- 02_ruway/shuma/shuma-daemon/README.md +15 −0
- 02_ruway/shuma/shuma-daemon/src/main.rs +1723 −0
- 02_ruway/shuma/shuma-gateway/Cargo.toml +21 −0
- 02_ruway/shuma/shuma-gateway/LEEME.md +9 −0
- 02_ruway/shuma/shuma-gateway/README.md +9 −0
- 02_ruway/shuma/shuma-gateway/src/main.rs +168 −0
- 02_ruway/shuma/shuma-shell-llimphi/Cargo.toml +46 −0
- 02_ruway/shuma/shuma-shell-llimphi/LEEME.md +15 −0
- 02_ruway/shuma/shuma-shell-llimphi/README.md +15 −0
- 02_ruway/shuma/shuma-shell-llimphi/src/config.rs +252 −0
- 02_ruway/shuma/shuma-shell-llimphi/src/main.rs +675 −0
- 02_ruway/shuma/shuma-shell-llimphi/src/menu.rs +369 −0
- 02_ruway/shuma/shuma-shell-llimphi/src/update.rs +666 −0
- 02_ruway/shuma/shuma-shell-llimphi/src/view.rs +447 −0
- Cargo.lock +8601 −0
- Cargo.toml +477 −0
- LICENSE +21 −0
- README.md +34 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/02_ruway/shuma/LEEME.md b/02_ruway/shuma/LEEME.md
new file mode 100644
index 0000000..fed95fe
--- /dev/null
+++ b/02_ruway/shuma/LEEME.md
@@ -0,0 +1,72 @@
+# shuma
+
+> Shell interactivo con paridad zsh/fish, sobre chasis Llimphi.
+
+`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). Roadmap de 8 bloques (target 2026-05-25). `matilda` es la herramienta hermana para configuración declarativa multi-host.
+
+## Instalación
+
+```sh
+# shell desktop
+cargo run --release -p shuma-shell-llimphi
+
+# CLI puro
+cargo run --release -p shuma-cli
+
+# daemon (multi-sesión persistente)
+cargo run --release -p shuma-daemon
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — shell + UI Llimphi.
+- **Wawa** — corre adentro del kernel.
+- Protocolo `shuma-protocol` permite cliente local + server remoto sin SSH.
+
+## Crates: shuma
+
+| Crate | Rol |
+|---|---|
+| [`shuma-core`](shuma-core/README.md) | Tipos: Session, Command, Output. |
+| [`shuma-cli`](shuma-cli/README.md) | CLI (no Llimphi). |
+| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de sesiones. |
+| [`shuma-shell-llimphi`](shuma-shell-llimphi/README.md) | Shell con UI Llimphi. |
+| [`shuma-shell-render`](shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). |
+| [`shuma-protocol`](shuma-protocol/README.md) | Protocolo wire (reemplazo de SSH/mosh). |
+| [`shuma-gateway`](shuma-gateway/README.md) | Gateway de sesiones remotas. |
+| [`shuma-remote-exec`](shuma-remote-exec/README.md) | Exec remoto vía gateway. |
+| [`shuma-session`](shuma-session/README.md) | Sesión persistente. |
+| [`shuma-history`](shuma-history/README.md) | History con búsqueda fuzzy. |
+| [`shuma-exec`](shuma-exec/README.md) | Ejecutor de comandos. |
+| [`shuma-line`](shuma-line/README.md) | Readline (edición · completion · highlight). |
+| [`shuma-config`](shuma-config/README.md) | Config del shell. |
+| [`shuma-intent`](shuma-intent/README.md) | Intent → comando (predictor). |
+| [`shuma-infer`](shuma-infer/README.md) | Inferencia para `intent`. |
+| [`shuma-discern`](shuma-discern/README.md) | Discriminador comando-vs-texto. |
+| [`shuma-link`](shuma-link/README.md) | Links clickables en output. |
+| [`shuma-sysmon`](shuma-sysmon/README.md) | Monitor de sistema embebido. |
+| [`shuma-card`](shuma-card/README.md) | Card escritorio. |
+| [`shuma-module`](shuma-module/README.md) | Trait módulo del chasis. |
+| [`shuma-module-shell`](shuma-module-shell/README.md) | Módulo shell (Main slot). |
+| [`shuma-module-commandbar`](shuma-module-commandbar/README.md) | Módulo command bar (TopBar). |
+| [`shuma-module-launcher`](shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). |
+| [`shuma-module-matilda`](shuma-module-matilda/README.md) | Módulo matilda integrado. |
+
+## Crates: matilda (declarative host config)
+
+| Crate | Rol |
+|---|---|
+| [`matilda-core`](matilda/matilda-core/README.md) | Modelo de config declarativa. |
+| [`matilda-config`](matilda/matilda-config/README.md) | Loader de archivos. |
+| [`matilda-plan`](matilda/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). |
+| [`matilda-apply`](matilda/matilda-apply/README.md) | Ejecutor del plan. |
+| [`matilda-discover`](matilda/matilda-discover/README.md) | Descubrimiento de estado actual. |
+| [`matilda-linker`](matilda/matilda-linker/README.md) | Enlaza dotfiles. |
+| [`matilda-ghost`](matilda/matilda-ghost/README.md) | Modo dry-run. |
+| [`matilda-app`](matilda/matilda-app/README.md) | CLI/UI. |
+
+## Consideraciones
+
+- **Reemplazo, no añadido.** Si usás shuma, podés desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto.
+- **`intent → comando`** es opcional; sin LLM corre el shell tradicional sin diferencia.
+- Sesiones remotas usan **`shuma-protocol`** sobre TCP/TLS — no requiere demonio SSH.
diff --git a/02_ruway/shuma/README.md b/02_ruway/shuma/README.md
new file mode 100644
index 0000000..4f1b379
--- /dev/null
+++ b/02_ruway/shuma/README.md
@@ -0,0 +1,27 @@
+# shuma
+
+> Interactive shell with zsh/fish parity, on a Llimphi chassis.
+
+`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-block roadmap (target 2026-05-25). `matilda` is the sibling tool for declarative multi-host configuration.
+
+## Install
+
+```sh
+cargo run --release -p shuma-shell-llimphi
+cargo run --release -p shuma-cli
+cargo run --release -p shuma-daemon
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — shell + Llimphi UI.
+- **Wawa** — runs inside the kernel.
+- `shuma-protocol` enables local-client + remote-server without SSH.
+
+Crates listed in [README.md](README.md) (shuma + matilda).
+
+## Considerations
+
+- **Replacement, not addition.** If you use shuma, you can uninstall zsh/tmux/mosh; behavior fully covered.
+- **`intent → command`** is optional; without LLM the traditional shell runs unchanged.
+- Remote sessions use **`shuma-protocol`** over TCP/TLS — no SSH daemon required.
diff --git a/02_ruway/shuma/README.qu.md b/02_ruway/shuma/README.qu.md
new file mode 100644
index 0000000..75bcfef
--- /dev/null
+++ b/02_ruway/shuma/README.qu.md
@@ -0,0 +1,29 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# shuma
+
+> Shell interactivo zsh/fish kasqaqlla, Llimphi chasis patanpi.
+
+`shuma` zsh + tmux + mosh suyaspa, sapan p'aki hina: shell history/completion/job-control, natural multiplexing (manan `tmux`), karu sesiones (manan `mosh`), Llimphi 4-slot chasispi (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-bloque roadmap (suyay 2026-05-25). `matilda` huk multi-host declarativo herramienta.
+
+## Churay
+
+```sh
+cargo run --release -p shuma-shell-llimphi
+cargo run --release -p shuma-cli
+cargo run --release -p shuma-daemon
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — shell + Llimphi UI.
+- **Wawa** — kernel ukhupi.
+- `shuma-protocol` lokal-cliente + karu-server, mana SSHwan.
+
+Crateskuna [README.md](README.md)-pi.
+
+## Yuyaykunaq
+
+- **Suyay, mana aymachay.** Shuma usaspa, zsh/tmux/mosh wikch'ay atinki.
+- **`intent → comando`** opcional; mana LLM tradicional shell hina.
+- Karu sesiones **`shuma-protocol`** TCP/TLS patanpi — manan SSH daemon munana.
diff --git a/02_ruway/shuma/REPORTE.md b/02_ruway/shuma/REPORTE.md
new file mode 100644
index 0000000..ea2696b
--- /dev/null
+++ b/02_ruway/shuma/REPORTE.md
@@ -0,0 +1,367 @@
+# shuma — reporte técnico para IA
+
+> Estado: **2026-05-31** · rama `main` · compila limpio (`cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway`).
+> Audiencia: sesión de Claude futura u otra IA que retome el shell+plugins. Idioma del proyecto: español.
+
+## Estado (2026-05-31)
+
+### Hecho
+
+- **Chasis Llimphi completo** (`shuma-shell-llimphi`): slots TopBar/Main/BottomBar +
+ tabs, monitores (CPU/MEM) con splitter, i18n vía `rimay_localize`, theme/locale
+ vivos desde el bus `wawa-config`.
+- **Bloques A–F del roadmap cerrados (2026-05-28/29)**: REPL usable (streaming no
+ bloqueante, decoración clickeable del output, `LineState` con completion+ghost,
+ historial JSONL+fuzzy, PTY+vt100 con resize dinámico, paste con bracketed paste,
+ 33/33 tests); daemon como ejecutor (local/daemon/`DaemonTcp` Noise_XK) + sidecar
+ al broker; launcher y commandbar reales (Cmd-P con nucleo_matcher); integración
+ wawa (watcher + theme/lang live); limpieza (SO_PEERCRED, parser de bindings, lienzo).
+- **Lienzo de intenciones shell↔canvas** (2026-05-29): cada `start_run` aparece como
+ `%cN` en el `SessionGraph`; nodo verde/rojo según exit; canvas clickeable que
+ inserta `%cN`/`%pN` en el cursor del shell.
+- **Adiós al Quake-drawer** (2026-05-29): el chasis es app standalone normal (tabs
+ siempre visibles); el overlay launcher vive en `pata` (antes en el retirado
+ `mirada-launcher-llimphi`).
+- **vim como card themeable** (2026-05): PTY con skin app-aware, drag-to-select +
+ copia al clipboard, paste con click derecho/medio, iconitos por tipo en paths.
+- **Menús** (lote 4): menú principal + menús contextuales en el chasis.
+- **Stack matilda** (`baremetal/`): config declarativa multi-host (core/plan/discover/
+ config/apply/ghost/linker/app) + `shuma-module-matilda` (tab con SSH real).
+- **Refactors regla #1**: split de `shuma-module-shell` (3028 LOC), `shuma-core`
+ (1517) y `shuma-shell-llimphi` main (1522) en módulos.
+
+### Pendiente
+
+- **E2 — hover trigger del drawer**: bloqueado por dispatching de pointer enter/leave
+ en `llimphi-ui`.
+- **Mouse en el PTY**: vt100 ya parsea los eventos; falta cablear el mouse de Llimphi.
+- **Tooltip "what would clicking this do?"** en decoraciones (espera al hover de llimphi-ui).
+- **Cablear `shuma-line::decorate`** completo desde más consumidores (ya hace mucho,
+ poco consumido).
+- **Daemon**: lockfile + check de PID vivo (hoy ignora bind si el socket existe).
+- **Placeholders residuales**: aunque launcher/commandbar ya tienen impl real, varios
+ crates sandbox del listado siguen sin app que los consuma directamente.
+
+---
+
+## 1. Mapa del subárbol `02_ruway/shuma/`
+
+```
+shuma/
+├── shuma-cli/ ← CLI admin del daemon (postcard sobre Unix socket)
+├── shuma-daemon/ ← runtime: dueño de Workspaces, admin socket, reaper
+├── shuma-gateway/ ← bridge HTTP/JSON ↔ postcard (1 endpoint: POST /rpc)
+├── shuma-shell-llimphi/ ← CHASIS gráfico (Llimphi) — host de los módulos
+├── baremetal/ ← stack de "matilda" (admin server declarativa)
+│ ├── matilda-core, -plan, -discover, -apply, -ghost, -linker, -config, -app
+└── sandbox/ ← crates de soporte del shell (sync, agnósticos de UI)
+ ├── shuma-card ← Workspace/Pipeline/CommandRef → card_core::Card
+ ├── shuma-core ← runtime in-memory (Mutex<HashMap>), reap, persist
+ ├── shuma-protocol ← wire postcard u32-BE-prefix (daemon ↔ cli/gui)
+ ├── shuma-discern ← discerners (magic-bytes, JSON, TOML, UTF8, Card)
+ ├── shuma-exec ← ejecución sync: Direct / Shell / Pty; eventos mpsc
+ ├── shuma-link ← Noise_XK + identity X25519 + FramedChannel
+ ├── shuma-remote-exec ← cliente sync del ExecStream del daemon
+ ├── shuma-line ← lex/parse/decorate/complete del input (sin frontend)
+ ├── shuma-history ← JSONL append-only + fuzzy (nucleo_matcher)
+ ├── shuma-session ← WorkSession (cwd, runs, grupos)
+ ├── shuma-intent ← grafo de intenciones %cN/%pN
+ ├── shuma-shell-render ← CanvasPlan (lienzo de contexto agnóstico)
+ ├── shuma-sysmon ← /proc/stat + /proc/meminfo + historial
+ ├── shuma-module ← contrato estructural de módulos (sin trait dyn)
+ ├── shuma-module-shell ← MVP REPL (sh -c, sync, builtins)
+ ├── shuma-module-matilda ← admin declarativa como tab del shell
+ ├── shuma-module-launcher ← PLACEHOLDER
+ └── shuma-module-commandbar ← PLACEHOLDER
+```
+
+---
+
+## 2. Arquitectura en una pantalla
+
+```
+ ┌────────────────────────────┐
+ shuma-cli ─postcard──┐ │ shuma-shell-llimphi │
+ shuma-gateway ─json──┤ │ (chasis Llimphi) │
+ ▼ │ ┌──────────────────────┐ │
+ ┌─────────────┴┐ │ Slots: │ │
+ │ shuma-daemon │ │ TopBar (launcher) │ │
+ │ (admin sock)│ │ Main (matilda…) │ │
+ │ + reaper │ │ Drawer [shell|…] │ │
+ │ + Workspace │ │ BottomBar (cmdbar) │ │
+ │ Manager │ └──────────────────────┘ │
+ └──────┬───────┘ │ │
+ │ ▼ │
+ │ ┌─────────────────────┐ │
+ │ │ shuma-module-shell │ │
+ │ │ ↓ (cuando se cablee)│ │
+ │ │ shuma-exec / -line │ │
+ │ │ -history / -session│ │
+ │ └─────────────────────┘ │
+ │ ┌─────────────────────┐ │
+ │ │ shuma-module-matilda│ │
+ │ │ → baremetal/matilda│ │
+ │ └─────────────────────┘ │
+ └────────────────────────────────────┘
+ (vía shuma-protocol — hoy NO cableado
+ desde el shell; el shell ejecuta local
+ con sh -c, no via daemon)
+```
+
+**Puntos clave**:
+- El chasis es **static-dispatch**: enum `Kind { Launcher, CommandBar, Shell, Matilda }`. Agregar un módulo = variante + ramas en `update`/`view`. Sortea que `llimphi-ui` no tenga `View::map`.
+- Cada módulo expone `pub fn make(host) -> ...`; el binario `shuma-shell-llimphi` enlaza estáticamente y mapea `ModuleMsg → ShellMsg` con un cierre (`lift`).
+- El daemon, la CLI y el gateway son una **familia paralela** al chasis. El módulo `shuma-module-shell` ejecuta hoy directo con `sh -c` (no habla con el daemon).
+
+---
+
+## 3. Qué está hecho
+
+### 3.1 Chasis gráfico (`shuma-shell-llimphi`, 1 588 LOC)
+- **Layout completo**: TopBar, Main, BottomBar, Drawer-Quake (40 % altura por defecto), monitor stack con stat-cards + curvas (CPU, MEM + monitores aportados por módulos).
+- **Slots configurables** vía `shumarc-modules.toml` (`src/config.rs`): cualquier `id` no compilado se ignora con warning — el shumarc no rompe el arranque.
+- **Drawer**: toggle por F12, cerrar por Esc, click en command-bar abre. *Hover trigger pendiente* (`main.rs:40`: faltan enter/leave events en llimphi-ui).
+- **Toolbar de shortcuts** alimentada por `ModuleContributions` (declarativo).
+- **Resize del panel de monitores** con drag (splitter).
+- **i18n**: `rimay_localize::init()` en `main` — todas las cadenas vía `t("shuma-…")`.
+
+### 3.2 Daemon stack (`shuma-daemon` + `shuma-cli` + `shuma-gateway`)
+- **Protocolo** (`shuma-protocol`, 589 LOC): postcard sobre Unix socket; `Request`/`Response` con Workspace CRUD, Run one-shot, Pipeline, ExecStream, Discern, Health, Caps.
+- **Daemon** (1 279 LOC): `WorkspaceManager` (Mutex<HashMap>), reap cada 500 ms, drena pipelines en restart, persist a disco, sidecar pool opcional al broker `card_sidecar`.
+- **CLI** (`shuma`, 740 LOC): subcomandos `ping`, `health`, `caps`, `workspace {create|list|stop}`, `run`, `commands`, `discern`, `pipeline …`.
+- **Gateway HTTP** (168 LOC): `POST /rpc` con body JSON → postcard → daemon. Bind por env `SHIPOTE_GATEWAY_LISTEN`, default `127.0.0.1:7378`. Sin axum/hyper — parser ad-hoc.
+- **Noise_XK** (`shuma-link`, ~860 LOC): handshake, `KnownPeers` (allowlist tipo `authorized_keys`), `Keypair` X25519 en `~/.config/shuma/keys/identity.x25519`, `FramedChannel` (length-prefix + chacha20-poly1305). Listo para reemplazar Unix socket por TCP autenticado.
+- **Discern** (`shuma-discern`): pipeline configurable (MagicBytes → CardProbe → JsonProbe → TomlProbe → Utf8Probe).
+
+### 3.3 Stack matilda (`baremetal/`)
+- **`matilda-core`**: modelo declarativo (Host, Container, VHost, Inventory).
+- **`matilda-plan`**: diff inventario actual vs deseado → `Vec<Action>` ordenado.
+- **`matilda-discover`**: lee estado real (v1: por nombre — detecta creates y orphans, no cambios de config de un recurso existente).
+- **`matilda-config`**: `Container → docker run`, `VHost → server { … }` de nginx. Funciones puras.
+- **`matilda-apply`**: `Action → ApplyStep` (archivos + comandos), agnóstico de transporte.
+- **`matilda-ghost`**: ejecutor local (`set -e`), reporta `ApplyReport`.
+- **`matilda-linker`**: ejecutor SSH (sobre `brahman-ssh-multiplex`), mismo `ApplyReport`.
+- **`matilda-app`** (CLI standalone): `matilda example | plan | script | apply | dry-run` local y remoto.
+- **`shuma-module-matilda`** (1 120 LOC, **el módulo más completo**): tab del shell con inventario + plan + log + monitor de "pasos pendientes" + 3 shortcuts (Discover/Plan/Dry-run). Soporta `Source::Local` y `Source::Remote { host, user }` con SSH real. Recarga inventario desde el shumarc.
+
+### 3.4 Línea + ejecución sync (sandbox)
+Cinco crates listos pero **NO enchufados** al `shuma-module-shell` actual:
+- **`shuma-exec`** (PTY incluido): `Exec::{Direct, Shell, Pty}`, eventos por mpsc (`Stdout`/`Stderr`/`Bytes`/`Truncated`/`Spilled`/`Done`), capture-limit + spill a disco, splice(2) zero-copy.
+- **`shuma-line`**: tokenize + clasificación, `split_pipeline`, `complete` (con `flag_hints`), `ghost_suggestion`, `decorate_line` (paths clickeables, URLs, grep refs, SHA, `#NN`), `needs_continuation`, parser ANSI completo.
+- **`shuma-history`**: JSONL append-only, fuzzy con nucleo_matcher, dedup configurable.
+- **`shuma-session`**: WorkSession con cwd, `CommandRun` (estado + salida acotada), grupos guardados.
+- **`shuma-shell-render`**: CanvasPlan (lienzo de contexto del grafo de intenciones, agnóstico de UI).
+- **`shuma-remote-exec`**: cliente sync del subprotocolo `ExecStream` del daemon — API espejo de `shuma-exec::RunHandle`. Listo para reemplazar `sh -c` por *ejecución contra el daemon*.
+
+### 3.5 Estado actual del REPL (`shuma-module-shell`, ~2000 LOC)
+
+**Bloque A completo (2026-05-28).** El REPL ya es una pieza usable.
+
+- **A1** ejecución no bloqueante: streaming via `shuma-exec`, drenado por `Msg::ShellTick` a 100 ms. Cola si hay run vivo. Cancel = SIGKILL al grupo (`process_group(0)` + `killpg`).
+- **A2** decoración del output: `shuma_line::decorate_line` por línea; paths/URLs/grep-refs/issue/box-draw → `theme.accent`; git SHAs → `theme.fg_muted`.
+- **A3** input inteligente: `LineState` con tokens coloreados, cursor visible, ghost suggestion del historial. Tab completion (binarios en `$PATH` + paths bajo cwd + flag hints + prefijo común con N candidatos). ArrowRight al final acepta ghost. Ctrl+Arrow palabra, Home/End.
+- **A4** historial durable: JSONL en `$XDG_DATA_HOME/shuma/history.jsonl`. Up/Down navegan; Ctrl-R abre overlay `fuzzy_search`.
+- **A5** PTY + vt100: allowlist + prefijo `:tui` → `Exec::Pty`. `vt100::Parser` alimentado por bytes; render del panel = grid de celdas con `paint_with`. Teclas → xterm bytes.
+- **A6** resize dinámico del PTY: `shuma_exec::RunHandle::resize(rows, cols)` expuesto vía `MasterPty` en `Arc<Mutex<>>`; `tui_panel` painter publica el `PaintRect` en `state.last_tui_rect`; cada `drain_run` mira si cambió y manda `MasterPty::resize` + reescala el screen del `vt100::Parser`. vim/htop reciben SIGWINCH y reflowean.
+- **A7** click handlers en decoraciones: `Msg::OpenDecoration(DecorationKind)`. Path-dir → cd (más recálculo del `ShellSource`); Path-executable → llena el input con el path; Path-archivo / URL → `xdg-open` detached; GrepRef → `$EDITOR +line file`; GitSha → llena el input con `git show <sha>`. Render del output ahora es `FlexDirection::Row` con un nodo por span (los actionables llevan `on_click`).
+- **A8** paste + bracketed paste: Ctrl-V y Shift+Insert leen el clipboard (vía `arboard`). Sin TUI → `LineState::insert`. Con TUI → `RunHandle::write_input`; si el child habilitó bracketed paste (DECSET 2004, leído de `screen.bracketed_paste()`), la secuencia se envuelve en `\x1b[200~…\x1b[201~` para que vim/emacs distingan tipeo de pegado.
+- Builtins: `cd`, `pwd`, `clear`, `exit`. Tope 500 líneas en el buffer.
+- Tests: **33/33 verde** (timing del ejecutor, navegación de historial, tab/ghost/clicks/paste, build_spec routing, key→PTY bytes, palette ansi, partition_line, decoration handlers, PTY resize end-to-end con `stty size`).
+
+---
+
+## 4. Wawa — qué hay y qué falta
+
+`wawa-config` (en `shared/wawa-config`) es el **bus de configuración del SO wawa**: archivo JSON canónico (system: `/etc/wawa/config.json`, user: `$XDG_CONFIG_HOME/wawa/config.json`), watcher `notify` sobre ambos paths, atomic save (`tmp + rename`). Sin daemon pub-sub: las apps leen el archivo y se suscriben a cambios. Esto sobrevive a la transición Linux → arje (cuando wawa sea su propio SO, `system_path()` cambia, el resto no).
+
+Forma actual de la config:
+```json
+{
+ "theme_variant": "dark", "accent": "default",
+ "lang": "es-PE", "timefmt_24h": true,
+ "modules": { "mirada": true, "shuma": true, "chasqui": true,
+ "akasha": true, "minga": true, "agora": true }
+}
+```
+
+**Estado de la integración shuma ↔ wawa (2026-05-28): activa.**
+
+- `shuma-shell-llimphi::init` carga `WawaConfig::load()` + `theme_from_wawa(&wawa, &Theme::dark())` + `rimay_localize::set_locale(&wawa.lang)` antes de armar las instancias, así el primer render ya sale con el theme y locale correctos.
+- Un `wawa_config::ConfigWatcher` corre en background; cada cambio dispara `Msg::WawaConfigChanged(Box<WawaConfig>)` vía `Handle::dispatch`.
+- El handler re-arma `m.theme` con el nuevo variant/accent (fallback al theme actual si el variant es desconocido) y reinvoca `set_locale` — sin reiniciar el chasis, sin re-cargar las instancias. Los próximos `view()` ya pintan con la paleta nueva; los strings que viajan por `t(...)` también se rehidratan al cambiar.
+
+**Contrato dividido (D3):**
+
+- **`shumarc-modules.toml`** (TOML, project-local): topología de la UI del shell — qué módulo se monta en qué slot (TopBar/Main/BottomBar/Drawer), labels custom, Source (Local/Daemon/DaemonTcp/Remote). Esto es estructura de la app y vive con la app.
+- **`$XDG_CONFIG_HOME/wawa/config.json`** (JSON, perfil del usuario): preferencias visuales (`theme_variant`, `accent`), locale (`lang`), formato del reloj (`timefmt_24h`), bitmask de qué apps están on (`modules.{shuma, mirada, pluma, …}`). Esto es preferencia del usuario y es compartida por **todas** las apps Llimphi de gioser (pluma, dominium, cosmos, nada, nakui, shuma…).
+
+El toggle `modules.shuma = false` en el JSON wawa no apaga el binario corriendo (el chasis no se suicida); el efecto es que los launchers no listan a shuma como app activa. La supervisión del binario en sí es decisión del SO (wawa-init en el futuro arje, o systemd/manual hoy).
+
+---
+
+## 5. Plan propuesto (priorizado)
+
+### Bloque A — desbloquear el REPL ✅ **completo (2026-05-28)**
+Ver §3.5 para el detalle del estado actual. Resumen:
+
+- A1 ✅ ejecución no bloqueante + cola + cancel SIGKILL al grupo
+- A2 ✅ decoración del output (paths/URLs/SHAs/grep-refs/issue/box-draw)
+- A3 ✅ LineState + tokens coloreados + Tab completion + ghost
+- A4 ✅ historial durable JSONL + Up/Down + Ctrl-R fuzzy overlay
+- A5 ✅ PTY + emulador vt100 (vía `vt100` crate) + render de grid
+- A6 ✅ resize dinámico del PTY (`RunHandle::resize` + tracking del PaintRect del panel)
+- A7 ✅ click handlers sobre decoraciones (Path/Url/GrepRef/GitSha)
+- A8 ✅ paste con bracketed paste (`arboard` + DECSET 2004)
+
+Pendientes opcionales (no bloquean nada):
+- Mouse en el PTY (vt100 ya parsea los eventos; falta cablear el mouse de Llimphi).
+- Tooltip "what would clicking this do?" en decoraciones (espera al hover en llimphi-ui).
+
+### Bloque B — integrar el daemon como ejecutor ✅ **completo (2026-05-28)**
+
+B1 ✅ **Runner enum local/daemon** en `shuma-module-shell`. `BackendHandle` envuelve `shuma_exec::RunHandle` y `shuma_remote_exec::RemoteRunHandle` con la misma API (`try_events`, `is_finished`, `kill`, `write_input`, `resize` — write/resize son no-op en remoto). `Source` extendido con variantes `Daemon { socket: Option<PathBuf>, label }` y `DaemonTcp { addr, server_pub_hex, label }`; `start_run` rutea según la variante. PTY siempre cae a local con notice (daemon no soporta PTY remoto).
+
+B2 ✅ **Source remoto via Noise XK**. `Source::DaemonTcp` consume `shuma_remote_exec::run_tcp`. Identidad X25519 del shell persiste vía `shuma_link::Keypair::load_or_generate(Keypair::default_path())` — primer arranque genera, después se reusa. `server_pub_hex` parseado con `PublicKey::from_hex`. Errores (no hay daemon, pubkey errónea) salen como notice en el output sin tumbar el shell.
+
+B3 ✅ **Sidecar broker en daemon**. `WorkspaceCreate` ahora llama a `pool.spawn(build_workspace_card(label, id))` cuando hay pool — cada workspace se publica al broker como `Card { kind: Ente, lifecycle: Daemon, flow: ["commands"] }` (paralelo a la `shuma.daemon` card que ya existía). `announce_edges_to_broker` para edges de pipeline ya estaba.
+
+### Bloque C — módulos placeholder ✅ **completo (2026-05-28)**
+
+C1 ✅ **launcher real con manifests**. `shuma-module-launcher` ahora lee `$XDG_CONFIG_HOME/shuma/apps/*.toml` (orden alfabético) en `State::from_apps_dir()`. Cada manifest es `{label, exec?, action_id?}`; si tiene `exec`, click → spawn detached (`process_group(0)`); si no, emite `Msg::EntryClicked(action_id)` al chasis. Si el dir no existe o no hay manifests válidos, cae a `State::demo()` para que el chasis siga exploratorio. Chasis llama a `from_apps_dir()` en lugar de `demo()`.
+
+C2 ✅ **commandbar real Cmd-P**. `shuma-module-commandbar` ahora trae catálogo de `CommandEntry { label, category, kind: FocusTab|Exec|Action }` provisionable vía `State::set_catalog`. Tipear filtra con `nucleo_matcher::Pattern::score`; Up/Down navegan; Enter activa (`activation_for(&state, &ev)` retorna `CommandKind`); Escape limpia; click en row → `ActivateAt(idx)`. Modo `Launcher` usa el catálogo, modo `Shell` ejecuta la línea tal cual (`CommandKind::Exec(text)`). Dropdown se muestra encima de la barra con hasta 8 matches.
+
+### Bloque D — wawa integration
+D1. Suscribir watcher `wawa-config` en `shuma-shell-llimphi::main`.
+D2. Reaccionar a cambios de `theme_variant`/`accent`/`lang` sin reiniciar (`Theme::for_variant` + `rimay_localize::set_lang`).
+D3. Documentar el contrato: el shumarc topología (qué módulos en qué slots) sigue siendo TOML aparte; el JSON wawa es para preferencias visuales y toggle de apps.
+
+### Bloque E — limpieza pendiente
+E1 ✅ `audit_request(peer: &str, req)` — Unix socket pasa `uid:1000` desde `SO_PEERCRED`; TCP autenticado pasa `pubkey:<16 hex>` (primeros 16 chars de la X25519 del peer).
+E2 ⏳ Hover trigger del drawer Quake — bloqueado por dispatching de `on_pointer_enter/leave` en `llimphi-ui` (WIP del usuario en curso; los campos y métodos públicos existen pero el runtime no los emite todavía).
+E3 ✅ Parser real de teclas en shumarc — `parse_binding` acepta `Ctrl+Shift+Space`, `Super+grave`, `Alt+F1`, etc. Modifiers: `Ctrl/Alt/Shift/Super` (con alias `Meta/Cmd/Win`). Named keys: F1..F24, Escape, Enter, Space, Tab, Backspace, Delete, Home, End, PageUp/Down, Arrows, Insert, grave. Tests cubren combos.
+E4 ✅ `shuma-module-canvas` consume el `SessionGraph` directo (layout in-tree para no arrastrar `pineal-render` al chasis).
+
+### Bloque F — features grandes (post-A/B)
+F1. Lienzo de contexto: panel adicional que renderice `shuma-intent::SessionGraph` con `shuma-shell-render::CanvasPlan`. El grafo `%cN`/`%pN` ya existe en `shuma-intent`; falta la UI y el parser de intents en la commandbar.
+F2. Job control en el módulo shell: `:jobs`, `:term`, `:stop`, `:cont`, sufijo `&` (`shuma-exec` ya soporta multi-run + kill).
+F3. Editor multi-línea: `shuma-line::continuation::needs_continuation` ya está; falta cablear al input.
+
+---
+
+## 6. Decisiones de diseño que conviene preservar
+
+1. **Static dispatch sobre trait objects**: `Kind` enum + `ModuleState` enum. Coste: una rama por módulo en `update`/`view`. Beneficio: cada módulo declara su `Msg` propio sin pelearse con `Box<dyn Any>` y sin downcast.
+2. **Sync por dentro, async sólo en bordes**: `shuma-exec`/`shuma-remote-exec` son sync (threads + mpsc); el daemon es tokio. El shell es sync — drena eventos en cada `Tick`. No tirar este patrón "porque tokio es lo moderno": Llimphi es sync.
+3. **El módulo no depende de `llimphi-ui` desde `shuma-module`**: sólo desde su crate concreto. Esto deja `shuma-module` (el contrato) testeable sin display.
+4. **El daemon ignora errores de bind si existe el socket** (`main.rs:30`): asume restart limpio. *Pendiente*: lockfile + check de PID vivo.
+5. **El gateway no usa axum**: parser HTTP ad-hoc en ~120 LOC. No agregar axum sólo para "ser idiomático" — un POST único no lo justifica.
+6. **Notación de slots** del shumarc: `[topbar]`, `[main]`, `[bottombar]`, `[[drawer.tabs]]`. Mantener — está documentada en `config.rs:1-44`.
+7. **`shuma-protocol::DEFAULT_SOCK_NAME = "shuma.sock"`** en `$XDG_RUNTIME_DIR`. No mover.
+
+---
+
+## 7. Trampas conocidas
+
+- **El binario `shuma-shell` GPUI (3.7k LOC) ya no existe** — se borró en `b92b643`. Cualquier referencia a "shuma-shell" en docs viejas es a esa versión. Las features grandes (completion, decoración, historial) viven en sandbox/* sueltas, no en un shell ensamblado.
+- **`russh v0.54.5`** dispara warning de future-incompat — no bloquea, llega vía `matilda-linker`.
+- **`gpui extinto en gioser** (memoria del proyecto): nada nuevo sobre GPUI. Todo gráfico es Llimphi.
+- **El módulo matilda en remoto SÍ ejecuta SSH real** (vía `matilda-linker`/`brahman-ssh-multiplex`); las pruebas reales necesitan un servidor con sshd alcanzable.
+- **`shuma-line::decorate` ya hace mucho** (paths clickeables, URLs, SHAs, grep refs) pero ningún consumidor lo usa hoy — fácil ganancia al cablearlo a `shuma-module-shell`.
+
+---
+
+## 8. Ranking de prioridad
+
+| # | Tarea | Ganancia | Costo |
+|---|-------|----------|-------|
+| ✅ | A1..A8 — bloque REPL extendido | shell completo | hecho 2026-05-28 |
+| ✅ | B1..B3 — daemon ejecutor + broker | shell remoto + observable | hecho 2026-05-28 |
+| ✅ | C1..C2 — launcher + commandbar reales | palette Cmd-P + apps | hecho 2026-05-28 |
+| ✅ | D1..D3 — wawa watcher + theme/lang live | preferencias unificadas | hecho 2026-05-28 |
+| ✅ | E1 E3 E4 — limpieza | SO_PEERCRED, parser bindings, lienzo | hecho 2026-05-28 |
+| ✅ | F2 F3 F1 — features grandes | jobs, multi-línea, lienzo | hecho 2026-05-28 |
+| ✅ | shell↔canvas live | runs del shell aparecen como `%cN` en el lienzo | hecho 2026-05-29 |
+| ⏳ | E2 — hover trigger drawer | requiere WIP llimphi-ui (pointer events) | bloqueado |
+
+**Integración shell↔canvas (2026-05-29).** `shuma-module-shell` mantiene
+su propio `SessionGraph` (campo `intent_graph` en `State`) y registra
+cada `start_run` como `%cN`. `drain_run` acumula bytes de
+stdout/stderr/raw y al cerrar el run llama `complete(id, ok, bytes)` —
+nodo verde si `exit 0`, rojo en cualquier otro caso (incluidos errores
+de spawn del backend remoto). Builtins (`cd`/`pwd`/`clear`/`exit` y los
+`:jobs/:term/:stop/:cont`) no entran al grafo. El chasis añadió
+`Kind::Canvas` con tab nuevo "Lienzo" en el drawer por defecto; cada
+`SHELL_TICK` (~100 ms) `sync_canvas_from_primary_shell` empuja el grafo
+del primer shell encontrado a todas las instancias canvas con
+`Msg::SyncGraph(graph)`. El lienzo refleja al instante el flujo de la
+sesión (3 tests nuevos en `shell` + 1 en `canvas`).
+
+**Adiós al Quake-drawer (2026-05-29, tercer bloque).** El chasis dejó
+de ser una imitación del launcher overlay y volvió a ser app standalone
+normal: tabs siempre visibles, sin F12, sin Esc-cierra-drawer, sin
+overlay absoluto sobre el escritorio. Eso vive en `pata` (antes en el
+retirado `mirada-launcher-llimphi`). Cambios concretos:
+
+- `Model`: `drawer_tabs` → `tabs`, `active_drawer_tab` → `active_tab`,
+ fuera `drawer_open` y `drawer_trigger`.
+- `Msg`: fuera `ToggleDrawer`, `CloseDrawer`, `SelectDrawerTab` →
+ queda `SelectTab(usize)`.
+- `Slot::DrawerTab(usize)` → `Slot::Tab(usize)`.
+- `on_key` ya no atrapa F12 ni Esc; `forward_key_to_focused_shell`
+ prioriza `Slot::Main` y cae al `tabs[active_tab]`.
+- `render_main_area` se simplificó: si el shumarc declara `[main]`,
+ ocupa todo el área (sin tabs ni monitores). Si no, tabs + splitter
+ con monitores a la derecha. No hay más `Position::Absolute` ni
+ capas overlay.
+- shumarc TOML: `[[drawer.tabs]]` → `[[tabs]]`, fuera `[drawer.trigger]`.
+- `parse_binding` + `matches_key` + `tests_bindings` (todo para
+ reconocer el shortcut de toggle drawer) borrados.
+- i18n: `shuma-empty-no-drawer-tabs`/`-compat` renombrados a
+ `shuma-empty-no-tabs`/`-compat`; `shuma-empty-no-main` y el hint que
+ mencionaba "F12 abre el drawer" eliminados.
+
+**Canvas clickeable (2026-05-29, segundo bloque).** Las cajas del
+lienzo responden al click vía `on_click_at` + `hit_test_box`: el
+primer click enfoca el `%cN` (borde 3.5 px en lugar de 2.0), el
+segundo desenfoca, y un click en vacío también desenfoca. Cuando hay
+un nodo enfocado aparece una tira inferior con la intención completa,
+status y bytes; al lado, dos botones "Insertar %cN" / "Insertar %pN"
+emiten `Msg::InsertRef(text)`. El chasis intercepta esta variante
+(`apply_module_msg` antes de routear al canvas), busca el primer
+`Shell` con `first_shell_slot`, abre+enfoca el drawer si está en una
+tab, y le manda `Msg::InsertAtCursor(text)` al shell. El shell inserta
+en la posición actual del cursor del `LineState`, cierra el overlay
+Ctrl-R si estaba abierto y deja el cursor justo después del texto.
+`SyncGraph` ahora limpia `focused` si el nodo desapareció del snapshot
+nuevo (evita detalle stale). 6 tests nuevos en canvas + 1 en shell.
+
+Pendientes opcionales restantes:
+- Mouse en el PTY (vt100 ya parsea; falta cablear el mouse de Llimphi).
+- Tooltip "what would clicking this do?" en decoraciones (espera al hover de llimphi-ui).
+
+---
+
+## 9. Comandos útiles para retomar
+
+```bash
+# Compilar todo el subárbol shuma
+cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway
+
+# Probar el chasis (necesita servidor gráfico Llimphi)
+cargo run -p shuma-shell-llimphi
+
+# Daemon + CLI rápida
+cargo run -p shuma-daemon &
+cargo run -p shuma-cli -- health
+
+# Gateway HTTP
+SHIPOTE_GATEWAY_LISTEN=127.0.0.1:7378 cargo run -p shuma-gateway
+
+# Estado de los crates sandbox (sin app que los consuma)
+wc -l 02_ruway/shuma/sandbox/*/src/*.rs
+```
+
+---
+
+*Generado por Claude (Opus 4.7) — `2026-05-27`. Si el plan cambia, actualizá la tabla de la §8 antes de tocar la §3.*
diff --git a/02_ruway/shuma/baremetal/matilda-app/Cargo.toml b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml
new file mode 100644
index 0000000..8fffe1a
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "matilda"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — CLI de administración de servidores: carga un inventario, muestra el plan, emite el script y lo aplica (local, remoto por SSH, o en seco)."
+
+[[bin]]
+name = "matilda"
+path = "src/main.rs"
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+matilda-config = { path = "../matilda-config" }
+matilda-plan = { path = "../matilda-plan" }
+matilda-apply = { path = "../matilda-apply" }
+matilda-ghost = { path = "../matilda-ghost" }
+matilda-linker = { path = "../matilda-linker" }
+matilda-discover = { path = "../matilda-discover" }
+clap = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-app/LEEME.md b/02_ruway/shuma/baremetal/matilda-app/LEEME.md
new file mode 100644
index 0000000..2051f13
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/LEEME.md
@@ -0,0 +1,16 @@
+# matilda-app
+
+> CLI/UI de [matilda](../../README.md).
+
+Comandos: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. UI Llimphi opcional para review del plan antes de aplicar.
+
+## Uso
+
+```sh
+cargo run --release -p matilda-app -- apply
+```
+
+## Deps
+
+- Todos los `matilda-*`
+- `clap`, opcional [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/shuma/baremetal/matilda-app/README.md b/02_ruway/shuma/baremetal/matilda-app/README.md
new file mode 100644
index 0000000..397bd56
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/README.md
@@ -0,0 +1,16 @@
+# matilda-app
+
+> CLI/UI of [matilda](../../README.md).
+
+Commands: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. Optional Llimphi UI for plan review before applying.
+
+## Usage
+
+```sh
+cargo run --release -p matilda-app -- apply
+```
+
+## Deps
+
+- All `matilda-*`
+- `clap`, optional [`llimphi-ui`](../../../llimphi/)
diff --git a/02_ruway/shuma/baremetal/matilda-app/src/main.rs b/02_ruway/shuma/baremetal/matilda-app/src/main.rs
new file mode 100644
index 0000000..80cd944
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-app/src/main.rs
@@ -0,0 +1,238 @@
+//! `matilda` — CLI de administración de servidores.
+//!
+//! Carga un inventario declarativo (JSON), lo reconcilia contra el
+//! estado actual y aplica los cambios — localmente, en seco, o en un
+//! servidor remoto por SSH:
+//!
+//! ```text
+//! matilda example imprime un inventario de ejemplo
+//! matilda plan inv.json muestra el plan de reconciliación
+//! matilda script inv.json emite el script de aplicación
+//! matilda apply inv.json aplica localmente
+//! matilda apply inv.json --dry-run simula
+//! matilda apply inv.json --host deploy@srv aplica por SSH
+//! ```
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
+use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
+use matilda_ghost::ApplyReport;
+use matilda_linker::{Linker, SshAuth, SshConfig};
+use matilda_plan::{plan, Op};
+
+#[derive(Parser)]
+#[command(name = "matilda", about = "Administración declarativa de servidores")]
+struct Cli {
+ #[command(subcommand)]
+ cmd: Cmd,
+}
+
+#[derive(Subcommand)]
+enum Cmd {
+ /// Imprime un inventario de ejemplo para editar.
+ Example,
+ /// Muestra el plan de reconciliación del inventario.
+ Plan {
+ inventory: PathBuf,
+ /// Estado actual del servidor (por defecto: vacío).
+ #[arg(long)]
+ current: Option<PathBuf>,
+ /// Descubre el estado actual de esta máquina (docker + nginx).
+ #[arg(long)]
+ discover: bool,
+ },
+ /// Emite el script de shell que aplicaría el plan.
+ Script {
+ inventory: PathBuf,
+ #[arg(long)]
+ current: Option<PathBuf>,
+ #[arg(long)]
+ discover: bool,
+ },
+ /// Aplica el plan: local, en seco, o remoto por SSH.
+ Apply {
+ inventory: PathBuf,
+ #[arg(long)]
+ current: Option<PathBuf>,
+ /// Descubre el estado actual de esta máquina antes de reconciliar.
+ #[arg(long)]
+ discover: bool,
+ /// Simula sin tocar nada.
+ #[arg(long)]
+ dry_run: bool,
+ /// Aplica en un host remoto, `usuario@host`.
+ #[arg(long)]
+ host: Option<String>,
+ /// Contraseña SSH (si no se da, se usa la clave por defecto).
+ #[arg(long)]
+ password: Option<String>,
+ },
+}
+
+/// Carga un inventario JSON desde un archivo.
+fn load(path: &PathBuf) -> Result<Inventory, String> {
+ let text = std::fs::read_to_string(path)
+ .map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?;
+ serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
+}
+
+/// Resuelve el inventario "actual" contra el que reconciliar:
+/// `--discover` observa esta máquina; `--current` lee un archivo; si no,
+/// se parte de un inventario vacío (todo es creación).
+fn current_inventory(
+ discover: bool,
+ current: &Option<PathBuf>,
+ desired: &Inventory,
+) -> Result<Inventory, String> {
+ if discover {
+ // Descubrimiento detallado: `docker inspect` detecta el drift.
+ Ok(matilda_discover::discover_inventory(desired))
+ } else {
+ match current {
+ Some(p) => load(p),
+ None => Ok(Inventory::new()),
+ }
+ }
+}
+
+/// Construye un inventario de ejemplo.
+fn example_inventory() -> Inventory {
+ let mut inv = Inventory::new();
+ inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
+ inv.add_container(
+ Container::new("web", "nginx:1.27")
+ .with_port(8080, 80)
+ .with_volume("/srv/site", "/usr/share/nginx/html")
+ .with_restart(RestartPolicy::Always),
+ );
+ inv.add_container(
+ Container::new("api", "ghcr.io/ejemplo/api:1.0")
+ .with_port(9000, 9000)
+ .with_env("DATABASE_URL", "postgres://db/app")
+ .with_restart(RestartPolicy::UnlessStopped),
+ );
+ inv.add_vhost(
+ VHost::to_container("sitio.com", "web", 80)
+ .with_alias("www.sitio.com")
+ .with_tls(),
+ );
+ inv
+}
+
+/// Imprime un `ApplyReport` legible.
+fn print_report(report: &ApplyReport) {
+ for r in &report.results {
+ println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
+ for l in &r.log {
+ println!(" {l}");
+ }
+ }
+ println!(
+ "\n{} de {} pasos aplicados.",
+ report.applied(),
+ report.results.len()
+ );
+ if !report.all_ok() {
+ println!("✘ se detuvo en el primer error.");
+ }
+}
+
+/// Aplica los pasos en un host remoto por SSH.
+async fn apply_remote(
+ target: &str,
+ password: Option<String>,
+ steps: &[ApplyStep],
+) -> Result<ApplyReport, String> {
+ let (user, host) = target
+ .split_once('@')
+ .ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
+ let auth = match password {
+ Some(pw) => SshAuth::Password(pw),
+ None => {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
+ SshAuth::Key {
+ path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
+ passphrase: None,
+ }
+ }
+ };
+ let config = SshConfig::new(host, user, auth);
+ let linker = Linker::connect(&config)
+ .await
+ .map_err(|e| format!("conexión SSH: {e}"))?;
+ Ok(linker.apply(steps).await)
+}
+
+fn run() -> Result<(), String> {
+ match Cli::parse().cmd {
+ Cmd::Example => {
+ let json = serde_json::to_string_pretty(&example_inventory())
+ .map_err(|e| e.to_string())?;
+ println!("{json}");
+ }
+
+ Cmd::Plan { inventory, current, discover } => {
+ let desired = load(&inventory)?;
+ let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
+ if p.is_empty() {
+ println!("Sin cambios: el servidor ya está al día.");
+ } else {
+ for (i, action) in p.actions.iter().enumerate() {
+ println!("{:>2}. {}", i + 1, action.describe());
+ }
+ println!(
+ "\n{} acciones — {} crear, {} actualizar, {} eliminar.",
+ p.len(),
+ p.count(Op::Create),
+ p.count(Op::Update),
+ p.count(Op::Remove),
+ );
+ }
+ }
+
+ Cmd::Script { inventory, current, discover } => {
+ let desired = load(&inventory)?;
+ let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
+ print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
+ }
+
+ Cmd::Apply { inventory, current, discover, dry_run, host, password } => {
+ let desired = load(&inventory)?;
+ let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
+ let steps = plan_to_steps(&p, &desired);
+ if steps.is_empty() {
+ println!("Sin cambios: nada que aplicar.");
+ return Ok(());
+ }
+ let report = if dry_run {
+ println!("— simulación (no se toca nada) —");
+ matilda_ghost::dry_run(&steps)
+ } else if let Some(target) = host {
+ println!("— aplicando en {target} por SSH —");
+ let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
+ rt.block_on(apply_remote(&target, password, &steps))?
+ } else {
+ println!("— aplicando localmente —");
+ matilda_ghost::apply(&steps)
+ };
+ print_report(&report);
+ if !report.all_ok() {
+ return Err("la aplicación falló".into());
+ }
+ }
+ }
+ Ok(())
+}
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("error: {e}");
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml
new file mode 100644
index 0000000..b1f903d
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "matilda-apply"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — puente plan→ejecución: traduce un Plan de reconciliación a pasos concretos (archivos a escribir + comandos a correr) listos para aplicar en el servidor."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+matilda-plan = { path = "../matilda-plan" }
+matilda-config = { path = "../matilda-config" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-apply/LEEME.md b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md
new file mode 100644
index 0000000..0429010
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md
@@ -0,0 +1,10 @@
+# matilda-apply
+
+> Ejecutor del plan de [matilda](../../README.md).
+
+Aplica `Vec<Action>` de [`matilda-plan`](../matilda-plan/README.md). Cada action loguea + revertible. Para checks duros pide confirmación interactiva.
+
+## Deps
+
+- [`matilda-plan`](../matilda-plan/README.md)
+- `tokio`
diff --git a/02_ruway/shuma/baremetal/matilda-apply/README.md b/02_ruway/shuma/baremetal/matilda-apply/README.md
new file mode 100644
index 0000000..d163512
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/README.md
@@ -0,0 +1,10 @@
+# matilda-apply
+
+> Plan executor of [matilda](../../README.md).
+
+Applies `Vec<Action>` from [`matilda-plan`](../matilda-plan/README.md). Each action is logged + revertible. For hard checks, asks for interactive confirmation.
+
+## Deps
+
+- [`matilda-plan`](../matilda-plan/README.md)
+- `tokio`
diff --git a/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs
new file mode 100644
index 0000000..353c08f
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs
@@ -0,0 +1,208 @@
+//! `matilda-apply` — el puente entre el plan y la ejecución real.
+//!
+//! `matilda-plan` dice *qué* cambiar (una lista ordenada de `Action`s).
+//! Este crate dice *cómo*: traduce cada acción a un [`ApplyStep`]
+//! concreto — los archivos a escribir en el servidor y los comandos a
+//! correr, en orden.
+//!
+//! Sigue siendo **agnóstico de transporte**: no abre conexiones ni
+//! ejecuta nada. Aplicar los pasos —localmente, por SSH o vía el agente
+//! `matilda-ghost`— es trabajo de la capa de I/O. Aquí todo es una
+//! función pura y testeable.
+
+#![forbid(unsafe_code)]
+
+use matilda_config::{docker_run_command, nginx_server_block};
+use matilda_core::Inventory;
+use matilda_plan::{Op, Plan, Resource};
+use serde::{Deserialize, Serialize};
+
+/// Directorio donde matilda deja los `server` de nginx.
+const NGINX_SITES: &str = "/etc/nginx/sites-enabled";
+
+/// Un archivo a escribir en el servidor.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FileWrite {
+ pub path: String,
+ pub content: String,
+}
+
+/// Un paso de aplicación: la traducción concreta de una acción del plan.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ApplyStep {
+ /// Descripción legible de la acción de origen.
+ pub describe: String,
+ /// Archivos a escribir en el servidor (antes de los comandos).
+ pub files: Vec<FileWrite>,
+ /// Comandos de shell a ejecutar, en orden.
+ pub commands: Vec<String>,
+}
+
+/// Ruta del archivo `server` de un dominio.
+fn vhost_path(domain: &str) -> String {
+ format!("{NGINX_SITES}/{domain}.conf")
+}
+
+/// Traduce un plan a pasos concretos de aplicación.
+///
+/// Necesita el inventario **deseado** para conocer los detalles de cada
+/// recurso (imagen del contenedor, upstream del vhost). Las acciones
+/// sobre *hosts* no producen pasos: un host es a qué servidor conectarse,
+/// no algo que se "aplique" en él.
+pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec<ApplyStep> {
+ let mut steps = Vec::new();
+ for action in &plan.actions {
+ let describe = action.describe();
+ let step = match (action.op, action.resource) {
+ // --- Contenedores ---
+ (Op::Create, Resource::Container) => desired
+ .container(&action.name)
+ .map(|c| ApplyStep {
+ describe,
+ files: Vec::new(),
+ commands: vec![docker_run_command(c)],
+ }),
+ (Op::Update, Resource::Container) => desired.container(&action.name).map(|c| {
+ ApplyStep {
+ describe,
+ files: Vec::new(),
+ // Recrear: quitar el viejo, lanzar el nuevo.
+ commands: vec![
+ format!("docker rm -f {}", action.name),
+ docker_run_command(c),
+ ],
+ }
+ }),
+ (Op::Remove, Resource::Container) => Some(ApplyStep {
+ describe,
+ files: Vec::new(),
+ commands: vec![format!("docker rm -f {}", action.name)],
+ }),
+
+ // --- VHosts ---
+ (Op::Create | Op::Update, Resource::VHost) => {
+ desired.vhost(&action.name).map(|v| ApplyStep {
+ describe,
+ files: vec![FileWrite {
+ path: vhost_path(&action.name),
+ content: nginx_server_block(v),
+ }],
+ commands: vec!["nginx -t && nginx -s reload".to_string()],
+ })
+ }
+ (Op::Remove, Resource::VHost) => Some(ApplyStep {
+ describe,
+ files: Vec::new(),
+ commands: vec![
+ format!("rm -f {}", vhost_path(&action.name)),
+ "nginx -t && nginx -s reload".to_string(),
+ ],
+ }),
+
+ // --- Hosts: no se "aplican" (son destino de conexión) ---
+ (_, Resource::Host) => None,
+ };
+ if let Some(step) = step {
+ steps.push(step);
+ }
+ }
+ steps
+}
+
+/// Vuelca los pasos a un script de shell único — útil para revisarlo, o
+/// para ejecutarlo de un tirón en el servidor. Los archivos se emiten
+/// como heredocs.
+pub fn steps_to_script(steps: &[ApplyStep]) -> String {
+ let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n");
+ for step in steps {
+ out.push_str(&format!("\n# {}\n", step.describe));
+ for f in &step.files {
+ out.push_str(&format!("cat > {} <<'MATILDA_EOF'\n", f.path));
+ out.push_str(&f.content);
+ if !f.content.ends_with('\n') {
+ out.push('\n');
+ }
+ out.push_str("MATILDA_EOF\n");
+ }
+ for cmd in &step.commands {
+ out.push_str(cmd);
+ out.push('\n');
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_core::{Container, VHost};
+
+ fn desired() -> Inventory {
+ let mut inv = Inventory::new();
+ inv.add_container(Container::new("web", "nginx:1.27").with_port(8080, 80));
+ inv.add_vhost(VHost::to_container("site.com", "web", 8080));
+ inv
+ }
+
+ #[test]
+ fn fresh_inventory_produces_create_steps() {
+ let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
+ assert_eq!(steps.len(), 2); // un contenedor + un vhost
+ // El contenedor se crea con `docker run`.
+ assert!(steps[0].commands[0].starts_with("docker run -d --name web"));
+ // El vhost escribe su archivo y recarga nginx.
+ assert_eq!(steps[1].files.len(), 1);
+ assert!(steps[1].files[0].path.ends_with("site.com.conf"));
+ assert!(steps[1].commands[0].contains("nginx -s reload"));
+ }
+
+ #[test]
+ fn update_recreates_the_container() {
+ let mut current = Inventory::new();
+ current.add_container(Container::new("web", "nginx:1.25"));
+ let steps = plan_to_steps(&matilda_plan::plan(¤t, &desired()), &desired());
+ let cont = steps.iter().find(|s| s.describe.contains("contenedor")).unwrap();
+ assert_eq!(cont.commands[0], "docker rm -f web");
+ assert!(cont.commands[1].starts_with("docker run"));
+ }
+
+ #[test]
+ fn removal_steps_clean_up() {
+ let mut current = Inventory::new();
+ current.add_container(Container::new("viejo", "img"));
+ current.add_vhost(VHost::to_address("viejo.com", "1.2.3.4:80"));
+ let steps = plan_to_steps(&matilda_plan::plan(¤t, &Inventory::new()), &Inventory::new());
+ let cmds: Vec<&str> = steps
+ .iter()
+ .flat_map(|s| s.commands.iter())
+ .map(|s| s.as_str())
+ .collect();
+ assert!(cmds.iter().any(|c| c.contains("docker rm -f viejo")));
+ assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com")));
+ }
+
+ #[test]
+ fn host_actions_produce_no_steps() {
+ let mut desired = Inventory::new();
+ desired.add_host(matilda_core::Host::new("edge", "10.0.0.1"));
+ let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired);
+ assert!(steps.is_empty());
+ }
+
+ #[test]
+ fn empty_plan_yields_no_steps() {
+ let inv = desired();
+ let steps = plan_to_steps(&matilda_plan::plan(&inv, &inv.clone()), &inv);
+ assert!(steps.is_empty());
+ }
+
+ #[test]
+ fn script_emits_heredocs_and_commands() {
+ let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
+ let script = steps_to_script(&steps);
+ assert!(script.starts_with("#!/usr/bin/env bash"));
+ assert!(script.contains("docker run -d --name web"));
+ assert!(script.contains("cat > /etc/nginx/sites-enabled/site.com.conf <<'MATILDA_EOF'"));
+ assert!(script.contains("MATILDA_EOF"));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/Cargo.toml b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml
new file mode 100644
index 0000000..70f5434
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "matilda-config"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — renderizado de configuración: del modelo declarativo a comandos docker run, servicios docker-compose y bloques server de nginx."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
diff --git a/02_ruway/shuma/baremetal/matilda-config/LEEME.md b/02_ruway/shuma/baremetal/matilda-config/LEEME.md
new file mode 100644
index 0000000..72fb835
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-config
+
+> Loader de archivos de [matilda](../../README.md).
+
+Lee `matilda.toml` + includes, resuelve variables, valida contra el schema de [`matilda-core`](../matilda-core/README.md).
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-config/README.md b/02_ruway/shuma/baremetal/matilda-config/README.md
new file mode 100644
index 0000000..7da49c1
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/README.md
@@ -0,0 +1,9 @@
+# matilda-config
+
+> File loader of [matilda](../../README.md).
+
+Reads `matilda.toml` + includes, resolves variables, validates against [`matilda-core`](../matilda-core/README.md)'s schema.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/docker.rs b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs
new file mode 100644
index 0000000..0259ef7
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs
@@ -0,0 +1,105 @@
+//! Renderizado de un [`Container`] a Docker — `docker run` y compose.
+
+use matilda_core::Container;
+
+/// Comando `docker run` de un contenedor, en una sola línea. El orden de
+/// los flags es fijo (determinista): `-d --name --restart -p -e -v img`.
+pub fn docker_run_command(c: &Container) -> String {
+ let mut parts: Vec<String> = vec![
+ "docker".into(),
+ "run".into(),
+ "-d".into(),
+ "--name".into(),
+ c.name.clone(),
+ "--restart".into(),
+ c.restart.docker_flag().into(),
+ ];
+ for p in &c.ports {
+ parts.push("-p".into());
+ parts.push(format!("{}:{}", p.host, p.container));
+ }
+ for (k, v) in &c.env {
+ parts.push("-e".into());
+ parts.push(format!("{k}={v}"));
+ }
+ for (host, container) in &c.volumes {
+ parts.push("-v".into());
+ parts.push(format!("{host}:{container}"));
+ }
+ parts.push(c.image.clone());
+ parts.join(" ")
+}
+
+/// Bloque de servicio para un `docker-compose.yml`. Viene indentado para
+/// colocarse tal cual bajo la clave `services:`.
+pub fn compose_service(c: &Container) -> String {
+ let mut out = String::new();
+ out.push_str(&format!(" {}:\n", c.name));
+ out.push_str(&format!(" image: {}\n", c.image));
+ out.push_str(&format!(" restart: {}\n", c.restart.docker_flag()));
+ if !c.ports.is_empty() {
+ out.push_str(" ports:\n");
+ for p in &c.ports {
+ out.push_str(&format!(" - \"{}:{}\"\n", p.host, p.container));
+ }
+ }
+ if !c.env.is_empty() {
+ out.push_str(" environment:\n");
+ for (k, v) in &c.env {
+ out.push_str(&format!(" - {k}={v}\n"));
+ }
+ }
+ if !c.volumes.is_empty() {
+ out.push_str(" volumes:\n");
+ for (host, container) in &c.volumes {
+ out.push_str(&format!(" - {host}:{container}\n"));
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_core::RestartPolicy;
+
+ fn sample() -> Container {
+ Container::new("web", "nginx:1.27")
+ .with_port(8080, 80)
+ .with_env("TZ", "America/Caracas")
+ .with_volume("/srv/web", "/usr/share/nginx/html")
+ .with_restart(RestartPolicy::Always)
+ }
+
+ #[test]
+ fn run_command_has_all_flags() {
+ let cmd = docker_run_command(&sample());
+ assert!(cmd.starts_with("docker run -d --name web --restart always"));
+ assert!(cmd.contains("-p 8080:80"));
+ assert!(cmd.contains("-e TZ=America/Caracas"));
+ assert!(cmd.contains("-v /srv/web:/usr/share/nginx/html"));
+ assert!(cmd.ends_with("nginx:1.27"));
+ }
+
+ #[test]
+ fn run_command_is_deterministic() {
+ assert_eq!(docker_run_command(&sample()), docker_run_command(&sample()));
+ }
+
+ #[test]
+ fn compose_service_indents_under_services() {
+ let yaml = compose_service(&sample());
+ assert!(yaml.contains(" web:\n"));
+ assert!(yaml.contains(" image: nginx:1.27\n"));
+ assert!(yaml.contains(" restart: always\n"));
+ assert!(yaml.contains(" - \"8080:80\"\n"));
+ }
+
+ #[test]
+ fn minimal_container_omits_empty_sections() {
+ let yaml = compose_service(&Container::new("bare", "alpine"));
+ assert!(!yaml.contains("ports:"));
+ assert!(!yaml.contains("environment:"));
+ assert!(!yaml.contains("volumes:"));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/lib.rs b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs
new file mode 100644
index 0000000..6682e08
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs
@@ -0,0 +1,63 @@
+//! `matilda-config` — del modelo declarativo a archivos de configuración.
+//!
+//! Funciones puras: toman un tipo de `matilda-core` y devuelven el texto
+//! de configuración listo para escribir en el servidor. No tocan disco
+//! ni Docker — sólo construyen strings, así que cada salida es testeable
+//! y determinista.
+//!
+//! - [`docker`] — `Container` → `docker run` / servicio docker-compose.
+//! - [`nginx`] — `VHost` → bloque `server` de nginx.
+
+#![forbid(unsafe_code)]
+
+pub mod docker;
+pub mod nginx;
+
+pub use docker::{compose_service, docker_run_command};
+pub use nginx::nginx_server_block;
+
+use matilda_core::Inventory;
+
+/// Renderiza el `docker-compose.yml` completo de un inventario.
+pub fn compose_file(inv: &Inventory) -> String {
+ let mut out = String::from("services:\n");
+ for c in inv.containers() {
+ out.push_str(&compose_service(c));
+ }
+ out
+}
+
+/// Renderiza el archivo de sites de nginx — un bloque `server` por
+/// vhost, separados por una línea en blanco.
+pub fn nginx_sites(inv: &Inventory) -> String {
+ inv.vhosts()
+ .map(nginx_server_block)
+ .collect::<Vec<_>>()
+ .join("\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_core::{Container, VHost};
+
+ #[test]
+ fn compose_file_lists_every_container() {
+ let mut inv = Inventory::new();
+ inv.add_container(Container::new("web", "nginx"));
+ inv.add_container(Container::new("db", "postgres:16"));
+ let yaml = compose_file(&inv);
+ assert!(yaml.starts_with("services:\n"));
+ assert!(yaml.contains(" web:\n") && yaml.contains(" db:\n"));
+ }
+
+ #[test]
+ fn nginx_sites_renders_every_vhost() {
+ let mut inv = Inventory::new();
+ inv.add_vhost(VHost::to_container("a.com", "web", 80));
+ inv.add_vhost(VHost::to_container("b.com", "web", 80));
+ let conf = nginx_sites(&inv);
+ assert!(conf.contains("server_name a.com;"));
+ assert!(conf.contains("server_name b.com;"));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs
new file mode 100644
index 0000000..e2a1425
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs
@@ -0,0 +1,94 @@
+//! Renderizado de un [`VHost`] a un bloque `server` de nginx.
+
+use matilda_core::{Upstream, VHost};
+
+/// URL de `proxy_pass` para un upstream. Un contenedor se referencia por
+/// su nombre, que la red de Docker resuelve a su IP interna.
+fn proxy_target(upstream: &Upstream) -> String {
+ match upstream {
+ Upstream::Address(addr) => format!("http://{addr}"),
+ Upstream::Container { name, port } => format!("http://{name}:{port}"),
+ }
+}
+
+/// Renderiza el `server` de nginx de un vhost. Con TLS emite dos
+/// bloques: el `:443 ssl` y un `:80` que redirige a HTTPS.
+pub fn nginx_server_block(v: &VHost) -> String {
+ let names: Vec<&str> = std::iter::once(v.domain.as_str())
+ .chain(v.aliases.iter().map(|s| s.as_str()))
+ .collect();
+ let server_name = names.join(" ");
+ let target = proxy_target(&v.upstream);
+
+ let mut out = String::new();
+ if v.tls {
+ // Redirección :80 → :443.
+ out.push_str("server {\n");
+ out.push_str(" listen 80;\n");
+ out.push_str(&format!(" server_name {server_name};\n"));
+ out.push_str(" return 301 https://$host$request_uri;\n");
+ out.push_str("}\n\n");
+
+ out.push_str("server {\n");
+ out.push_str(" listen 443 ssl;\n");
+ out.push_str(&format!(" server_name {server_name};\n"));
+ out.push_str(&format!(
+ " ssl_certificate /etc/letsencrypt/live/{}/fullchain.pem;\n",
+ v.domain
+ ));
+ out.push_str(&format!(
+ " ssl_certificate_key /etc/letsencrypt/live/{}/privkey.pem;\n",
+ v.domain
+ ));
+ } else {
+ out.push_str("server {\n");
+ out.push_str(" listen 80;\n");
+ out.push_str(&format!(" server_name {server_name};\n"));
+ }
+
+ out.push_str(" location / {\n");
+ out.push_str(&format!(" proxy_pass {target};\n"));
+ out.push_str(" proxy_set_header Host $host;\n");
+ out.push_str(" proxy_set_header X-Real-IP $remote_addr;\n");
+ out.push_str(" }\n");
+ out.push_str("}\n");
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn plain_vhost_listens_on_80() {
+ let block = nginx_server_block(&VHost::to_container("app.com", "web", 8080));
+ assert!(block.contains("listen 80;"));
+ assert!(!block.contains("listen 443"));
+ assert!(block.contains("server_name app.com;"));
+ assert!(block.contains("proxy_pass http://web:8080;"));
+ }
+
+ #[test]
+ fn tls_vhost_adds_443_and_redirect() {
+ let block = nginx_server_block(&VHost::to_address("secure.com", "10.0.0.5:80").with_tls());
+ assert!(block.contains("listen 443 ssl;"));
+ assert!(block.contains("return 301 https://$host$request_uri;"));
+ assert!(block.contains("/etc/letsencrypt/live/secure.com/fullchain.pem"));
+ assert!(block.contains("proxy_pass http://10.0.0.5:80;"));
+ }
+
+ #[test]
+ fn aliases_join_the_server_name() {
+ let v = VHost::to_address("main.com", "1.2.3.4:80")
+ .with_alias("www.main.com")
+ .with_alias("alt.com");
+ let block = nginx_server_block(&v);
+ assert!(block.contains("server_name main.com www.main.com alt.com;"));
+ }
+
+ #[test]
+ fn render_is_deterministic() {
+ let v = VHost::to_container("x.com", "c", 80).with_tls();
+ assert_eq!(nginx_server_block(&v), nginx_server_block(&v));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/Cargo.toml b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml
new file mode 100644
index 0000000..2bd92c9
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "matilda-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — modelo de dominio de administración de servidores: Host, Container, VHost y el Inventory que los agrupa. Agnóstico de transporte y de Docker."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-core/LEEME.md b/02_ruway/shuma/baremetal/matilda-core/LEEME.md
new file mode 100644
index 0000000..deb5ec4
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-core
+
+> Modelo de config declarativa de [shuma/matilda](../../README.md).
+
+`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe acá.
+
+## Deps
+
+- `serde`, `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-core/README.md b/02_ruway/shuma/baremetal/matilda-core/README.md
new file mode 100644
index 0000000..1bfa2ed
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/README.md
@@ -0,0 +1,9 @@
+# matilda-core
+
+> Declarative config model of [shuma/matilda](../../README.md).
+
+`HostConfig { packages, files, services, dotfiles, ... }` serializable to TOML. The truth of "how the host should be" is written here.
+
+## Deps
+
+- `serde`, `toml`
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/container.rs b/02_ruway/shuma/baremetal/matilda-core/src/container.rs
new file mode 100644
index 0000000..a731a25
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/container.rs
@@ -0,0 +1,132 @@
+//! `Container` — la especificación declarativa de un contenedor Docker.
+//!
+//! Es sólo el *deseo*: qué imagen, qué puertos, qué entorno. Ejecutar
+//! Docker es trabajo de capas superiores; aquí el contenedor es un dato
+//! comparable (`PartialEq`) para que el plan detecte cambios.
+
+use serde::{Deserialize, Serialize};
+
+/// Política de reinicio del contenedor.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum RestartPolicy {
+ /// Nunca reiniciar.
+ #[default]
+ No,
+ /// Reiniciar sólo si salió con error.
+ OnFailure,
+ /// Reiniciar siempre.
+ Always,
+ /// Reiniciar salvo que se haya detenido a mano.
+ UnlessStopped,
+}
+
+impl RestartPolicy {
+ /// Valor tal como lo espera el flag `--restart` de Docker.
+ pub fn docker_flag(self) -> &'static str {
+ match self {
+ RestartPolicy::No => "no",
+ RestartPolicy::OnFailure => "on-failure",
+ RestartPolicy::Always => "always",
+ RestartPolicy::UnlessStopped => "unless-stopped",
+ }
+ }
+}
+
+/// Un mapeo de puerto `host → contenedor`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PortMap {
+ pub host: u16,
+ pub container: u16,
+}
+
+impl PortMap {
+ pub fn new(host: u16, container: u16) -> Self {
+ Self { host, container }
+ }
+}
+
+/// La especificación declarativa de un contenedor. Clave única: `name`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Container {
+ pub name: String,
+ /// Imagen con etiqueta — `"nginx:1.27"`, `"postgres:16"`.
+ pub image: String,
+ pub ports: Vec<PortMap>,
+ /// Variables de entorno, ordenadas por clave para comparación estable.
+ pub env: Vec<(String, String)>,
+ /// Volúmenes `ruta_host → ruta_contenedor`.
+ pub volumes: Vec<(String, String)>,
+ pub restart: RestartPolicy,
+}
+
+impl Container {
+ /// Contenedor mínimo: nombre + imagen.
+ pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
+ Self {
+ name: name.into(),
+ image: image.into(),
+ ports: Vec::new(),
+ env: Vec::new(),
+ volumes: Vec::new(),
+ restart: RestartPolicy::default(),
+ }
+ }
+
+ /// Publica un puerto (encadenable).
+ pub fn with_port(mut self, host: u16, container: u16) -> Self {
+ self.ports.push(PortMap::new(host, container));
+ self
+ }
+
+ /// Define una variable de entorno (encadenable). El vector se
+ /// mantiene ordenado por clave para que dos contenedores con el
+ /// mismo entorno comparen iguales sin importar el orden de llamada.
+ pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
+ let key = key.into();
+ self.env.retain(|(k, _)| k != &key);
+ self.env.push((key, value.into()));
+ self.env.sort_by(|a, b| a.0.cmp(&b.0));
+ self
+ }
+
+ /// Monta un volumen (encadenable).
+ pub fn with_volume(
+ mut self,
+ host_path: impl Into<String>,
+ container_path: impl Into<String>,
+ ) -> Self {
+ self.volumes.push((host_path.into(), container_path.into()));
+ self
+ }
+
+ /// Fija la política de reinicio (encadenable).
+ pub fn with_restart(mut self, restart: RestartPolicy) -> Self {
+ self.restart = restart;
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn env_order_does_not_affect_equality() {
+ let a = Container::new("c", "img").with_env("B", "2").with_env("A", "1");
+ let b = Container::new("c", "img").with_env("A", "1").with_env("B", "2");
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn with_env_overwrites_same_key() {
+ let c = Container::new("c", "img").with_env("K", "old").with_env("K", "new");
+ assert_eq!(c.env, vec![("K".to_string(), "new".to_string())]);
+ }
+
+ #[test]
+ fn restart_flags_match_docker() {
+ assert_eq!(RestartPolicy::UnlessStopped.docker_flag(), "unless-stopped");
+ assert_eq!(RestartPolicy::default(), RestartPolicy::No);
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/host.rs b/02_ruway/shuma/baremetal/matilda-core/src/host.rs
new file mode 100644
index 0000000..75bc095
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/host.rs
@@ -0,0 +1,46 @@
+//! `Host` — un servidor administrado.
+
+use serde::{Deserialize, Serialize};
+
+/// Un servidor bajo administración. La clave única es `name`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Host {
+ /// Nombre lógico — clave de inventario, no necesariamente el hostname.
+ pub name: String,
+ /// IP o nombre DNS por el que se alcanza.
+ pub address: String,
+ /// Etiquetas libres — `"prod"`, `"db"`, `"edge"`.
+ pub tags: Vec<String>,
+}
+
+impl Host {
+ pub fn new(name: impl Into<String>, address: impl Into<String>) -> Self {
+ Self { name: name.into(), address: address.into(), tags: Vec::new() }
+ }
+
+ /// Añade una etiqueta (encadenable). No duplica.
+ pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
+ let tag = tag.into();
+ if !self.tags.contains(&tag) {
+ self.tags.push(tag);
+ }
+ self
+ }
+
+ /// `true` si el host lleva la etiqueta `tag`.
+ pub fn has_tag(&self, tag: &str) -> bool {
+ self.tags.iter().any(|t| t == tag)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn with_tag_dedups() {
+ let h = Host::new("edge-1", "10.0.0.1").with_tag("prod").with_tag("prod");
+ assert_eq!(h.tags.len(), 1);
+ assert!(h.has_tag("prod"));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs
new file mode 100644
index 0000000..01259a3
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs
@@ -0,0 +1,131 @@
+//! `Inventory` — el estado declarado de la infraestructura.
+//!
+//! Reúne hosts, contenedores y vhosts. Cada colección es un `BTreeMap`
+//! por nombre: toda iteración es determinista y el `diff` de
+//! `matilda-plan` produce siempre el mismo orden de acciones.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::container::Container;
+use crate::host::Host;
+use crate::vhost::VHost;
+
+/// El inventario completo — la fuente de verdad declarativa.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Inventory {
+ hosts: BTreeMap<String, Host>,
+ containers: BTreeMap<String, Container>,
+ vhosts: BTreeMap<String, VHost>,
+}
+
+impl Inventory {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ // --- Hosts ---
+
+ pub fn add_host(&mut self, host: Host) {
+ self.hosts.insert(host.name.clone(), host);
+ }
+
+ pub fn host(&self, name: &str) -> Option<&Host> {
+ self.hosts.get(name)
+ }
+
+ pub fn hosts(&self) -> impl Iterator<Item = &Host> {
+ self.hosts.values()
+ }
+
+ // --- Contenedores ---
+
+ pub fn add_container(&mut self, container: Container) {
+ self.containers.insert(container.name.clone(), container);
+ }
+
+ pub fn container(&self, name: &str) -> Option<&Container> {
+ self.containers.get(name)
+ }
+
+ pub fn containers(&self) -> impl Iterator<Item = &Container> {
+ self.containers.values()
+ }
+
+ // --- VHosts ---
+
+ pub fn add_vhost(&mut self, vhost: VHost) {
+ self.vhosts.insert(vhost.domain.clone(), vhost);
+ }
+
+ pub fn vhost(&self, domain: &str) -> Option<&VHost> {
+ self.vhosts.get(domain)
+ }
+
+ pub fn vhosts(&self) -> impl Iterator<Item = &VHost> {
+ self.vhosts.values()
+ }
+
+ // --- Consultas transversales ---
+
+ /// `true` si el inventario no tiene nada declarado.
+ pub fn is_empty(&self) -> bool {
+ self.hosts.is_empty() && self.containers.is_empty() && self.vhosts.is_empty()
+ }
+
+ /// VHosts cuyo upstream apunta a un contenedor inexistente — la
+ /// inconsistencia más común de un inventario.
+ pub fn broken_vhosts(&self) -> Vec<&VHost> {
+ self.vhosts
+ .values()
+ .filter(|v| {
+ v.depends_on_container()
+ .is_some_and(|c| !self.containers.contains_key(c))
+ })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn add_and_query_each_kind() {
+ let mut inv = Inventory::new();
+ inv.add_host(Host::new("edge", "10.0.0.1"));
+ inv.add_container(Container::new("web", "nginx:1.27"));
+ inv.add_vhost(VHost::to_container("site.com", "web", 80));
+ assert!(inv.host("edge").is_some());
+ assert!(inv.container("web").is_some());
+ assert!(inv.vhost("site.com").is_some());
+ assert!(!inv.is_empty());
+ }
+
+ #[test]
+ fn broken_vhosts_point_to_missing_containers() {
+ let mut inv = Inventory::new();
+ inv.add_vhost(VHost::to_container("site.com", "fantasma", 80));
+ inv.add_vhost(VHost::to_address("static.com", "1.2.3.4:80"));
+ let broken: Vec<_> = inv.broken_vhosts().iter().map(|v| v.domain.clone()).collect();
+ assert_eq!(broken, vec!["site.com"]);
+ }
+
+ #[test]
+ fn vhost_with_present_container_is_not_broken() {
+ let mut inv = Inventory::new();
+ inv.add_container(Container::new("web", "nginx:1.27"));
+ inv.add_vhost(VHost::to_container("site.com", "web", 80));
+ assert!(inv.broken_vhosts().is_empty());
+ }
+
+ #[test]
+ fn iteration_is_ordered_by_name() {
+ let mut inv = Inventory::new();
+ inv.add_container(Container::new("zeta", "img"));
+ inv.add_container(Container::new("alfa", "img"));
+ let names: Vec<_> = inv.containers().map(|c| c.name.as_str()).collect();
+ assert_eq!(names, vec!["alfa", "zeta"]);
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/lib.rs b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs
new file mode 100644
index 0000000..5135abb
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs
@@ -0,0 +1,26 @@
+//! `matilda-core` — el modelo de dominio de administración de servidores.
+//!
+//! matilda administra servidores, sus contenedores Docker y los hosts
+//! virtuales de proxy inverso. Este crate es la parte declarativa y
+//! pura: describe *qué* debe existir, sin tocar Docker, SSH ni archivos.
+//!
+//! - [`host`] — [`Host`], un servidor administrado.
+//! - [`container`] — [`Container`], la spec declarativa de un contenedor.
+//! - [`vhost`] — [`VHost`], un host virtual de proxy inverso.
+//! - [`inventory`] — [`Inventory`], el estado declarado completo.
+//!
+//! El renderizado de configuración vive en `matilda-config`; la
+//! reconciliación deseado-vs-actual, en `matilda-plan`; el transporte
+//! (SSH «Linker», agente «Ghost»), en capas superiores.
+
+#![forbid(unsafe_code)]
+
+pub mod container;
+pub mod host;
+pub mod inventory;
+pub mod vhost;
+
+pub use container::{Container, PortMap, RestartPolicy};
+pub use host::Host;
+pub use inventory::Inventory;
+pub use vhost::{Upstream, VHost};
diff --git a/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs
new file mode 100644
index 0000000..4c48fb9
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs
@@ -0,0 +1,88 @@
+//! `VHost` — un host virtual de proxy inverso.
+
+use serde::{Deserialize, Serialize};
+
+/// El destino al que un `VHost` reenvía el tráfico.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Upstream {
+ /// Una dirección `host:puerto` literal.
+ Address(String),
+ /// Un contenedor del inventario, por nombre y puerto interno.
+ Container { name: String, port: u16 },
+}
+
+/// Un host virtual: un dominio que se reenvía a un upstream. Clave
+/// única: `domain`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct VHost {
+ pub domain: String,
+ pub upstream: Upstream,
+ /// Si se sirve sobre HTTPS.
+ pub tls: bool,
+ /// Dominios alternativos que resuelven al mismo upstream.
+ pub aliases: Vec<String>,
+}
+
+impl VHost {
+ /// VHost que apunta a una dirección literal.
+ pub fn to_address(domain: impl Into<String>, address: impl Into<String>) -> Self {
+ Self {
+ domain: domain.into(),
+ upstream: Upstream::Address(address.into()),
+ tls: false,
+ aliases: Vec::new(),
+ }
+ }
+
+ /// VHost que apunta a un contenedor del inventario.
+ pub fn to_container(
+ domain: impl Into<String>,
+ container: impl Into<String>,
+ port: u16,
+ ) -> Self {
+ Self {
+ domain: domain.into(),
+ upstream: Upstream::Container { name: container.into(), port },
+ tls: false,
+ aliases: Vec::new(),
+ }
+ }
+
+ /// Activa TLS (encadenable).
+ pub fn with_tls(mut self) -> Self {
+ self.tls = true;
+ self
+ }
+
+ /// Añade un alias de dominio (encadenable).
+ pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
+ self.aliases.push(alias.into());
+ self
+ }
+
+ /// Nombre del contenedor del que depende, si el upstream es uno.
+ pub fn depends_on_container(&self) -> Option<&str> {
+ match &self.upstream {
+ Upstream::Container { name, .. } => Some(name),
+ Upstream::Address(_) => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn container_upstream_reports_its_dependency() {
+ let v = VHost::to_container("app.example.com", "web", 8080).with_tls();
+ assert_eq!(v.depends_on_container(), Some("web"));
+ assert!(v.tls);
+ }
+
+ #[test]
+ fn address_upstream_has_no_container_dependency() {
+ let v = VHost::to_address("static.example.com", "10.0.0.9:80");
+ assert_eq!(v.depends_on_container(), None);
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml
new file mode 100644
index 0000000..f4e5297
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "matilda-discover"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — descubrimiento del estado actual de un servidor: qué contenedores y vhosts existen, para reconciliar contra el inventario deseado."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+matilda-plan = { path = "../matilda-plan" }
diff --git a/02_ruway/shuma/baremetal/matilda-discover/LEEME.md b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md
new file mode 100644
index 0000000..a97dd47
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md
@@ -0,0 +1,10 @@
+# matilda-discover
+
+> Descubrimiento de estado actual de [matilda](../../README.md).
+
+Lee el sistema (paquetes instalados, archivos en `/etc`, servicios systemd) y produce un `HostConfig` "actual". Comparable con el deseado para calcular el diff.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
+- `dbus` (systemd), `walkdir`
diff --git a/02_ruway/shuma/baremetal/matilda-discover/README.md b/02_ruway/shuma/baremetal/matilda-discover/README.md
new file mode 100644
index 0000000..48da119
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/README.md
@@ -0,0 +1,10 @@
+# matilda-discover
+
+> Current-state discovery of [matilda](../../README.md).
+
+Reads the system (installed packages, files in `/etc`, systemd services) and produces the "actual" `HostConfig`. Comparable with the desired state to compute the diff.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
+- `dbus` (systemd), `walkdir`
diff --git a/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs
new file mode 100644
index 0000000..4feb9d0
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs
@@ -0,0 +1,349 @@
+//! `matilda-discover` — qué hay realmente en el servidor.
+//!
+//! Para reconciliar de verdad hace falta saber el estado *actual*: qué
+//! contenedores y vhosts existen. Este crate lo observa y lo reconstruye
+//! como un [`Inventory`] que `matilda-plan` puede diferenciar contra el
+//! deseado.
+//!
+//! Alcance v1: descubre por **nombre**. Detecta correctamente lo que hay
+//! que **crear** y lo que hay que **eliminar** (huérfanos). No detecta
+//! cambios de configuración de un recurso existente — eso necesita
+//! inspección detallada (`docker inspect`), aún no implementada; un
+//! recurso presente y deseado se asume sin cambios.
+//!
+//! El parseo es puro y testeable; sólo [`discover_local`] toca el sistema.
+
+#![forbid(unsafe_code)]
+
+use matilda_core::{Container, Inventory, VHost};
+use serde::{Deserialize, Serialize};
+
+/// El estado observado de un servidor — los nombres de lo que existe.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerState {
+ /// Nombres de los contenedores presentes.
+ pub containers: Vec<String>,
+ /// Dominios de los vhosts presentes.
+ pub vhosts: Vec<String>,
+}
+
+/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre
+/// por línea.
+pub fn parse_docker_names(text: &str) -> Vec<String> {
+ text.lines()
+ .map(str::trim)
+ .filter(|l| !l.is_empty())
+ .map(str::to_string)
+ .collect()
+}
+
+/// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por
+/// línea; el sufijo `.conf` se quita para quedarse con el dominio.
+pub fn parse_nginx_sites(text: &str) -> Vec<String> {
+ text.lines()
+ .map(str::trim)
+ .filter(|l| !l.is_empty())
+ .map(|l| l.strip_suffix(".conf").unwrap_or(l).to_string())
+ .collect()
+}
+
+/// Reconstruye el inventario "actual" a partir de los nombres observados.
+///
+/// Un recurso presente que también está en `desired` se copia de ahí —
+/// así el `plan` no marca cambios espurios (la detección real de drift
+/// necesita inspección detallada). Un recurso presente que **no** está
+/// en `desired` entra como un marcador, y el `plan` lo verá como un
+/// `Remove`.
+pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory {
+ let mut inv = Inventory::new();
+ for name in &state.containers {
+ match desired.container(name) {
+ Some(c) => inv.add_container(c.clone()),
+ None => inv.add_container(Container::new(name, "(desconocido)")),
+ }
+ }
+ for domain in &state.vhosts {
+ match desired.vhost(domain) {
+ Some(v) => inv.add_vhost(v.clone()),
+ None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")),
+ }
+ }
+ inv
+}
+
+/// Ejecuta un comando local y devuelve su stdout, o `None` si falla.
+fn run_local(program: &str, args: &[&str]) -> Option<String> {
+ let out = std::process::Command::new(program).args(args).output().ok()?;
+ out.status
+ .success()
+ .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
+}
+
+// --- Detección de drift por `docker inspect` ----------------------------
+
+/// Subconjunto de la salida de `docker inspect` que importa para el drift.
+#[derive(Debug, Deserialize)]
+struct DockerInspect {
+ #[serde(rename = "Config")]
+ config: DockerConfig,
+ #[serde(rename = "HostConfig")]
+ host_config: DockerHostConfig,
+}
+
+#[derive(Debug, Deserialize)]
+struct DockerConfig {
+ #[serde(rename = "Image")]
+ image: String,
+ #[serde(default, rename = "Env")]
+ env: Vec<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DockerHostConfig {
+ #[serde(default, rename = "Binds")]
+ binds: Option<Vec<String>>,
+ #[serde(default, rename = "PortBindings")]
+ port_bindings: std::collections::HashMap<String, Option<Vec<PortBinding>>>,
+ #[serde(default, rename = "RestartPolicy")]
+ restart_policy: DockerRestart,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DockerRestart {
+ #[serde(default, rename = "Name")]
+ name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct PortBinding {
+ #[serde(rename = "HostPort")]
+ host_port: String,
+}
+
+/// `true` si el contenedor que está corriendo **se desvió** de lo que
+/// declara `desired` — distinta imagen, puerto, env o volumen.
+///
+/// La comparación es por *satisfacción*: lo que el spec declara debe
+/// estar; lo extra que traiga la imagen (su `PATH`, etc.) se ignora.
+/// Si el JSON no se puede leer, se asume que no hay drift (no se marca
+/// un cambio espurio).
+pub fn container_drift(desired: &Container, inspect_json: &str) -> bool {
+ let parsed: Vec<DockerInspect> = match serde_json::from_str(inspect_json) {
+ Ok(v) => v,
+ Err(_) => return false,
+ };
+ let Some(d) = parsed.first() else {
+ return false;
+ };
+
+ // Imagen.
+ if d.config.image != desired.image {
+ return true;
+ }
+ // Política de reinicio (docker reporta "" cuando no hay → "no").
+ let actual = if d.host_config.restart_policy.name.is_empty() {
+ "no"
+ } else {
+ d.host_config.restart_policy.name.as_str()
+ };
+ if actual != desired.restart.docker_flag() {
+ return true;
+ }
+ // Cada puerto declarado debe estar publicado al host correcto.
+ for p in &desired.ports {
+ let key = format!("{}/tcp", p.container);
+ let published = d
+ .host_config
+ .port_bindings
+ .get(&key)
+ .and_then(|b| b.as_ref())
+ .map(|bs| bs.iter().any(|b| b.host_port == p.host.to_string()))
+ .unwrap_or(false);
+ if !published {
+ return true;
+ }
+ }
+ // Cada variable de entorno declarada debe estar presente.
+ for (k, v) in &desired.env {
+ let want = format!("{k}={v}");
+ if !d.config.env.iter().any(|e| e == &want) {
+ return true;
+ }
+ }
+ // Cada volumen declarado debe estar montado.
+ for (h, c) in &desired.volumes {
+ let want = format!("{h}:{c}");
+ if !d.host_config.binds.iter().flatten().any(|b| b.starts_with(&want)) {
+ return true;
+ }
+ }
+ false
+}
+
+/// Descubre el inventario actual **con detección de drift**: corre
+/// `docker inspect` en cada contenedor y, si se desvió del spec deseado,
+/// lo marca para que el `plan` emita un `Update`. Los contenedores al
+/// día se copian del deseado (sin cambio); los huérfanos quedan marcados
+/// para `Remove`. Los vhosts se descubren por nombre.
+pub fn discover_inventory(desired: &Inventory) -> Inventory {
+ let mut inv = Inventory::new();
+ let names = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
+ .map(|t| parse_docker_names(&t))
+ .unwrap_or_default();
+ for name in names {
+ match desired.container(&name) {
+ Some(d) => {
+ let drifted = run_local("docker", &["inspect", &name])
+ .map(|json| container_drift(d, &json))
+ .unwrap_or(false);
+ if drifted {
+ // Marcador distinto del deseado → el plan verá `Update`.
+ inv.add_container(Container::new(&name, "(desviado)"));
+ } else {
+ inv.add_container(d.clone());
+ }
+ }
+ None => inv.add_container(Container::new(&name, "(huérfano)")),
+ }
+ }
+ for domain in run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
+ .map(|t| parse_nginx_sites(&t))
+ .unwrap_or_default()
+ {
+ match desired.vhost(&domain) {
+ Some(v) => inv.add_vhost(v.clone()),
+ None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")),
+ }
+ }
+ inv
+}
+
+/// Observa el estado de *esta* máquina: `docker ps` + los sitios de
+/// nginx. Si docker no está o el directorio no existe, esa parte queda
+/// vacía (no es un error — quizá el servidor aún no tiene nada).
+pub fn discover_local() -> ServerState {
+ let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
+ .map(|t| parse_docker_names(&t))
+ .unwrap_or_default();
+ let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
+ .map(|t| parse_nginx_sites(&t))
+ .unwrap_or_default();
+ ServerState { containers, vhosts }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_plan::{plan, Op};
+
+ #[test]
+ fn parses_docker_names() {
+ let names = parse_docker_names("web\napi\n\n db \n");
+ assert_eq!(names, vec!["web", "api", "db"]);
+ }
+
+ #[test]
+ fn parses_nginx_sites_stripping_conf() {
+ let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
+ assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
+ }
+
+ #[test]
+ fn observed_present_and_desired_diffs_clean() {
+ // Un contenedor presente que también se desea → sin cambios.
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("web", "nginx:1.27"));
+ let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
+ let current = observed_inventory(&state, &desired);
+ let p = plan(¤t, &desired);
+ assert!(p.is_empty(), "presente y deseado → sin acciones");
+ }
+
+ #[test]
+ fn observed_orphan_becomes_a_removal() {
+ // Un contenedor presente que NO se desea → se elimina.
+ let desired = Inventory::new();
+ let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+ let current = observed_inventory(&state, &desired);
+ let p = plan(¤t, &desired);
+ assert_eq!(p.count(Op::Remove), 1);
+ assert_eq!(p.actions[0].name, "viejo");
+ }
+
+ #[test]
+ fn missing_desired_resource_becomes_a_creation() {
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("nuevo", "img:1"));
+ // El servidor no tiene nada.
+ let current = observed_inventory(&ServerState::default(), &desired);
+ let p = plan(¤t, &desired);
+ assert_eq!(p.count(Op::Create), 1);
+ }
+
+ #[test]
+ fn create_and_remove_together() {
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("nuevo", "img:1"));
+ let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+ let p = plan(&observed_inventory(&state, &desired), &desired);
+ assert_eq!(p.count(Op::Create), 1);
+ assert_eq!(p.count(Op::Remove), 1);
+ }
+
+ /// `docker inspect` de un `web` con nginx:1.27, 8080→80, un volumen,
+ /// la env TZ y reinicio `always`.
+ const INSPECT_WEB: &str = r#"[{
+ "Config": {
+ "Image": "nginx:1.27",
+ "Env": ["PATH=/usr/local/sbin", "TZ=UTC"]
+ },
+ "HostConfig": {
+ "Binds": ["/srv/web:/usr/share/nginx/html"],
+ "PortBindings": {"80/tcp": [{"HostPort": "8080"}]},
+ "RestartPolicy": {"Name": "always"}
+ }
+ }]"#;
+
+ fn web_spec() -> matilda_core::Container {
+ Container::new("web", "nginx:1.27")
+ .with_port(8080, 80)
+ .with_volume("/srv/web", "/usr/share/nginx/html")
+ .with_env("TZ", "UTC")
+ .with_restart(matilda_core::RestartPolicy::Always)
+ }
+
+ #[test]
+ fn no_drift_when_running_matches_the_spec() {
+ assert!(!container_drift(&web_spec(), INSPECT_WEB));
+ }
+
+ #[test]
+ fn drift_when_image_changed() {
+ let mut spec = web_spec();
+ spec.image = "nginx:1.25".into();
+ assert!(container_drift(&spec, INSPECT_WEB));
+ }
+
+ #[test]
+ fn drift_when_a_declared_port_is_missing() {
+ let spec = web_spec().with_port(9000, 9000);
+ assert!(container_drift(&spec, INSPECT_WEB));
+ }
+
+ #[test]
+ fn drift_when_a_declared_env_is_missing() {
+ let spec = web_spec().with_env("DEBUG", "1");
+ assert!(container_drift(&spec, INSPECT_WEB));
+ }
+
+ #[test]
+ fn drift_when_restart_policy_differs() {
+ let spec = web_spec().with_restart(matilda_core::RestartPolicy::No);
+ assert!(container_drift(&spec, INSPECT_WEB));
+ }
+
+ #[test]
+ fn unreadable_json_is_not_treated_as_drift() {
+ assert!(!container_drift(&web_spec(), "no es json"));
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml
new file mode 100644
index 0000000..64fe4fa
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "matilda-ghost"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — el agente que ejecuta los ApplySteps en la máquina destino: escribe los archivos, corre los comandos y reporta el resultado paso a paso."
+
+[dependencies]
+matilda-apply = { path = "../matilda-apply" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md
new file mode 100644
index 0000000..e1be2ca
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-ghost
+
+> Modo dry-run de [matilda](../../README.md).
+
+Wrapper de [`matilda-apply`](../matilda-apply/README.md) que NO ejecuta — sólo imprime. Útil antes de un apply real.
+
+## Deps
+
+- [`matilda-apply`](../matilda-apply/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/README.md b/02_ruway/shuma/baremetal/matilda-ghost/README.md
new file mode 100644
index 0000000..c988fa4
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/README.md
@@ -0,0 +1,9 @@
+# matilda-ghost
+
+> Dry-run mode of [matilda](../../README.md).
+
+Wrapper of [`matilda-apply`](../matilda-apply/README.md) that does NOT execute — only prints what would. Useful before a real apply.
+
+## Deps
+
+- [`matilda-apply`](../matilda-apply/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs
new file mode 100644
index 0000000..bc4d9db
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs
@@ -0,0 +1,222 @@
+//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
+//!
+//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
+//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
+//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
+//! un [`ApplyReport`].
+//!
+//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
+//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
+//!
+//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
+//! el mismo [`ApplyReport`] reusando estos tipos.
+
+#![forbid(unsafe_code)]
+
+use matilda_apply::ApplyStep;
+use serde::{Deserialize, Serialize};
+
+/// Resultado de un paso de aplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct StepResult {
+ /// Descripción de la acción aplicada.
+ pub describe: String,
+ /// `true` si el paso completó sin errores.
+ pub ok: bool,
+ /// Bitácora legible: archivos escritos, comandos y su salida.
+ pub log: Vec<String>,
+}
+
+/// El reporte de aplicar un plan: un resultado por paso ejecutado.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct ApplyReport {
+ pub results: Vec<StepResult>,
+}
+
+impl ApplyReport {
+ /// `true` si todos los pasos ejecutados salieron bien.
+ pub fn all_ok(&self) -> bool {
+ self.results.iter().all(|r| r.ok)
+ }
+
+ /// Cantidad de pasos que salieron bien.
+ pub fn applied(&self) -> usize {
+ self.results.iter().filter(|r| r.ok).count()
+ }
+
+ /// El primer paso que falló, si lo hubo.
+ pub fn failed(&self) -> Option<&StepResult> {
+ self.results.iter().find(|r| !r.ok)
+ }
+}
+
+/// Corre un comando de shell, juntando su salida (stdout + stderr).
+/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
+fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
+ let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
+ let mut lines = Vec::new();
+ for chunk in [&out.stdout, &out.stderr] {
+ for l in String::from_utf8_lossy(chunk).lines() {
+ lines.push(l.to_string());
+ }
+ }
+ Ok((out.status.code().unwrap_or(-1), lines))
+}
+
+/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
+/// comandos. Devuelve el resultado; se detiene en el primer error.
+fn apply_step(step: &ApplyStep) -> StepResult {
+ let mut log = Vec::new();
+ let mut ok = true;
+
+ for f in &step.files {
+ match std::fs::write(&f.path, &f.content) {
+ Ok(()) => log.push(format!("✔ escrito {}", f.path)),
+ Err(e) => {
+ log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
+ ok = false;
+ break;
+ }
+ }
+ }
+
+ if ok {
+ for cmd in &step.commands {
+ log.push(format!("$ {cmd}"));
+ match run_command(cmd) {
+ Ok((0, out)) => {
+ log.extend(out.into_iter().map(|l| format!(" {l}")));
+ }
+ Ok((code, out)) => {
+ log.extend(out.into_iter().map(|l| format!(" {l}")));
+ log.push(format!("✘ el comando salió con código {code}"));
+ ok = false;
+ break;
+ }
+ Err(e) => {
+ log.push(format!("✘ no se pudo ejecutar: {e}"));
+ ok = false;
+ break;
+ }
+ }
+ }
+ }
+
+ StepResult { describe: step.describe.clone(), ok, log }
+}
+
+/// Aplica los pasos en orden. Se detiene en el primero que falle
+/// (semántica `set -e`): los posteriores no se ejecutan.
+pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
+ let mut results = Vec::new();
+ for step in steps {
+ let result = apply_step(step);
+ let failed = !result.ok;
+ results.push(result);
+ if failed {
+ break;
+ }
+ }
+ ApplyReport { results }
+}
+
+/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
+/// sin tocar nada. Seguro para previsualizar.
+pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
+ let results = steps
+ .iter()
+ .map(|s| {
+ let mut log = Vec::new();
+ for f in &s.files {
+ log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
+ }
+ for c in &s.commands {
+ log.push(format!("$ {c}"));
+ }
+ StepResult { describe: s.describe.clone(), ok: true, log }
+ })
+ .collect();
+ ApplyReport { results }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_apply::FileWrite;
+
+ /// Paso que escribe un archivo temporal y corre un comando.
+ fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
+ ApplyStep {
+ describe: describe.into(),
+ files: file.into_iter().collect(),
+ commands: cmds.iter().map(|s| s.to_string()).collect(),
+ }
+ }
+
+ fn temp(name: &str) -> String {
+ std::env::temp_dir()
+ .join(format!("matilda-ghost-{}-{name}", std::process::id()))
+ .to_string_lossy()
+ .into_owned()
+ }
+
+ #[test]
+ fn dry_run_touches_nothing() {
+ let path = temp("dry");
+ let _ = std::fs::remove_file(&path);
+ let steps = vec![step(
+ "crear x",
+ Some(FileWrite { path: path.clone(), content: "hola".into() }),
+ &["echo hecho"],
+ )];
+ let report = dry_run(&steps);
+ assert!(report.all_ok());
+ assert_eq!(report.results.len(), 1);
+ // dry_run no escribió el archivo.
+ assert!(!std::path::Path::new(&path).exists());
+ }
+
+ #[test]
+ fn apply_writes_files_and_runs_commands() {
+ let path = temp("apply");
+ let _ = std::fs::remove_file(&path);
+ let steps = vec![step(
+ "crear config",
+ Some(FileWrite { path: path.clone(), content: "contenido".into() }),
+ &["echo aplicado"],
+ )];
+ let report = apply(&steps);
+ assert!(report.all_ok());
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
+ assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn apply_stops_at_the_first_failure() {
+ let steps = vec![
+ step("ok", None, &["true"]),
+ step("falla", None, &["exit 7"]),
+ step("nunca", None, &["echo no-deberia-correr"]),
+ ];
+ let report = apply(&steps);
+ // El tercer paso no se ejecutó.
+ assert_eq!(report.results.len(), 2);
+ assert!(!report.all_ok());
+ assert_eq!(report.applied(), 1);
+ assert!(report.failed().unwrap().describe.contains("falla"));
+ }
+
+ #[test]
+ fn nonzero_exit_marks_the_step_failed() {
+ let report = apply(&[step("test", None, &["false"])]);
+ assert!(!report.results[0].ok);
+ }
+
+ #[test]
+ fn empty_plan_applies_cleanly() {
+ let report = apply(&[]);
+ assert!(report.all_ok());
+ assert_eq!(report.applied(), 0);
+ }
+}
diff --git a/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml
new file mode 100644
index 0000000..4ea4830
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "matilda-linker"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — el enlace SSH: conecta a un servidor y aplica los ApplySteps remotamente, escribiendo archivos y corriendo comandos sobre la conexión multiplexada."
+
+[dependencies]
+matilda-apply = { path = "../matilda-apply" }
+matilda-ghost = { path = "../matilda-ghost" }
+ssh = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-linker/LEEME.md b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md
new file mode 100644
index 0000000..1b265a3
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-linker
+
+> Enlaza dotfiles de [matilda](../../README.md).
+
+Maneja symlinks de dotfiles desde el repo del usuario a `$HOME`. Detecta conflictos; backup automático antes de pisar.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-linker/README.md b/02_ruway/shuma/baremetal/matilda-linker/README.md
new file mode 100644
index 0000000..80f6878
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/README.md
@@ -0,0 +1,9 @@
+# matilda-linker
+
+> Dotfile linker of [matilda](../../README.md).
+
+Manages symlinks from the user's repo to `$HOME`. Detects conflicts; automatic backup before overwriting.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs
new file mode 100644
index 0000000..e1efc8a
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs
@@ -0,0 +1,154 @@
+//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
+//!
+//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
+//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
+//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
+//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
+//! así el consumidor no distingue aplicación local de remota.
+//!
+//! La prueba real necesita un servidor SSH — se hace fuera del unit
+//! test. Lo puro y testeable es la construcción del comando de escritura.
+
+#![forbid(unsafe_code)]
+
+use matilda_apply::{ApplyStep, FileWrite};
+use matilda_ghost::{ApplyReport, StepResult};
+
+pub use ssh::{SshAuth, SshConfig, SshError};
+use ssh::SshSession;
+
+/// Marcador de heredoc para escribir archivos remotos.
+const HEREDOC: &str = "MATILDA_LINKER_EOF";
+
+/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
+fn file_write_command(f: &FileWrite) -> String {
+ format!(
+ "cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
+ f.path, f.content
+ )
+}
+
+/// Enlace activo a un servidor: una sesión SSH multiplexada.
+pub struct Linker {
+ session: SshSession,
+}
+
+impl Linker {
+ /// Conecta y autentica contra el host descrito por `config`.
+ pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
+ Ok(Linker { session: SshSession::connect(config).await? })
+ }
+
+ /// Aplica un paso en el host remoto: escribe sus archivos, corre sus
+ /// comandos. Se detiene en el primer error.
+ async fn apply_step(&self, step: &ApplyStep) -> StepResult {
+ let mut log = Vec::new();
+ let mut ok = true;
+
+ for f in &step.files {
+ match self.session.exec(&file_write_command(f)).await {
+ Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
+ Ok(out) => {
+ log.push(format!(
+ "✘ escribir {}: {}",
+ f.path,
+ String::from_utf8_lossy(&out.stderr).trim()
+ ));
+ ok = false;
+ break;
+ }
+ Err(e) => {
+ log.push(format!("✘ {e}"));
+ ok = false;
+ break;
+ }
+ }
+ }
+
+ if ok {
+ for cmd in &step.commands {
+ log.push(format!("$ {cmd}"));
+ match self.session.exec(cmd).await {
+ Ok(out) => {
+ for l in String::from_utf8_lossy(&out.stdout).lines() {
+ log.push(format!(" {l}"));
+ }
+ for l in String::from_utf8_lossy(&out.stderr).lines() {
+ log.push(format!(" {l}"));
+ }
+ if out.exit_code != 0 {
+ log.push(format!("✘ el comando salió con código {}", out.exit_code));
+ ok = false;
+ break;
+ }
+ }
+ Err(e) => {
+ log.push(format!("✘ no se pudo ejecutar: {e}"));
+ ok = false;
+ break;
+ }
+ }
+ }
+ }
+
+ StepResult { describe: step.describe.clone(), ok, log }
+ }
+
+ /// Ejecuta un comando arbitrario en el host remoto y devuelve su
+ /// stdout. Útil para discover (p. ej. `docker ps -a --format
+ /// '{{.Names}}'`) sin abrir un `apply` completo. Si el comando
+ /// sale con código distinto de 0, retorna el stderr como `Err`.
+ pub async fn exec(&self, cmd: &str) -> Result<String, SshError> {
+ let out = self.session.exec(cmd).await?;
+ if out.exit_code != 0 {
+ return Err(SshError::Channel(format!(
+ "exit {}: {}",
+ out.exit_code,
+ String::from_utf8_lossy(&out.stderr).trim()
+ )));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+ }
+
+ /// Aplica los pasos en orden sobre el host remoto. Se detiene en el
+ /// primero que falle (semántica `set -e`).
+ pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
+ let mut results = Vec::new();
+ for step in steps {
+ let result = self.apply_step(step).await;
+ let failed = !result.ok;
+ results.push(result);
+ if failed {
+ break;
+ }
+ }
+ ApplyReport { results }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn file_write_command_uses_a_heredoc() {
+ let f = FileWrite {
+ path: "/etc/nginx/sites-enabled/site.conf".into(),
+ content: "server { listen 80; }".into(),
+ };
+ let cmd = file_write_command(&f);
+ assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
+ assert!(cmd.contains("server { listen 80; }"));
+ assert!(cmd.ends_with(HEREDOC));
+ }
+
+ #[test]
+ fn ssh_config_is_re_exported() {
+ // El consumidor arma la conexión sin depender de ssh-multiplex.
+ let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
+ assert_eq!(c.host, "srv.example");
+ }
+
+ // La aplicación remota real (`Linker::connect` + `apply`) necesita un
+ // servidor SSH — se prueba fuera del unit test.
+}
diff --git a/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml
new file mode 100644
index 0000000..983b530
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "matilda-plan"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — reconciliación de estado: compara el inventario deseado con el actual y produce una lista ordenada de acciones que respeta las dependencias."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+serde = { workspace = true }
diff --git a/02_ruway/shuma/baremetal/matilda-plan/LEEME.md b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md
new file mode 100644
index 0000000..87c865f
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md
@@ -0,0 +1,9 @@
+# matilda-plan
+
+> Planificador de diff (actual → deseado) de [matilda](../../README.md).
+
+Toma actual + deseado, produce `Vec<Action>` ordenada por dependencia. Cada `Action` es atómica y reversible.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-plan/README.md b/02_ruway/shuma/baremetal/matilda-plan/README.md
new file mode 100644
index 0000000..5540c04
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/README.md
@@ -0,0 +1,9 @@
+# matilda-plan
+
+> Diff planner (actual → desired) of [matilda](../../README.md).
+
+Takes actual + desired, produces a dependency-ordered `Vec<Action>`. Each `Action` is atomic and reversible.
+
+## Deps
+
+- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
diff --git a/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs
new file mode 100644
index 0000000..09de1ea
--- /dev/null
+++ b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs
@@ -0,0 +1,268 @@
+//! `matilda-plan` — reconciliación de estado deseado vs actual.
+//!
+//! Dado el inventario *actual* de un servidor y el inventario *deseado*,
+//! produce la lista de [`Action`]s que lo lleva de uno al otro. El orden
+//! respeta las dependencias:
+//!
+//! 1. crear/actualizar hosts;
+//! 2. crear/actualizar contenedores (los vhosts dependen de ellos);
+//! 3. crear/actualizar vhosts;
+//! 4. eliminar vhosts (antes que sus contenedores);
+//! 5. eliminar contenedores;
+//! 6. eliminar hosts.
+//!
+//! Es una función pura y determinista — el mismo par de inventarios da
+//! siempre el mismo plan. Aplicarlo (Docker, nginx, SSH) es trabajo de
+//! capas superiores.
+
+#![forbid(unsafe_code)]
+
+use matilda_core::Inventory;
+use serde::{Deserialize, Serialize};
+
+/// El tipo de recurso sobre el que opera una acción.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Resource {
+ Host,
+ Container,
+ VHost,
+}
+
+impl Resource {
+ fn label(self) -> &'static str {
+ match self {
+ Resource::Host => "host",
+ Resource::Container => "contenedor",
+ Resource::VHost => "vhost",
+ }
+ }
+}
+
+/// La operación de una acción.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Op {
+ Create,
+ Update,
+ Remove,
+}
+
+impl Op {
+ fn verb(self) -> &'static str {
+ match self {
+ Op::Create => "crear",
+ Op::Update => "actualizar",
+ Op::Remove => "eliminar",
+ }
+ }
+}
+
+/// Una acción del plan: operar sobre un recurso identificado por nombre.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Action {
+ pub op: Op,
+ pub resource: Resource,
+ /// Nombre del recurso — `name` del host/contenedor, `domain` del vhost.
+ pub name: String,
+}
+
+impl Action {
+ fn new(op: Op, resource: Resource, name: impl Into<String>) -> Self {
+ Self { op, resource, name: name.into() }
+ }
+
+ /// Descripción legible — `"crear contenedor «web»"`.
+ pub fn describe(&self) -> String {
+ format!("{} {} «{}»", self.op.verb(), self.resource.label(), self.name)
+ }
+}
+
+/// El plan de reconciliación: acciones en orden de aplicación.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Plan {
+ pub actions: Vec<Action>,
+}
+
+impl Plan {
+ /// `true` si no hay nada que cambiar — los inventarios ya coinciden.
+ pub fn is_empty(&self) -> bool {
+ self.actions.is_empty()
+ }
+
+ /// Cantidad de acciones.
+ pub fn len(&self) -> usize {
+ self.actions.len()
+ }
+
+ /// Cuenta las acciones de una operación dada.
+ pub fn count(&self, op: Op) -> usize {
+ self.actions.iter().filter(|a| a.op == op).count()
+ }
+}
+
+/// Calcula el plan que lleva de `current` a `desired`.
+pub fn plan(current: &Inventory, desired: &Inventory) -> Plan {
+ let mut actions: Vec<Action> = Vec::new();
+
+ // --- Fase 1: hosts a crear/actualizar ---
+ for h in desired.hosts() {
+ match current.host(&h.name) {
+ None => actions.push(Action::new(Op::Create, Resource::Host, &h.name)),
+ Some(cur) if cur != h => {
+ actions.push(Action::new(Op::Update, Resource::Host, &h.name))
+ }
+ Some(_) => {}
+ }
+ }
+
+ // --- Fase 2: contenedores a crear/actualizar ---
+ for c in desired.containers() {
+ match current.container(&c.name) {
+ None => actions.push(Action::new(Op::Create, Resource::Container, &c.name)),
+ Some(cur) if cur != c => {
+ actions.push(Action::new(Op::Update, Resource::Container, &c.name))
+ }
+ Some(_) => {}
+ }
+ }
+
+ // --- Fase 3: vhosts a crear/actualizar ---
+ for v in desired.vhosts() {
+ match current.vhost(&v.domain) {
+ None => actions.push(Action::new(Op::Create, Resource::VHost, &v.domain)),
+ Some(cur) if cur != v => {
+ actions.push(Action::new(Op::Update, Resource::VHost, &v.domain))
+ }
+ Some(_) => {}
+ }
+ }
+
+ // --- Fase 4: vhosts a eliminar (antes que sus contenedores) ---
+ for v in current.vhosts() {
+ if desired.vhost(&v.domain).is_none() {
+ actions.push(Action::new(Op::Remove, Resource::VHost, &v.domain));
+ }
+ }
+
+ // --- Fase 5: contenedores a eliminar ---
+ for c in current.containers() {
+ if desired.container(&c.name).is_none() {
+ actions.push(Action::new(Op::Remove, Resource::Container, &c.name));
+ }
+ }
+
+ // --- Fase 6: hosts a eliminar ---
+ for h in current.hosts() {
+ if desired.host(&h.name).is_none() {
+ actions.push(Action::new(Op::Remove, Resource::Host, &h.name));
+ }
+ }
+
+ Plan { actions }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use matilda_core::{Container, Host, VHost};
+
+ #[test]
+ fn empty_to_empty_is_a_noop() {
+ let p = plan(&Inventory::new(), &Inventory::new());
+ assert!(p.is_empty());
+ }
+
+ #[test]
+ fn fresh_inventory_is_all_creates() {
+ let mut desired = Inventory::new();
+ desired.add_host(Host::new("edge", "10.0.0.1"));
+ desired.add_container(Container::new("web", "nginx"));
+ desired.add_vhost(VHost::to_container("site.com", "web", 80));
+ let p = plan(&Inventory::new(), &desired);
+ assert_eq!(p.count(Op::Create), 3);
+ assert_eq!(p.count(Op::Remove), 0);
+ }
+
+ #[test]
+ fn unchanged_inventory_yields_no_actions() {
+ let mut inv = Inventory::new();
+ inv.add_container(Container::new("web", "nginx:1.27"));
+ let p = plan(&inv, &inv.clone());
+ assert!(p.is_empty());
+ }
+
+ #[test]
+ fn changed_image_is_an_update() {
+ let mut current = Inventory::new();
+ current.add_container(Container::new("web", "nginx:1.26"));
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("web", "nginx:1.27"));
+ let p = plan(¤t, &desired);
+ assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Container, "web")]);
+ }
+
+ #[test]
+ fn dropped_resources_become_removes() {
+ let mut current = Inventory::new();
+ current.add_container(Container::new("old", "img"));
+ current.add_vhost(VHost::to_container("old.com", "old", 80));
+ let p = plan(¤t, &Inventory::new());
+ assert_eq!(p.count(Op::Remove), 2);
+ }
+
+ #[test]
+ fn vhost_removal_precedes_container_removal() {
+ // Un vhost debe eliminarse antes que el contenedor que lo sirve.
+ let mut current = Inventory::new();
+ current.add_container(Container::new("web", "nginx"));
+ current.add_vhost(VHost::to_container("site.com", "web", 80));
+ let p = plan(¤t, &Inventory::new());
+ let vhost_pos = p
+ .actions
+ .iter()
+ .position(|a| a.resource == Resource::VHost)
+ .unwrap();
+ let cont_pos = p
+ .actions
+ .iter()
+ .position(|a| a.resource == Resource::Container)
+ .unwrap();
+ assert!(vhost_pos < cont_pos);
+ }
+
+ #[test]
+ fn container_creation_precedes_vhost_creation() {
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("web", "nginx"));
+ desired.add_vhost(VHost::to_container("site.com", "web", 80));
+ let p = plan(&Inventory::new(), &desired);
+ let cont_pos = p
+ .actions
+ .iter()
+ .position(|a| a.resource == Resource::Container)
+ .unwrap();
+ let vhost_pos = p
+ .actions
+ .iter()
+ .position(|a| a.resource == Resource::VHost)
+ .unwrap();
+ assert!(cont_pos < vhost_pos);
+ }
+
+ #[test]
+ fn plan_is_deterministic() {
+ let mut current = Inventory::new();
+ current.add_container(Container::new("a", "img:1"));
+ let mut desired = Inventory::new();
+ desired.add_container(Container::new("a", "img:2"));
+ desired.add_container(Container::new("b", "img:1"));
+ assert_eq!(plan(¤t, &desired), plan(¤t, &desired));
+ }
+
+ #[test]
+ fn describe_is_human_readable() {
+ let a = Action::new(Op::Create, Resource::Container, "web");
+ assert_eq!(a.describe(), "crear contenedor «web»");
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-card/Cargo.toml b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml
new file mode 100644
index 0000000..810de74
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "shuma-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de card-core."
+
+[dependencies]
+card-core = { workspace = true }
+# Orquestador único de la suite (Linux): shuma compila sus specs a `Intent`
+# de sandokan en vez de manejar su propio ciclo de vida. Ver SDD de sandokan.
+sandokan-core = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+
+[dev-dependencies]
+# Prueba end-to-end del puente: spec de shuma → Intent → proceso aislado real.
+sandokan-local = { workspace = true }
+tokio = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-card/LEEME.md b/02_ruway/shuma/sandbox/shuma-card/LEEME.md
new file mode 100644
index 0000000..5f77c7a
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-card
+
+> Card escritorio de [shuma](../../README.md).
+
+Stat-card con sesiones activas + última actividad. Click abre el shell.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-card/README.md b/02_ruway/shuma/sandbox/shuma-card/README.md
new file mode 100644
index 0000000..29de46e
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/README.md
@@ -0,0 +1,9 @@
+# shuma-card
+
+> Desktop card of [shuma](../../README.md).
+
+Stat-card with active sessions + last activity. Click opens the shell.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-card/src/lib.rs b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs
new file mode 100644
index 0000000..1972c63
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs
@@ -0,0 +1,774 @@
+//! `shuma-card` — tipos del runtime shuma.
+//!
+//! Tres entidades nuevas encima del `brahman-card::Card`:
+//!
+//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`.
+//! - [`CommandRef`] — un comando dentro de un workspace.
+//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`.
+//!
+//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias
+//! [`card_core::Card`] que el daemon entrega al [`Incarnator`] de
+//! `ente-incarnate`. Esto preserva el contrato canónico del fractal.
+
+#![forbid(unsafe_code)]
+
+use card_core::{Card, Payload, Permissions, SomaSpec, Supervision};
+use sandokan_core::{Intent, IsolationLevel};
+use serde::{Deserialize, Serialize};
+use std::time::Duration;
+use thiserror::Error;
+use ulid::Ulid;
+
+// =====================================================================
+// Identidades
+// =====================================================================
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct WorkspaceId(pub Ulid);
+
+impl WorkspaceId {
+ pub fn new() -> Self {
+ Self(Ulid::new())
+ }
+}
+
+impl Default for WorkspaceId {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl std::fmt::Display for WorkspaceId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct PipelineId(pub Ulid);
+
+impl PipelineId {
+ pub fn new() -> Self {
+ Self(Ulid::new())
+ }
+}
+
+impl Default for PipelineId {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl std::fmt::Display for PipelineId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+// =====================================================================
+// Workspace
+// =====================================================================
+
+/// Espacio aislado de shuma. Es la raíz de aislamiento — cualquier comando
+/// que corre dentro hereda restricciones y no puede aflojarlas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceSpec {
+ pub label: String,
+
+ /// Aislamiento del workspace mismo (cuando se materializa como Card raíz).
+ #[serde(default)]
+ pub soma: SomaSpec,
+
+ /// Permisos máximos para hijas. Hijas pueden bajar pero no subir.
+ #[serde(default)]
+ pub permissions: Permissions,
+
+ /// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d.
+ #[serde(default, with = "opt_duration_millis")]
+ pub ttl: Option<Duration>,
+
+ /// Slots de flow pre-declarados. Limitan qué consumidores externos al
+ /// workspace pueden empatar contra los productores internos.
+ #[serde(default)]
+ pub flow_dirs: Vec<FlowSlot>,
+
+ /// Política al terminar el workspace.
+ #[serde(default)]
+ pub on_exit: ExitPolicy,
+
+ /// Política de enforcement automático cuando un recurso excede su
+ /// rlimit declarado en `soma.rlimits`. Default = sólo accounting
+ /// (None) — el quota report sigue funcionando, pero no hay kill.
+ #[serde(default)]
+ pub quota_enforce: QuotaEnforcement,
+}
+
+/// Acción cuando un recurso excede su límite. Aplica por recurso (mem,
+/// nproc, ...).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum QuotaAction {
+ /// Sólo accounting: la breach aparece en `workspace_quota`.
+ #[default]
+ None,
+ /// Loguear la breach (info-level del daemon).
+ Log,
+ /// Matar todos los comandos vivos del workspace (SIGKILL, sin grace).
+ Kill,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct QuotaEnforcement {
+ #[serde(default)]
+ pub mem: QuotaAction,
+ #[serde(default)]
+ pub nproc: QuotaAction,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowSlot {
+ pub name: String,
+ pub direction: FlowDirection,
+ /// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar.
+ /// Si `Public`, el broker global puede emparejar.
+ #[serde(default)]
+ pub scope: FlowScope,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowDirection {
+ Input,
+ Output,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FlowScope {
+ #[default]
+ Workspace,
+ Public,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ExitPolicy {
+ /// Reapear procesos hijos y descartar estado.
+ #[default]
+ Reap,
+ /// Mantener el workspace en `stopped` para inspección.
+ Keep,
+ /// Tomar snapshot del estado (para restart posterior).
+ Snapshot,
+}
+
+mod opt_duration_millis {
+ use serde::{Deserialize, Deserializer, Serialize, Serializer};
+ use std::time::Duration;
+
+ pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
+ d.map(|x| x.as_millis() as u64).serialize(s)
+ }
+
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
+ let v: Option<u64> = Option::deserialize(d)?;
+ Ok(v.map(Duration::from_millis))
+ }
+}
+
+// =====================================================================
+// CommandRef
+// =====================================================================
+
+/// Un comando que vive dentro de un workspace. Se compila a una `Card` con
+/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec`
+/// intersectado con el del workspace.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandRef {
+ pub label: String,
+ pub payload: Payload,
+
+ /// SomaSpec del comando. El compilador lo intersecta con el del workspace.
+ #[serde(default)]
+ pub soma: SomaSpec,
+
+ /// Inputs/outputs tipados (mismos `Flow` de brahman-card).
+ #[serde(default)]
+ pub flows: card_core::Flows,
+
+ /// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere).
+ #[serde(default = "default_oneshot")]
+ pub supervision: Supervision,
+}
+
+fn default_oneshot() -> Supervision {
+ Supervision::OneShot
+}
+
+// =====================================================================
+// Pipeline
+// =====================================================================
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineSpec {
+ pub label: String,
+ pub workspace: WorkspaceId,
+ pub nodes: Vec<CommandRef>,
+ #[serde(default)]
+ pub edges: Vec<FlowEdge>,
+ #[serde(default)]
+ pub discern: DiscernPolicy,
+ /// Si `true` y cualquier comando del pipeline termina con exit!=0,
+ /// el daemon relaunch el pipeline ENTERO (stop + nuevo run_pipeline).
+ /// Útil para pipelines de procesamiento continuo.
+ #[serde(default)]
+ pub restart_on_failure: bool,
+ /// Backoff inicial entre restarts (ms). Crece exponencialmente
+ /// hasta `restart_max_backoff_ms`. Default 200ms = ~5 restarts/s
+ /// inicial, escalando rápido.
+ #[serde(default = "default_restart_backoff")]
+ pub restart_backoff_ms: u64,
+ /// Backoff máximo (ms). Default 30s. El backoff no crece más allá.
+ #[serde(default = "default_restart_max_backoff")]
+ pub restart_max_backoff_ms: u64,
+ /// Máximo de restarts antes de dar up. `0` = infinito. Default 0.
+ /// Útil para fail-loud cuando un pipeline siempre falla.
+ #[serde(default)]
+ pub restart_max: u32,
+}
+
+fn default_restart_backoff() -> u64 {
+ 200
+}
+fn default_restart_max_backoff() -> u64 {
+ 30_000
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlowEdge {
+ /// Índice en `PipelineSpec.nodes` del productor.
+ pub from: usize,
+ /// Nombre del Flow output del productor.
+ pub from_output: String,
+ /// Índice en `PipelineSpec.nodes` del consumidor.
+ pub to: usize,
+ /// Nombre del Flow input del consumidor.
+ pub to_input: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DiscernPolicy {
+ /// Bytes a samplear por flow para el discernidor. Default 4 KiB.
+ #[serde(default = "default_sample_bytes")]
+ pub sample_bytes: usize,
+ /// Si `true`, enriquece la Card del producer con el TypeRef detectado.
+ #[serde(default = "default_true")]
+ pub enrich_producer: bool,
+ /// Chunks que el FlowChannel guarda en replay buffer para subscribers
+ /// tarde. Default 32. Subir si los productores escriben en ráfagas y
+ /// querés que los consumidores tardíos vean toda la salida.
+ #[serde(default = "default_replay_chunks")]
+ pub replay_chunks: usize,
+ /// Tope adicional por **bytes** acumulados en el replay buffer. Lo
+ /// que se exceda primero (chunks o bytes) drop-ea el chunk más viejo.
+ /// `0` = sin tope por bytes (sólo aplica `replay_chunks`). Útil para
+ /// productores con chunks de tamaño variable.
+ #[serde(default)]
+ pub replay_bytes: usize,
+ /// Rate-limit del flow channel (bytes/s). `0` = sin límite. Si está
+ /// definido, el splitter sleeps proporcional al tamaño del chunk
+ /// antes de re-broadcastear. Protege subscribers lentos.
+ #[serde(default)]
+ pub max_bytes_per_sec: u64,
+}
+
+impl Default for DiscernPolicy {
+ fn default() -> Self {
+ Self {
+ sample_bytes: default_sample_bytes(),
+ enrich_producer: default_true(),
+ replay_chunks: default_replay_chunks(),
+ replay_bytes: 0,
+ max_bytes_per_sec: 0,
+ }
+ }
+}
+
+fn default_sample_bytes() -> usize {
+ 4096
+}
+fn default_true() -> bool {
+ true
+}
+fn default_replay_chunks() -> usize {
+ 32
+}
+
+// =====================================================================
+// Compilación a Card
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum CompileError {
+ #[error("workspace label vacío")]
+ EmptyWorkspaceLabel,
+ #[error("comando con label vacío en posición {0}")]
+ EmptyCommandLabel(usize),
+ #[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")]
+ EdgeOutOfBounds { from: usize, to: usize, nodes: usize },
+}
+
+impl WorkspaceSpec {
+ /// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede
+ /// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por
+ /// sí solo; sólo aloja hijos).
+ pub fn to_card(&self, id: WorkspaceId) -> Result<Card, CompileError> {
+ if self.label.trim().is_empty() {
+ return Err(CompileError::EmptyWorkspaceLabel);
+ }
+ let mut c = Card::new(format!("shuma.workspace.{}", self.label));
+ c.id = id.0;
+ c.soma = self.soma.clone();
+ c.permissions = self.permissions.clone();
+ c.payload = Payload::Virtual;
+ c.supervision = Supervision::OneShot;
+ Ok(c)
+ }
+
+ /// Puente al orquestador único (sandokan): compila el workspace a una
+ /// `Intent` que cualquier `sandokan_core::Engine` (LocalEngine en este
+ /// host, DaemonEngine o RemoteEngine) puede encarnar. Es la Card
+ /// `Virtual` contenedora — aloja comandos hijos que se le unen.
+ pub fn to_intent(
+ &self,
+ id: WorkspaceId,
+ isolation: IsolationLevel,
+ ) -> Result<Intent, CompileError> {
+ Ok(Intent::new(self.to_card(id)?).with_isolation(isolation))
+ }
+
+ /// Puente "ambiente = un shell aislado" (slice 1: *solo aislarlo*).
+ /// A diferencia de [`Self::to_card`] (Card `Virtual` contenedora), esto
+ /// produce una Card **Native** que ejecuta `exec`/`argv` tomando el
+ /// `soma` del workspace como su propio aislamiento — el shell vive
+ /// directamente dentro de los namespaces del ambiente, sin un paso de
+ /// "unirse" a un contenedor aparte. La devuelve como `Intent` lista
+ /// para `Engine::run`.
+ pub fn shell_intent(
+ &self,
+ id: WorkspaceId,
+ exec: impl Into<String>,
+ argv: Vec<String>,
+ isolation: IsolationLevel,
+ ) -> Result<Intent, CompileError> {
+ if self.label.trim().is_empty() {
+ return Err(CompileError::EmptyWorkspaceLabel);
+ }
+ let mut c = Card::new(format!("shuma.workspace.{}", self.label));
+ c.id = id.0;
+ c.soma = self.soma.clone();
+ c.permissions = self.permissions.clone();
+ c.payload = Payload::Native {
+ exec: exec.into(),
+ argv,
+ envp: vec![],
+ };
+ c.supervision = Supervision::OneShot;
+ Ok(Intent::new(c).with_isolation(isolation))
+ }
+}
+
+impl CommandRef {
+ /// Compila un CommandRef a Card hija de un workspace. La Card resultante
+ /// referencia al workspace por label en `pin_to` de cada Flow.
+ pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result<Card, CompileError> {
+ if self.label.trim().is_empty() {
+ return Err(CompileError::EmptyCommandLabel(idx));
+ }
+ let mut c = Card::new(format!("shuma.cmd.{}.{}", workspace_label, self.label));
+ c.payload = self.payload.clone();
+ c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default());
+ c.supervision = self.supervision.clone();
+ c.flow = self.flows.clone();
+ // pin_to del workspace en cada Flow input/output → el broker prefiere
+ // resolver dentro del mismo workspace cuando hay candidatos múltiples.
+ let pin = format!("shuma.workspace.{}", workspace_label);
+ for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) {
+ if f.pin_to.is_none() {
+ f.pin_to = Some(pin.clone());
+ }
+ }
+ Ok(c)
+ }
+}
+
+/// Intersección conservadora: si el workspace pidió aislamiento, la hija
+/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra,
+/// se respeta.
+fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec {
+ let mut out = child.clone();
+ out.namespaces.mount |= ws.namespaces.mount;
+ out.namespaces.pid |= ws.namespaces.pid;
+ out.namespaces.net |= ws.namespaces.net;
+ out.namespaces.uts |= ws.namespaces.uts;
+ out.namespaces.ipc |= ws.namespaces.ipc;
+ out.namespaces.user |= ws.namespaces.user;
+ out.namespaces.cgroup |= ws.namespaces.cgroup;
+ // rlimits: el menor (más restrictivo) gana.
+ out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes);
+ out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc);
+ out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile);
+ out
+}
+
+fn min_opt<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
+ match (a, b) {
+ (Some(x), Some(y)) => Some(x.min(y)),
+ (Some(x), None) | (None, Some(x)) => Some(x),
+ (None, None) => None,
+ }
+}
+
+impl PipelineSpec {
+ pub fn validate(&self) -> Result<(), CompileError> {
+ let n = self.nodes.len();
+ for (i, c) in self.nodes.iter().enumerate() {
+ if c.label.trim().is_empty() {
+ return Err(CompileError::EmptyCommandLabel(i));
+ }
+ }
+ for e in &self.edges {
+ if e.from >= n || e.to >= n {
+ return Err(CompileError::EdgeOutOfBounds {
+ from: e.from,
+ to: e.to,
+ nodes: n,
+ });
+ }
+ }
+ Ok(())
+ }
+}
+
+// =====================================================================
+// I/O conveniencia (TOML + JSON)
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum LoadError {
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("toml: {0}")]
+ Toml(#[from] toml::de::Error),
+ #[error("json: {0}")]
+ Json(#[from] serde_json::Error),
+ #[error("format desconocido (esperado .toml o .json)")]
+ UnknownFormat,
+}
+
+pub fn load_workspace_spec(path: &std::path::Path) -> Result<WorkspaceSpec, LoadError> {
+ let raw = std::fs::read_to_string(path)?;
+ match path.extension().and_then(|s| s.to_str()) {
+ Some("toml") => Ok(toml::from_str(&raw)?),
+ Some("json") => Ok(serde_json::from_str(&raw)?),
+ _ => Err(LoadError::UnknownFormat),
+ }
+}
+
+pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadError> {
+ let raw = std::fs::read_to_string(path)?;
+ match path.extension().and_then(|s| s.to_str()) {
+ Some("toml") => Ok(toml::from_str(&raw)?),
+ Some("json") => Ok(serde_json::from_str(&raw)?),
+ _ => Err(LoadError::UnknownFormat),
+ }
+}
+
+/// Sustituye `${KEY}` en todos los strings del spec por el valor de
+/// `vars["KEY"]`. Variables sin match quedan intactas (no se borra el
+/// placeholder — útil para detectar olvidos).
+///
+/// Walk recursivo sobre la representación JSON intermedia para cubrir
+/// labels, argv, envp, paths y cualquier String del schema.
+pub fn substitute_vars(
+ spec: &PipelineSpec,
+ vars: &std::collections::HashMap<String, String>,
+) -> Result<PipelineSpec, serde_json::Error> {
+ if vars.is_empty() {
+ return Ok(spec.clone());
+ }
+ let mut v = serde_json::to_value(spec)?;
+ walk_subst(&mut v, vars);
+ serde_json::from_value(v)
+}
+
+fn walk_subst(v: &mut serde_json::Value, vars: &std::collections::HashMap<String, String>) {
+ match v {
+ serde_json::Value::String(s) => {
+ *s = subst_str(s, vars);
+ }
+ serde_json::Value::Array(arr) => {
+ for item in arr {
+ walk_subst(item, vars);
+ }
+ }
+ serde_json::Value::Object(obj) => {
+ for (_, val) in obj.iter_mut() {
+ walk_subst(val, vars);
+ }
+ }
+ _ => {}
+ }
+}
+
+fn subst_str(s: &str, vars: &std::collections::HashMap<String, String>) -> String {
+ let mut out = String::with_capacity(s.len());
+ let bytes = s.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
+ // Buscar el cierre `}`.
+ if let Some(close) = bytes[i + 2..].iter().position(|&b| b == b'}') {
+ let key = std::str::from_utf8(&bytes[i + 2..i + 2 + close]).unwrap_or("");
+ if let Some(val) = vars.get(key) {
+ out.push_str(val);
+ i += 2 + close + 1;
+ continue;
+ }
+ }
+ }
+ out.push(bytes[i] as char);
+ i += 1;
+ }
+ out
+}
+
+#[cfg(test)]
+mod subst_tests {
+ use super::*;
+ use std::collections::HashMap;
+
+ #[test]
+ fn substitute_in_argv_and_label() {
+ let mut vars = HashMap::new();
+ vars.insert("MSG".into(), "hola-mundo".into());
+ vars.insert("LABEL".into(), "renamed".into());
+ let spec = PipelineSpec {
+ label: "p-${LABEL}".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![CommandRef {
+ label: "node-${LABEL}".into(),
+ payload: Payload::Native {
+ exec: "/bin/echo".into(),
+ argv: vec!["${MSG}".into()],
+ envp: vec![],
+ },
+ soma: Default::default(),
+ flows: Default::default(),
+ supervision: Supervision::OneShot,
+ }],
+ edges: vec![],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let out = substitute_vars(&spec, &vars).unwrap();
+ assert_eq!(out.label, "p-renamed");
+ assert_eq!(out.nodes[0].label, "node-renamed");
+ match &out.nodes[0].payload {
+ Payload::Native { argv, .. } => assert_eq!(argv[0], "hola-mundo"),
+ _ => panic!("wrong payload"),
+ }
+ }
+
+ #[test]
+ fn unknown_var_left_intact() {
+ let vars = HashMap::new();
+ let spec = PipelineSpec {
+ label: "p-${UNDEFINED}".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![],
+ edges: vec![],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let out = substitute_vars(&spec, &vars).unwrap();
+ assert_eq!(out.label, "p-${UNDEFINED}");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_workspace() -> WorkspaceSpec {
+ WorkspaceSpec {
+ label: "demo".into(),
+ soma: SomaSpec::default(),
+ permissions: Permissions::default(),
+ ttl: Some(Duration::from_secs(60)),
+ flow_dirs: vec![FlowSlot {
+ name: "out".into(),
+ direction: FlowDirection::Output,
+ scope: FlowScope::Public,
+ }],
+ on_exit: ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ }
+ }
+
+ #[test]
+ fn workspace_toml_roundtrip() {
+ let ws = sample_workspace();
+ let s = toml::to_string(&ws).unwrap();
+ let back: WorkspaceSpec = toml::from_str(&s).unwrap();
+ assert_eq!(back.label, ws.label);
+ assert_eq!(back.ttl, ws.ttl);
+ assert_eq!(back.flow_dirs.len(), 1);
+ }
+
+ #[test]
+ fn workspace_json_roundtrip() {
+ let ws = sample_workspace();
+ let s = serde_json::to_string(&ws).unwrap();
+ let back: WorkspaceSpec = serde_json::from_str(&s).unwrap();
+ assert_eq!(back.label, ws.label);
+ }
+
+ #[test]
+ fn workspace_compiles_to_card() {
+ let ws = sample_workspace();
+ let id = WorkspaceId::new();
+ let c = ws.to_card(id).unwrap();
+ assert_eq!(c.id, id.0);
+ assert!(c.label.starts_with("shuma.workspace."));
+ assert!(matches!(c.payload, Payload::Virtual));
+ }
+
+ #[test]
+ fn empty_label_rejected() {
+ let mut ws = sample_workspace();
+ ws.label = String::new();
+ assert!(ws.to_card(WorkspaceId::new()).is_err());
+ }
+
+ #[test]
+ fn workspace_compiles_to_intent_virtual() {
+ let ws = sample_workspace();
+ let intent = ws
+ .to_intent(WorkspaceId::new(), IsolationLevel::Standard)
+ .unwrap();
+ assert!(matches!(intent.card.payload, Payload::Virtual));
+ assert_eq!(intent.context.isolation, Some(IsolationLevel::Standard));
+ }
+
+ #[test]
+ fn shell_intent_is_native_and_inherits_workspace_soma() {
+ let mut ws = sample_workspace();
+ ws.soma.namespaces.user = true;
+ ws.soma.namespaces.pid = true;
+ let intent = ws
+ .shell_intent(
+ WorkspaceId::new(),
+ "/bin/sh",
+ vec!["-c".into(), "true".into()],
+ IsolationLevel::Standard,
+ )
+ .unwrap();
+ match &intent.card.payload {
+ Payload::Native { exec, .. } => assert_eq!(exec, "/bin/sh"),
+ other => panic!("esperaba Native, fue {other:?}"),
+ }
+ assert!(
+ intent.card.soma.namespaces.pid,
+ "el shell del ambiente hereda el soma del workspace"
+ );
+ }
+
+ /// Unificación del orquestador: un `WorkspaceSpec` de shuma se materializa
+ /// como un proceso aislado real **a través del `Engine` de sandokan**
+ /// (LocalEngine), sin que shuma maneje su propio ciclo de vida. El shell
+ /// sale 0 sólo si vio PID 1, probando que el aislamiento del ambiente se
+ /// aplicó end-to-end por el camino unificado.
+ #[tokio::test]
+ async fn workspace_shell_runs_isolated_via_sandokan_engine() {
+ use card_core::NamespaceSet;
+ use sandokan_core::Engine;
+ use sandokan_local::LocalEngine;
+
+ let mut ws = sample_workspace();
+ ws.soma.namespaces = NamespaceSet {
+ user: true,
+ pid: true,
+ mount: true,
+ uts: true,
+ ipc: true,
+ net: false,
+ cgroup: false,
+ };
+
+ let intent = ws
+ .shell_intent(
+ WorkspaceId::new(),
+ "/bin/sh",
+ vec!["-c".into(), "test $$ -eq 1".into()],
+ IsolationLevel::Standard,
+ )
+ .unwrap();
+ let id = intent.card_id();
+
+ let engine = LocalEngine::new();
+ engine.run(intent).await.expect("run vía sandokan");
+ tokio::time::sleep(Duration::from_millis(300)).await;
+ let st = engine.status(id).await.expect("status");
+ assert!(st.is_terminal(), "el shell debió terminar, fue {st:?}");
+ assert!(
+ !st.is_failure(),
+ "exit 0 ⇒ el shell vio PID 1 (ambiente aislado). Estado: {st:?}"
+ );
+ }
+
+ #[test]
+ fn pipeline_validates_edges() {
+ let p = PipelineSpec {
+ label: "p".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![CommandRef {
+ label: "a".into(),
+ payload: Payload::Virtual,
+ soma: SomaSpec::default(),
+ flows: card_core::Flows::default(),
+ supervision: Supervision::OneShot,
+ }],
+ edges: vec![FlowEdge {
+ from: 0,
+ from_output: "x".into(),
+ to: 5,
+ to_input: "y".into(),
+ }],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ assert!(p.validate().is_err());
+ }
+
+ #[test]
+ fn intersect_soma_takes_more_restrictive() {
+ let mut child = SomaSpec::default();
+ child.rlimits.mem_bytes = Some(1_000_000);
+ let mut ws = SomaSpec::default();
+ ws.rlimits.mem_bytes = Some(500_000);
+ ws.namespaces.user = true;
+ let r = intersect_soma(&child, &ws);
+ assert_eq!(r.rlimits.mem_bytes, Some(500_000));
+ assert!(r.namespaces.user);
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-config/Cargo.toml b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml
new file mode 100644
index 0000000..816b07d
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "shuma-config"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — fichero de configuración (.shumarc.toml): aliases, prompt, env, dedup/captura por sesión."
+
+[dependencies]
+serde = { workspace = true }
+toml = { workspace = true }
+directories = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-config/LEEME.md b/02_ruway/shuma/sandbox/shuma-config/LEEME.md
new file mode 100644
index 0000000..20c68d8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-config
+
+> Config del shell de [shuma](../../README.md).
+
+Aliases, env, prompt template, atajos, plugins. TOML serializable. Recarga en runtime sin reiniciar la sesión.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
diff --git a/02_ruway/shuma/sandbox/shuma-config/README.md b/02_ruway/shuma/sandbox/shuma-config/README.md
new file mode 100644
index 0000000..06c7cd9
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/README.md
@@ -0,0 +1,9 @@
+# shuma-config
+
+> Shell config of [shuma](../../README.md).
+
+Aliases, env, prompt template, shortcuts, plugins. TOML serializable. Runtime reload without restarting the session.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
diff --git a/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml
new file mode 100644
index 0000000..eef0d1b
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml
@@ -0,0 +1,29 @@
+# ============================================================
+# cargo.toml — completions extra para `cargo` en shuma-shell.
+#
+# Copiá este fichero a `~/.config/shuma/completions/cargo.toml`
+# (creando el directorio si no existe). Los flags listados se SUMAN al
+# catálogo built-in de shuma-line; no lo reemplazan. Ideal para flags
+# personalizados o nuevos que aún no estén en el catálogo.
+#
+# Convenciones:
+# - Un flag por entrada; el motor de completion los filtra por prefijo.
+# - Si el flag espera valor, terminá en `=` (p.ej. `--manifest-path=`):
+# tras `=` shuma-shell pasa a completar paths.
+# - El array `flags` es lo único soportado hoy; en el futuro se
+# sumarán `subcommands` y `args` con tipo (path/host/etc.).
+# ============================================================
+
+flags = [
+ "--target-dir=",
+ "--manifest-path=",
+ "--profile=",
+ "--config=",
+ "--build-plan",
+ "--message-format=human",
+ "--message-format=json",
+ "--message-format=short",
+ "--timings",
+ "--unit-graph",
+ "-Z",
+]
diff --git a/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml
new file mode 100644
index 0000000..38388f8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml
@@ -0,0 +1,62 @@
+# ============================================================
+# shumarc.toml — configuración personal de `shuma-shell`.
+#
+# Copiá este fichero a `~/.config/shuma/shumarc.toml` (o el
+# equivalente XDG que use tu SO) y editá lo que quieras. Cualquier
+# sección omitida cae a los valores por defecto.
+# ============================================================
+
+# ---- Aliases ----
+# La primera palabra de la línea de comando se reemplaza por el
+# cuerpo si coincide con un alias. No hay parámetros posicionales
+# ni recursión: lo que devuelve el alias es lo que se ejecuta.
+[aliases]
+ll = "ls -la --color=auto"
+la = "ls -A --color=auto"
+gs = "git status --short"
+gl = "git log --oneline --graph --decorate"
+gd = "git diff"
+cb = "cargo build"
+ct = "cargo test"
+cr = "cargo run"
+
+# ---- Variables de entorno ----
+# Se aplican al proceso del shell al arrancar; los hijos las heredan.
+[env]
+EDITOR = "hx"
+PAGER = "less -R"
+
+# ---- Prompt ----
+# Segmentos en orden. Tokens soportados:
+# "cwd" — directorio actual (con `~` corto si es $HOME).
+# "git" — rama actual, si estamos en un repo.
+# "exit" — código de salida del último comando, si fue ≠ 0.
+# "time" — HH:MM:SS local.
+# <otro> — literal, se muestra tal cual (útil como separador).
+[prompt]
+segments = ["cwd", "git", "exit"]
+
+# ---- Historial durable ----
+# Política de dedup al persistir en ~/.local/share/shuma/history.jsonl:
+# "none" — guarda todo.
+# "ignore_consecutive" — descarta repeticiones inmediatas (default).
+# "erase_dups" — al ver un duplicado, borra las copias previas.
+[history]
+dedup = "ignore_consecutive"
+
+# ---- Captura de salida por sesión ----
+# `limit_mb = 0` desactiva el tope; `spill = true` vuelca al disco la
+# salida que excede el tope (útil para builds gigantes).
+[capture]
+limit_mb = 8
+spill = false
+
+# ---- Completion de flags ----
+# El catálogo built-in de shuma-line cubre ~40 comandos típicos. Para
+# ampliarlo, dejá un archivo por comando en
+# `$XDG_CONFIG_HOME/shuma/completions/<cmd>.toml` con la forma:
+#
+# flags = ["--mi-flag", "--otro=", "-x"]
+#
+# Los flags terminados en `=` activan completion de path tras el `=`.
+# Ver `completions.example/cargo.toml` en el repo para un caso real.
diff --git a/02_ruway/shuma/sandbox/shuma-config/src/lib.rs b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs
new file mode 100644
index 0000000..9524ec0
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs
@@ -0,0 +1,474 @@
+//! `shuma-config` — el fichero de configuración personal del shell.
+//!
+//! Se carga al arrancar desde `$XDG_CONFIG_HOME/shuma/shumarc.toml`
+//! (típicamente `~/.config/shuma/shumarc.toml` en Linux). Si no existe
+//! o no se pudo parsear, el shell arranca con [`Config::default`] —
+//! aquí no hay nada crítico, sólo preferencias del usuario.
+//!
+//! Esquema mínimo:
+//!
+//! ```toml
+//! # ---- Aliases ----
+//! # Se expanden ANTES del tokenizer: la primera palabra de la línea,
+//! # si coincide, se reemplaza por el cuerpo.
+//! [aliases]
+//! ll = "ls -la"
+//! gs = "git status --short"
+//!
+//! # ---- Variables de entorno ----
+//! # Se exportan al proceso del shell al cargar; los procesos hijos las
+//! # heredan.
+//! [env]
+//! EDITOR = "hx"
+//!
+//! # ---- Prompt ----
+//! # Segmentos en orden. Tokens soportados:
+//! # "cwd", "git", "exit", "time", o cualquier literal.
+//! [prompt]
+//! segments = ["cwd", "git", "exit"]
+//!
+//! # ---- Historial durable ----
+//! [history]
+//! dedup = "ignore_consecutive" # none | ignore_consecutive | erase_dups
+//!
+//! # ---- Captura de salida ----
+//! [capture]
+//! limit_mb = 8
+//! spill = false
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Política de deduplicación, paralela a la de `shuma-history` pero
+/// codificada como string en el fichero TOML para que el rc sea legible.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DedupPolicy {
+ None,
+ #[default]
+ IgnoreConsecutive,
+ EraseDups,
+}
+
+/// Configuración del historial durable.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct HistoryConfig {
+ #[serde(default)]
+ pub dedup: DedupPolicy,
+}
+
+/// Configuración de la política de captura de salida por sesión.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CaptureConfig {
+ /// Tope en MiB; `0` = sin tope.
+ #[serde(default = "default_limit_mb")]
+ pub limit_mb: usize,
+ /// Si la salida que excede el tope se vuelca a disco.
+ #[serde(default)]
+ pub spill: bool,
+}
+
+fn default_limit_mb() -> usize {
+ 8
+}
+
+impl Default for CaptureConfig {
+ fn default() -> Self {
+ Self { limit_mb: 8, spill: false }
+ }
+}
+
+/// Configuración del prompt — segmentos en orden.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PromptConfig {
+ #[serde(default = "default_segments")]
+ pub segments: Vec<String>,
+}
+
+fn default_segments() -> Vec<String> {
+ vec!["cwd".into()]
+}
+
+impl Default for PromptConfig {
+ fn default() -> Self {
+ Self { segments: default_segments() }
+ }
+}
+
+/// Configuración completa cargada del `.shumarc.toml`.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct Config {
+ /// Aliases: la primera palabra de una línea se reemplaza por el cuerpo.
+ #[serde(default)]
+ pub aliases: HashMap<String, String>,
+ /// Variables de entorno a exportar al proceso del shell al cargar.
+ #[serde(default)]
+ pub env: HashMap<String, String>,
+ #[serde(default)]
+ pub prompt: PromptConfig,
+ #[serde(default)]
+ pub history: HistoryConfig,
+ #[serde(default)]
+ pub capture: CaptureConfig,
+}
+
+impl Config {
+ /// Ruta por defecto: `$XDG_CONFIG_HOME/shuma/shumarc.toml`. `None` si
+ /// el SO no expone un directorio de configuración.
+ pub fn default_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "shuma")
+ .map(|d| d.config_dir().join("shumarc.toml"))
+ }
+
+ /// Directorio donde el shell busca completions extendidas:
+ /// `$XDG_CONFIG_HOME/shuma/completions/`. Cada archivo `<cmd>.toml`
+ /// declara las flags de un comando — el shell las suma a la tabla
+ /// estática de [`shuma_line::flag_hints`].
+ pub fn completions_dir() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "shuma")
+ .map(|d| d.config_dir().join("completions"))
+ }
+
+ /// Carga la configuración del path indicado. Si el fichero no existe
+ /// devuelve [`Config::default`] sin error (caso normal en arranque
+ /// limpio).
+ pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
+ let path = path.as_ref();
+ if !path.exists() {
+ return Ok(Self::default());
+ }
+ let text = std::fs::read_to_string(path)
+ .map_err(|e| ConfigError::Io(path.to_path_buf(), e))?;
+ toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
+ }
+
+ /// Carga la configuración del path por defecto. Errores blandos
+ /// (parse, IO) se devuelven; ausencia del fichero da `default`.
+ pub fn load_default() -> Result<Self, ConfigError> {
+ match Self::default_path() {
+ Some(p) => Self::load(p),
+ None => Ok(Self::default()),
+ }
+ }
+
+ /// Aplica las variables de entorno declaradas al proceso actual.
+ /// Pensado para llamarse una vez al arrancar el shell; los procesos
+ /// hijos heredan el entorno y verán los valores.
+ pub fn apply_env(&self) {
+ for (k, v) in &self.env {
+ // SAFETY: setenv no es seguro en presencia de hilos concurrentes
+ // que lean getenv. El shell la llama una vez en el hilo principal,
+ // antes de spawnear ningún subproceso, así que es válido.
+ // SAFETY (Rust 2024): `set_var` es unsafe sólo bajo
+ // edición 2024; en 2021 sigue siendo seguro.
+ std::env::set_var(k, v);
+ }
+ }
+
+ /// Expande aliases en una línea: si la **primera palabra** coincide
+ /// con un alias, se reemplaza por su cuerpo. El resto de la línea
+ /// queda intacto.
+ ///
+ /// Convención simple — sin parámetros posicionales, sin recursión
+ /// (un alias se expande una vez, no se persigue el resultado).
+ pub fn expand_aliases<'a>(&self, line: &'a str) -> std::borrow::Cow<'a, str> {
+ let trimmed = line.trim_start();
+ let leading_ws = line.len() - trimmed.len();
+ let (head, rest) = match trimmed.find(char::is_whitespace) {
+ Some(i) => (&trimmed[..i], &trimmed[i..]),
+ None => (trimmed, ""),
+ };
+ if let Some(body) = self.aliases.get(head) {
+ let mut out = String::with_capacity(line.len() + body.len());
+ out.push_str(&line[..leading_ws]);
+ out.push_str(body);
+ out.push_str(rest);
+ std::borrow::Cow::Owned(out)
+ } else {
+ std::borrow::Cow::Borrowed(line)
+ }
+ }
+}
+
+impl From<DedupPolicy> for &'static str {
+ fn from(p: DedupPolicy) -> Self {
+ match p {
+ DedupPolicy::None => "none",
+ DedupPolicy::IgnoreConsecutive => "ignore_consecutive",
+ DedupPolicy::EraseDups => "erase_dups",
+ }
+ }
+}
+
+/// Expande `$VAR` y `${VAR}` en un texto contra `getenv`. Si la variable
+/// no existe, se sustituye por cadena vacía — convención bash. Las
+/// barras `\$` escapan el signo.
+pub fn expand_env(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let bytes = s.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ let c = bytes[i];
+ if c == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
+ out.push('$');
+ i += 2;
+ continue;
+ }
+ if c != b'$' {
+ out.push(bytes[i] as char);
+ i += 1;
+ continue;
+ }
+ // `$VAR` o `${VAR}`.
+ let (name_end, with_braces) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
+ // `${VAR}` — buscar la `}` que cierra.
+ match s[i + 2..].find('}') {
+ Some(off) => (i + 2 + off, true),
+ None => {
+ out.push('$');
+ i += 1;
+ continue;
+ }
+ }
+ } else {
+ let start = i + 1;
+ let mut end = start;
+ while end < bytes.len()
+ && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
+ {
+ end += 1;
+ }
+ if end == start {
+ // `$` solo: literal.
+ out.push('$');
+ i += 1;
+ continue;
+ }
+ (end, false)
+ };
+ let name_start = if with_braces { i + 2 } else { i + 1 };
+ let name = &s[name_start..name_end];
+ if let Ok(val) = std::env::var(name) {
+ out.push_str(&val);
+ }
+ i = name_end + if with_braces { 1 } else { 0 };
+ }
+ out
+}
+
+/// Completion declarada por el usuario para un comando concreto.
+/// Esquema mínimo en `<cmd>.toml`:
+///
+/// ```toml
+/// flags = ["--foo", "--bar=", "-x"]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct CommandCompletion {
+ #[serde(default)]
+ pub flags: Vec<String>,
+}
+
+impl CommandCompletion {
+ /// Carga `<dir>/<cmd>.toml` si existe, o devuelve `None`. Si el
+ /// archivo está roto, también `None` — completions son nice-to-have,
+ /// no deben caer el shell.
+ pub fn load(dir: &Path, command: &str) -> Option<Self> {
+ let path = dir.join(format!("{command}.toml"));
+ let text = std::fs::read_to_string(path).ok()?;
+ toml::from_str(&text).ok()
+ }
+
+ /// Carga *todas* las completions de un directorio en un HashMap.
+ /// Útil para precargar al arrancar el shell (un read_dir + N lecturas
+ /// pequeñas; barato comparado con el coste de un fork).
+ pub fn load_all(dir: &Path) -> HashMap<String, Self> {
+ let mut out = HashMap::new();
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return out;
+ };
+ for e in entries.flatten() {
+ let path = e.path();
+ let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
+ continue;
+ };
+ if path.extension().and_then(|e| e.to_str()) != Some("toml") {
+ continue;
+ }
+ if let Ok(text) = std::fs::read_to_string(&path) {
+ if let Ok(c) = toml::from_str::<CommandCompletion>(&text) {
+ out.insert(stem.to_string(), c);
+ }
+ }
+ }
+ out
+ }
+}
+
+/// Errores al cargar la configuración.
+#[derive(Debug)]
+pub enum ConfigError {
+ Io(PathBuf, std::io::Error),
+ Parse(PathBuf, toml::de::Error),
+}
+
+impl std::fmt::Display for ConfigError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ConfigError::Io(p, e) => write!(f, "lectura de {}: {}", p.display(), e),
+ ConfigError::Parse(p, e) => write!(f, "parseo de {}: {}", p.display(), e),
+ }
+ }
+}
+
+impl std::error::Error for ConfigError {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ #[test]
+ fn missing_file_yields_default() {
+ let d = tempdir().unwrap();
+ let c = Config::load(d.path().join("nope.toml")).unwrap();
+ assert_eq!(c, Config::default());
+ }
+
+ #[test]
+ fn parses_a_full_example() {
+ let d = tempdir().unwrap();
+ let path = d.path().join("shumarc.toml");
+ std::fs::write(
+ &path,
+ r#"
+[aliases]
+ll = "ls -la"
+gs = "git status"
+
+[env]
+EDITOR = "hx"
+
+[prompt]
+segments = ["cwd", "git", "exit"]
+
+[history]
+dedup = "erase_dups"
+
+[capture]
+limit_mb = 16
+spill = true
+"#,
+ )
+ .unwrap();
+ let c = Config::load(&path).unwrap();
+ assert_eq!(c.aliases.get("ll").map(|s| s.as_str()), Some("ls -la"));
+ assert_eq!(c.env.get("EDITOR").map(|s| s.as_str()), Some("hx"));
+ assert_eq!(c.prompt.segments, vec!["cwd", "git", "exit"]);
+ assert_eq!(c.history.dedup, DedupPolicy::EraseDups);
+ assert_eq!(c.capture.limit_mb, 16);
+ assert!(c.capture.spill);
+ }
+
+ #[test]
+ fn partial_toml_falls_back_to_defaults() {
+ // Sólo aliases — el resto debe defaultear, no fallar.
+ let d = tempdir().unwrap();
+ let path = d.path().join("shumarc.toml");
+ std::fs::write(&path, "[aliases]\nll = \"ls -la\"\n").unwrap();
+ let c = Config::load(&path).unwrap();
+ assert_eq!(c.aliases.len(), 1);
+ assert_eq!(c.prompt, PromptConfig::default());
+ assert_eq!(c.capture, CaptureConfig::default());
+ }
+
+ #[test]
+ fn alias_expansion_replaces_first_word_only() {
+ let mut c = Config::default();
+ c.aliases.insert("ll".into(), "ls -la".into());
+ assert_eq!(c.expand_aliases("ll"), "ls -la");
+ assert_eq!(c.expand_aliases("ll src/"), "ls -la src/");
+ // `ll` en el medio no es un alias.
+ assert_eq!(c.expand_aliases("echo ll"), "echo ll");
+ }
+
+ #[test]
+ fn alias_preserves_leading_whitespace() {
+ let mut c = Config::default();
+ c.aliases.insert("ll".into(), "ls -la".into());
+ // Un comando indentado mantiene su indentación tras expandir.
+ assert_eq!(c.expand_aliases(" ll src/"), " ls -la src/");
+ }
+
+ #[test]
+ fn alias_does_not_recurse() {
+ // No queremos que un alias expandido se vuelva a expandir —
+ // evita bucles infinitos triviales (ll=ls, ls=ll).
+ let mut c = Config::default();
+ c.aliases.insert("a".into(), "b".into());
+ c.aliases.insert("b".into(), "c".into());
+ assert_eq!(c.expand_aliases("a"), "b");
+ }
+
+ #[test]
+ fn expand_env_substitutes_vars() {
+ // Usamos una var artificial para no colisionar con el entorno real.
+ // SAFETY: ver `Config::apply_env`; en tests de un solo hilo es OK.
+ std::env::set_var("SHUMA_TEST_VAR", "valor");
+ assert_eq!(expand_env("$SHUMA_TEST_VAR"), "valor");
+ assert_eq!(expand_env("${SHUMA_TEST_VAR}/bin"), "valor/bin");
+ // Variable inexistente → cadena vacía.
+ std::env::remove_var("SHUMA_TEST_NOPE");
+ assert_eq!(expand_env("x=$SHUMA_TEST_NOPE!"), "x=!");
+ // `\$` se escapa.
+ assert_eq!(expand_env("precio \\$5"), "precio $5");
+ }
+
+ #[test]
+ fn expand_env_keeps_dollar_alone() {
+ std::env::remove_var("SHUMA_TEST_FOO");
+ assert_eq!(expand_env("$ "), "$ ");
+ assert_eq!(expand_env("$"), "$");
+ }
+
+ #[test]
+ fn completion_loads_per_command_file() {
+ let d = tempdir().unwrap();
+ std::fs::write(
+ d.path().join("mytool.toml"),
+ "flags = [\"--foo\", \"--bar=\"]\n",
+ )
+ .unwrap();
+ let c = CommandCompletion::load(d.path(), "mytool").unwrap();
+ assert_eq!(c.flags, vec!["--foo", "--bar="]);
+ // Comando inexistente → None.
+ assert!(CommandCompletion::load(d.path(), "nope").is_none());
+ }
+
+ #[test]
+ fn completion_loads_all_in_dir() {
+ let d = tempdir().unwrap();
+ std::fs::write(d.path().join("alfa.toml"), "flags = [\"--a\"]\n").unwrap();
+ std::fs::write(d.path().join("beta.toml"), "flags = [\"--b\"]\n").unwrap();
+ std::fs::write(d.path().join("ignored.txt"), "no soy toml").unwrap();
+ let all = CommandCompletion::load_all(d.path());
+ assert_eq!(all.len(), 2);
+ assert!(all.contains_key("alfa"));
+ assert!(all.contains_key("beta"));
+ assert!(!all.contains_key("ignored"));
+ }
+
+ #[test]
+ fn corrupt_completion_file_is_skipped() {
+ let d = tempdir().unwrap();
+ std::fs::write(d.path().join("bad.toml"), "not = valid = toml").unwrap();
+ std::fs::write(d.path().join("good.toml"), "flags = [\"--ok\"]\n").unwrap();
+ let all = CommandCompletion::load_all(d.path());
+ assert!(all.contains_key("good"));
+ assert!(!all.contains_key("bad"));
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/Cargo.toml b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml
new file mode 100644
index 0000000..5f22db8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "shuma-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Runtime de shuma: WorkspaceManager sobre arje-incarnate. Estado in-memory, lifecycle, reaping."
+
+[dependencies]
+shuma-card = { path = "../shuma-card" }
+shuma-discern = { workspace = true }
+card-core = { workspace = true }
+arje-incarnate = { workspace = true }
+nix = { workspace = true }
+libc = { workspace = true }
+anyhow = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+ulid = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-core/LEEME.md b/02_ruway/shuma/sandbox/shuma-core/LEEME.md
new file mode 100644
index 0000000..4332090
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-core
+
+> Tipos de [shuma](../../README.md): `Session`, `Command`, `Output`.
+
+Núcleo sin transporte ni UI. `Session` lleva history, env, cwd. `Command` representa lo tipeado. `Output` puede ser texto, imagen, link.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/shuma/sandbox/shuma-core/README.md b/02_ruway/shuma/sandbox/shuma-core/README.md
new file mode 100644
index 0000000..19a39c4
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/README.md
@@ -0,0 +1,9 @@
+# shuma-core
+
+> Types of [shuma](../../README.md): `Session`, `Command`, `Output`.
+
+Core without transport or UI. `Session` holds history, env, cwd. `Command` represents what the user typed. `Output` can be text, image, link.
+
+## Deps
+
+- `serde`, `uuid`
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs
new file mode 100644
index 0000000..a56797f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs
@@ -0,0 +1,478 @@
+//! Flow channels: data plane sobre Unix socket por edge enriquecido.
+//!
+//! Cuando un splitter detecta el TypeRef de un edge, además de replicar a
+//! los consumers internos del pipeline, se levanta un FlowChannel que
+//! expone los bytes a subscribers externos (otros módulos del fractal).
+//!
+//! ## Diseño
+//!
+//! - `tokio::sync::broadcast::channel` para fan-out lock-less entre el
+//! splitter (sender) y los N subscribers conectados.
+//! - `UnixListener` accept-loop: por cada cliente nuevo, spawn una task
+//! que drena el receiver y escribe al socket.
+//! - Subscribers lentos pueden perder mensajes (broadcast::Receiver::Lagged)
+//! — se loguea warn y se sigue. Esto es deliberado para no bloquear el
+//! splitter en consumers lentos.
+//!
+//! ## Lifetime
+//!
+//! `FlowChannel` se construye con `new(path)`. Cuando se drop:
+//! - El `accept_task` se cancela (vía drop del `tokio::task::JoinHandle`
+//! que tenemos abort-on-drop).
+//! - El socket file se borra del FS (`Drop` impl).
+//!
+//! Sender clones son baratos; los subscribers conectados se enteran del
+//! cierre cuando todos los senders se dropean.
+
+use std::collections::VecDeque;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+use tokio::io::AsyncWriteExt;
+use tokio::net::UnixListener;
+use tokio::sync::broadcast;
+use tokio::task::AbortHandle;
+use tracing::{debug, warn};
+
+/// Capacidad del broadcast channel. Si un subscriber está más de N chunks
+/// atrasado, queda `Lagged` y empieza a perder mensajes.
+const BROADCAST_CAP: usize = 64;
+
+/// Chunks default del replay buffer. Cuando un cliente nuevo se conecta,
+/// recibe hasta estos N chunks antes de iniciar el broadcast live.
+/// Override via `FlowChannel::with_replay_cap`.
+pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
+
+pub struct FlowChannel {
+ sender: broadcast::Sender<Arc<Vec<u8>>>,
+ replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
+ replay_caps: ReplayCaps,
+ socket_path: PathBuf,
+ meter: Arc<FlowMeter>,
+ _accept_handle: AbortOnDrop,
+}
+
+/// Contador de bytes y rate (bytes/s ventana 1s).
+#[derive(Debug)]
+pub struct FlowMeter {
+ /// Bytes acumulados desde la creación del FlowChannel.
+ total_bytes: std::sync::atomic::AtomicU64,
+ /// Ring buffer de (timestamp_ms, bytes_acumulados) para calcular
+ /// el rate sobre los últimos N samples.
+ rate_window: Mutex<VecDeque<(u64, u64)>>,
+}
+
+const RATE_WINDOW_SAMPLES: usize = 32;
+
+impl FlowMeter {
+ fn new() -> Self {
+ Self {
+ total_bytes: std::sync::atomic::AtomicU64::new(0),
+ rate_window: Mutex::new(VecDeque::with_capacity(RATE_WINDOW_SAMPLES)),
+ }
+ }
+
+ fn record(&self, delta: u64) {
+ let now = self.total_bytes
+ .fetch_add(delta, std::sync::atomic::Ordering::Relaxed)
+ + delta;
+ let ts = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0);
+ if let Ok(mut w) = self.rate_window.lock() {
+ if w.len() >= RATE_WINDOW_SAMPLES {
+ w.pop_front();
+ }
+ w.push_back((ts, now));
+ }
+ }
+
+ /// Bytes totales acumulados desde la creación.
+ pub fn total_bytes(&self) -> u64 {
+ self.total_bytes.load(std::sync::atomic::Ordering::Relaxed)
+ }
+
+ /// Bytes por segundo (rolling sobre la ventana). 0 si no hay
+ /// historia suficiente o si el último sample es muy viejo (>5s).
+ pub fn bytes_per_sec(&self) -> f64 {
+ let now_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0);
+ let w = match self.rate_window.lock() {
+ Ok(w) => w,
+ Err(_) => return 0.0,
+ };
+ if w.len() < 2 {
+ return 0.0;
+ }
+ let last = w.back().copied().unwrap();
+ // Si el último sample tiene >5s, asumimos idle.
+ if now_ms.saturating_sub(last.0) > 5000 {
+ return 0.0;
+ }
+ let first = w.front().copied().unwrap();
+ let dt_ms = last.0.saturating_sub(first.0).max(1);
+ let d_bytes = last.1.saturating_sub(first.1);
+ (d_bytes as f64 * 1000.0) / dt_ms as f64
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ReplayCaps {
+ /// Máximo de chunks retenidos.
+ pub chunks: usize,
+ /// Máximo de bytes (sumando len de chunks). `0` = sin tope.
+ pub bytes: usize,
+}
+
+impl ReplayCaps {
+ pub fn chunks_only(chunks: usize) -> Self {
+ Self { chunks: chunks.max(1), bytes: 0 }
+ }
+ pub fn new(chunks: usize, bytes: usize) -> Self {
+ Self { chunks: chunks.max(1), bytes }
+ }
+}
+
+#[derive(Clone)]
+pub struct FlowSender {
+ sender: broadcast::Sender<Arc<Vec<u8>>>,
+ replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
+ replay_caps: ReplayCaps,
+ meter: Arc<FlowMeter>,
+}
+
+impl FlowSender {
+ /// Pushea al broadcast y al replay buffer. Si no hay subscribers,
+ /// el broadcast::send retorna Err pero igual guardamos en replay
+ /// (subscribers tarde verán los chunks pasados).
+ pub fn send(&self, data: Arc<Vec<u8>>) {
+ let incoming = data.len();
+ let caps = self.replay_caps;
+ if let Ok(mut g) = self.replay.lock() {
+ evict_for_incoming(&mut g, caps, incoming);
+ g.push_back(data.clone());
+ }
+ self.meter.record(incoming as u64);
+ let _ = self.sender.send(data);
+ }
+}
+
+/// Evict los chunks más viejos para hacer espacio a un chunk entrante de
+/// `incoming` bytes — el buffer post-push queda dentro de los caps.
+fn evict_for_incoming(buf: &mut VecDeque<Arc<Vec<u8>>>, caps: ReplayCaps, incoming: usize) {
+ // 1) chunks: dejar lugar para 1 más.
+ while buf.len() + 1 > caps.chunks {
+ if buf.pop_front().is_none() {
+ break;
+ }
+ }
+ // 2) bytes (si está activado).
+ if caps.bytes > 0 {
+ let mut current: usize = buf.iter().map(|a| a.len()).sum();
+ while current + incoming > caps.bytes {
+ match buf.pop_front() {
+ Some(c) => current = current.saturating_sub(c.len()),
+ None => break,
+ }
+ }
+ }
+}
+
+impl std::fmt::Debug for FlowChannel {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("FlowChannel")
+ .field("socket_path", &self.socket_path)
+ .field("subscribers", &self.sender.receiver_count())
+ .finish()
+ }
+}
+
+impl FlowChannel {
+ /// Crea un FlowChannel atado al path `socket_path`. Si el path ya
+ /// existe, lo borra antes de bind (asume restart limpio).
+ pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
+ Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(DEFAULT_REPLAY_CHUNKS))
+ }
+
+ pub fn with_replay_cap(socket_path: PathBuf, chunks: usize) -> std::io::Result<Self> {
+ Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks))
+ }
+
+ pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result<Self> {
+ if socket_path.exists() {
+ let _ = std::fs::remove_file(&socket_path);
+ }
+ if let Some(parent) = socket_path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let listener = UnixListener::bind(&socket_path)?;
+ let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
+ let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
+ Arc::new(Mutex::new(VecDeque::with_capacity(caps.chunks)));
+ let tx_for_accept = tx.clone();
+ let replay_for_accept = replay.clone();
+ let path_for_log = socket_path.clone();
+
+ let join = tokio::spawn(async move {
+ debug!(path = %path_for_log.display(), "flow channel listening");
+ loop {
+ let (mut stream, _addr) = match listener.accept().await {
+ Ok(p) => p,
+ Err(e) => {
+ warn!(?e, "flow channel accept failed");
+ return;
+ }
+ };
+ // Snapshot del replay buffer Y subscribe al broadcast.
+ // El orden es crítico: subscribe ANTES de drenar el replay
+ // para no perder chunks que llegan justo en el medio.
+ let mut rx = tx_for_accept.subscribe();
+ let snapshot: Vec<Arc<Vec<u8>>> = {
+ let g = replay_for_accept.lock().expect("replay lock");
+ g.iter().cloned().collect()
+ };
+ tokio::spawn(async move {
+ // Fase 1: drenar replay snapshot al subscriber.
+ for chunk in &snapshot {
+ if let Err(e) = stream.write_all(chunk).await {
+ debug!(?e, "flow subscriber dropped during replay");
+ return;
+ }
+ }
+ // Fase 2: live broadcast.
+ loop {
+ match rx.recv().await {
+ Ok(chunk) => {
+ if let Err(e) = stream.write_all(&chunk).await {
+ debug!(?e, "flow subscriber dropped");
+ return;
+ }
+ }
+ Err(broadcast::error::RecvError::Closed) => return,
+ Err(broadcast::error::RecvError::Lagged(n)) => {
+ warn!(skipped = n, "flow subscriber lagged");
+ }
+ }
+ }
+ });
+ }
+ });
+
+ Ok(Self {
+ sender: tx,
+ replay,
+ replay_caps: caps,
+ socket_path,
+ meter: Arc::new(FlowMeter::new()),
+ _accept_handle: AbortOnDrop(join.abort_handle()),
+ })
+ }
+
+ pub fn meter(&self) -> &FlowMeter {
+ &self.meter
+ }
+
+ /// Push un chunk al channel. Si no hay subscribers, drop silencioso.
+ /// Siempre se guarda en el replay buffer (con cap rotation por chunks
+ /// y opcionalmente por bytes).
+ pub fn send(&self, data: Vec<u8>) {
+ let incoming = data.len();
+ let arc = Arc::new(data);
+ let caps = self.replay_caps;
+ if let Ok(mut g) = self.replay.lock() {
+ evict_for_incoming(&mut g, caps, incoming);
+ g.push_back(arc.clone());
+ }
+ self.meter.record(incoming as u64);
+ let _ = self.sender.send(arc);
+ }
+
+ pub fn socket_path(&self) -> &Path {
+ &self.socket_path
+ }
+
+ /// Handle clone-able para que tasks externas (splitter) pushen al
+ /// channel sin tener ownership del FlowChannel. Cada push se guarda
+ /// también en el replay buffer y se contabiliza en el meter.
+ pub fn sender_handle(&self) -> FlowSender {
+ FlowSender {
+ sender: self.sender.clone(),
+ replay: self.replay.clone(),
+ replay_caps: self.replay_caps,
+ meter: self.meter.clone(),
+ }
+ }
+
+ pub fn subscriber_count(&self) -> usize {
+ self.sender.receiver_count()
+ }
+}
+
+impl Drop for FlowChannel {
+ fn drop(&mut self) {
+ // El AbortOnDrop cancela el accept loop; sólo nos queda limpiar el
+ // socket file.
+ let _ = std::fs::remove_file(&self.socket_path);
+ }
+}
+
+struct AbortOnDrop(AbortHandle);
+impl Drop for AbortOnDrop {
+ fn drop(&mut self) {
+ self.0.abort();
+ }
+}
+
+/// Path canónico para un flow channel: `$XDG_RUNTIME_DIR/shuma-flow-<id>.sock`.
+pub fn default_flow_socket_path(id: &str) -> PathBuf {
+ let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
+ let uid = nix::unistd::getuid().as_raw();
+ let p = format!("/run/user/{uid}");
+ if std::path::Path::new(&p).exists() {
+ p
+ } else {
+ "/tmp".into()
+ }
+ });
+ PathBuf::from(base).join(format!("shuma-flow-{id}.sock"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tokio::io::AsyncReadExt;
+ use tokio::net::UnixStream;
+
+ #[tokio::test]
+ async fn channel_delivers_to_subscriber() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("flow.sock");
+ let ch = FlowChannel::new(path.clone()).unwrap();
+
+ // Subscriber se conecta.
+ let path_clone = path.clone();
+ let task = tokio::spawn(async move {
+ let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+ let mut buf = vec![0u8; 64];
+ let n = stream.read(&mut buf).await.unwrap();
+ buf.truncate(n);
+ buf
+ });
+
+ // Damos tiempo al accept.
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ // Hasta que haya 1 receiver_count, el send no llega.
+ for _ in 0..50 {
+ if ch.subscriber_count() >= 1 {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ }
+ ch.send(b"hello-flow".to_vec());
+
+ let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+ .await
+ .expect("timeout")
+ .unwrap();
+ assert_eq!(received, b"hello-flow");
+ }
+
+ #[tokio::test]
+ async fn replay_buffer_serves_late_subscriber() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("flow.sock");
+ let ch = FlowChannel::new(path.clone()).unwrap();
+
+ // Pushes ANTES de cualquier subscriber: van solo al replay.
+ ch.send(b"chunk-1".to_vec());
+ ch.send(b"chunk-2".to_vec());
+ ch.send(b"chunk-3".to_vec());
+
+ // Subscriber LATE — debe recibir los 3 chunks del replay.
+ let path_clone = path.clone();
+ let task = tokio::spawn(async move {
+ let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+ let mut buf = vec![0u8; 256];
+ // Leemos hasta recibir los 3 chunks (21 bytes esperados).
+ let mut total = Vec::new();
+ for _ in 0..20 {
+ let n = stream.read(&mut buf).await.unwrap();
+ if n == 0 {
+ break;
+ }
+ total.extend_from_slice(&buf[..n]);
+ if total.len() >= 21 {
+ break;
+ }
+ }
+ total
+ });
+
+ let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+ .await
+ .expect("timeout")
+ .unwrap();
+ let s = String::from_utf8_lossy(&received);
+ assert!(s.contains("chunk-1"), "got: {s:?}");
+ assert!(s.contains("chunk-2"), "got: {s:?}");
+ assert!(s.contains("chunk-3"), "got: {s:?}");
+ }
+
+ #[tokio::test]
+ async fn replay_evicts_by_bytes_cap() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("flow.sock");
+ // chunks=100 (no limita), bytes=20: deberíamos retener sólo los
+ // últimos chunks cuyos bytes sumen ≤ 20.
+ let ch = FlowChannel::with_replay_caps(path.clone(), ReplayCaps::new(100, 20)).unwrap();
+ ch.send(b"AAAAAAAA".to_vec()); // 8 bytes
+ ch.send(b"BBBBBBBB".to_vec()); // 8 → total 16
+ ch.send(b"CCCCCCCC".to_vec()); // 8 → total 24 > 20, evict A → 16
+ ch.send(b"DDDDDDDD".to_vec()); // 8 → total 24 > 20, evict B → 16
+
+ let path_clone = path.clone();
+ let task = tokio::spawn(async move {
+ let mut stream = UnixStream::connect(&path_clone).await.unwrap();
+ let mut buf = vec![0u8; 64];
+ let mut total = Vec::new();
+ for _ in 0..20 {
+ let n = stream.read(&mut buf).await.unwrap();
+ if n == 0 {
+ break;
+ }
+ total.extend_from_slice(&buf[..n]);
+ if total.len() >= 16 {
+ break;
+ }
+ }
+ total
+ });
+ let got = tokio::time::timeout(std::time::Duration::from_secs(2), task)
+ .await
+ .expect("timeout")
+ .unwrap();
+ let s = String::from_utf8_lossy(&got);
+ // Sólo C y D (los más viejos A y B fueron evicted).
+ assert!(!s.contains("AAAA"), "should have evicted A: {s:?}");
+ assert!(!s.contains("BBBB"), "should have evicted B: {s:?}");
+ assert!(s.contains("CCCC"), "should keep C: {s:?}");
+ assert!(s.contains("DDDD"), "should keep D: {s:?}");
+ }
+
+ #[tokio::test]
+ async fn drop_removes_socket() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("flow.sock");
+ {
+ let _ch = FlowChannel::new(path.clone()).unwrap();
+ assert!(path.exists());
+ }
+ // Después del drop, el socket file no debe quedar.
+ // Damos un pelín de tiempo al runtime para que el drop corra
+ // mientras estamos en task.
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ assert!(!path.exists());
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/lib.rs b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs
new file mode 100644
index 0000000..a97d7ba
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs
@@ -0,0 +1,261 @@
+//! `shuma-core` — runtime in-memory de Workspaces y comandos.
+//!
+//! Mantiene un estado tokio-friendly (Mutex sobre HashMap) con:
+//! - Workspaces vivos (id → state).
+//! - PIDs de comandos lanzados, indexados por workspace.
+//! - Reaping cooperativo: `reap_dead()` cosecha hijos terminados.
+
+// `pipeline` necesita `unsafe` puntual para `libc::close` y construir
+// `OwnedFd` desde fds que armamos con `pipe2(2)`. El resto del crate
+// permanece safe — el cargo lint `unsafe_code` queda permitido sólo en
+// el módulo concreto.
+#![deny(unsafe_op_in_unsafe_fn)]
+
+pub mod flow_channel;
+pub mod logbuf;
+pub mod persist;
+pub mod pipeline;
+pub mod stats;
+
+use card_core::{Card, Payload, Supervision};
+use arje_incarnate::{Incarnator, IncarnatorConfig};
+use nix::sys::signal::{kill, Signal};
+use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
+use nix::unistd::Pid;
+use shuma_card::{CommandRef, PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Instant;
+use thiserror::Error;
+use tokio::sync::Mutex;
+use tracing::{info, warn};
+use ulid::Ulid;
+
+#[derive(Debug, Error)]
+pub enum CoreError {
+ #[error("workspace {0} not found")]
+ WorkspaceNotFound(WorkspaceId),
+ #[error("compile: {0}")]
+ Compile(#[from] shuma_card::CompileError),
+ #[error("incarnate: {0}")]
+ Incarnate(#[from] arje_incarnate::IncarnateError),
+}
+
+#[derive(Debug)]
+pub struct WorkspaceState {
+ pub id: WorkspaceId,
+ pub spec: WorkspaceSpec,
+ pub root_card: Card,
+ pub commands: HashMap<Ulid, CommandState>,
+ pub started: Instant,
+ /// Última muestra de `(wall_instant, cpu_usec)` usada para calcular
+ /// `cpu_percent` en la próxima medición. None hasta el primer measure.
+ pub last_cpu_sample: Option<(Instant, u64)>,
+ /// Ring buffer de samples recientes para sparklines. Se popula cada
+ /// vez que `workspace_stats` se llama (típicamente desde el shell).
+ /// Cap 64 samples = ~2 minutos a 2s/sample.
+ pub stats_history: std::collections::VecDeque<stats::WorkspaceStats>,
+}
+
+const STATS_HISTORY_CAP: usize = 64;
+
+#[derive(Debug, Clone)]
+pub struct CommandState {
+ pub id: Ulid,
+ pub label: String,
+ pub pid: Pid,
+ pub alive: bool,
+ pub exit_status: Option<i32>,
+ /// Ring buffer del stdout. `None` para comandos sin captura.
+ pub stdout: Option<logbuf::LogBuf>,
+ /// Ring buffer del stderr. Separado de `stdout` para que el CLI
+ /// pueda filtrarlos. `None` para comandos sin captura.
+ pub stderr: Option<logbuf::LogBuf>,
+ /// Si el comando fue lanzado como parte de un Pipeline, su ULID.
+ pub pipeline_id: Option<Ulid>,
+}
+
+/// Stream a leer en `get_command_logs`. `Both` concatena stderr-después-stdout
+/// para una vista combinada (orden temporal aproximado).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LogStream {
+ Stdout,
+ Stderr,
+ Both,
+}
+
+pub struct WorkspaceManager {
+ inner: Arc<Mutex<Inner>>,
+ incarnator: Arc<Incarnator>,
+ /// True si hubo alguna mutación desde el último `save_snapshot`.
+ /// `save_snapshot` skip si false (snapshot incremental — evita
+ /// re-serialize cuando nada cambió, ej. SIGTERM tras un período idle).
+ dirty: std::sync::atomic::AtomicBool,
+}
+
+struct Inner {
+ workspaces: HashMap<WorkspaceId, WorkspaceState>,
+ /// Definiciones nombradas de pipelines persistidas. NO es lo mismo
+ /// que "pipelines vivos" — son specs guardados para reusar con
+ /// `run-saved`. Sobreviven restart vía snapshot.
+ saved_pipelines: HashMap<String, PipelineSpec>,
+ /// Flow channels vivos por pipeline. Se retienen hasta que el
+ /// pipeline termine — cuando todos los hijos del pipeline murieron,
+ /// el reaper los borra (futuro). v1: viven hasta `stop_pipeline_flows`
+ /// explícito o hasta shutdown.
+ pipeline_flows: HashMap<Ulid, Vec<crate::flow_channel::FlowChannel>>,
+ /// Specs de comandos `run()` con `restart_on_failure=true`. Indexed
+ /// por command_id. Cuando `reap_dead` detecta exit!=0, se relauncha
+ /// con la misma spec (nuevo pid y nuevo command_id se asigna por
+ /// el nuevo state pero el restart_spec sigue ligado al original).
+ restart_specs: HashMap<Ulid, RestartSpec>,
+ /// Supervisores de pipelines con `restart_on_failure`. Indexed por
+ /// pipeline_id. Cuando `reap_dead` detecta que el pipeline tuvo
+ /// algún command failure, agrega un entry a `pending_pipeline_restarts`.
+ pipeline_supervisors: HashMap<Ulid, PipelineSupervisor>,
+ /// Cola de pipelines pendientes de restart. El daemon la drena en
+ /// cada loop del reaper, hace stop + run_pipeline.
+ pending_pipeline_restarts: Vec<Ulid>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PipelineSupervisor {
+ pub workspace: WorkspaceId,
+ pub spec: PipelineSpec,
+ pub tap: bool,
+ pub restart_count: u32,
+ /// Backoff actual (ms) — escala exponencialmente con cada restart.
+ pub current_backoff_ms: u64,
+}
+
+#[derive(Debug, Clone)]
+struct RestartSpec {
+ workspace: WorkspaceId,
+ exec: String,
+ argv: Vec<String>,
+ envp: Vec<(String, String)>,
+ /// Backoff inicial (ms). Crece exponencialmente hasta max_backoff_ms.
+ backoff_ms: u64,
+ max_backoff_ms: u64,
+ /// Cantidad de restarts ya ejecutados (para tracking).
+ restart_count: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandSummary {
+ pub id: Ulid,
+ pub label: String,
+ pub pid: i32,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct HealthCounts {
+ pub alive_workspaces: u32,
+ pub alive_commands: u32,
+ pub alive_pipelines: u32,
+ pub active_flows: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct CommandInfo {
+ pub id: Ulid,
+ pub label: String,
+ pub pid: i32,
+ pub alive: bool,
+ pub exit_status: Option<i32>,
+ pub log_bytes: u64,
+}
+
+/// Lee VmRSS (bytes) de `/proc/<pid>/status`. Helper local para
+/// reap_dead que no necesita el full stats. Devuelve 0 si el proc no
+/// existe o el campo no aparece.
+fn read_proc_rss(pid: i32) -> Option<u64> {
+ let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
+ status
+ .lines()
+ .find_map(|l| l.strip_prefix("VmRSS:").map(str::trim))
+ .and_then(|s| s.split_whitespace().next())
+ .and_then(|s| s.parse::<u64>().ok())
+ .map(|kb| kb * 1024)
+}
+
+fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
+ // Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
+ // SAFETY: F_SETFL sobre fd válido.
+ unsafe {
+ let flags = libc::fcntl(read_fd, libc::F_GETFL, 0);
+ if flags >= 0 {
+ libc::fcntl(read_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+ }
+ }
+ tokio::spawn(async move {
+ // SAFETY: ownership del fd transferido al drainer task.
+ let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(read_fd) };
+ let afd = match tokio::io::unix::AsyncFd::with_interest(owned, tokio::io::Interest::READABLE) {
+ Ok(a) => a,
+ Err(e) => {
+ tracing::warn!(?e, "log drainer AsyncFd failed");
+ return;
+ }
+ };
+ let mut buf = [0u8; 4096];
+ loop {
+ let mut guard = match afd.readable().await {
+ Ok(g) => g,
+ Err(_) => break,
+ };
+ use std::os::fd::AsRawFd;
+ let fd = afd.as_raw_fd();
+ // SAFETY: read sobre fd válido.
+ let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+ if r > 0 {
+ logs.append(&buf[..r as usize]);
+ continue;
+ }
+ if r == 0 {
+ break; // EOF
+ }
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::WouldBlock {
+ guard.clear_ready();
+ continue;
+ }
+ tracing::warn!(?err, "log drainer read err");
+ break;
+ }
+ });
+}
+
+trait OwnedFdFromRawCompat: Sized {
+ unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+ unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self {
+ use std::os::fd::FromRawFd;
+ // SAFETY: el caller transfiere ownership de fd a OwnedFd.
+ unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+ }
+}
+
+
+#[derive(Debug, Clone)]
+pub struct WorkspaceSnapshot {
+ pub id: WorkspaceId,
+ pub label: String,
+ pub commands: u32,
+ pub uptime_ms: u64,
+}
+
+fn short_ulid(u: &Ulid) -> String {
+ let s = u.to_string();
+ s[s.len() - 6..].to_string()
+}
+
+// `impl WorkspaceManager` partido por dominio (regla dura #1, 1517 LOC):
+mod pipelines;
+mod runtime;
+mod workspaces;
+
+#[cfg(test)]
+mod tests;
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs
new file mode 100644
index 0000000..a5452c0
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs
@@ -0,0 +1,122 @@
+//! Ring buffer en memoria para capturar stdout/stderr de comandos.
+//!
+//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena,
+//! descarta los bytes más viejos. Pensado para diagnostico rápido, no
+//! para retención histórica — eso es trabajo de un journald-like aparte.
+
+use std::sync::{Arc, Mutex};
+
+/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin
+/// abusar de memoria si el daemon tiene cientos de comandos vivos.
+pub const MAX_LOG_BYTES: usize = 64 * 1024;
+
+#[derive(Debug, Clone)]
+pub struct LogBuf {
+ inner: Arc<Mutex<Inner>>,
+}
+
+#[derive(Debug)]
+struct Inner {
+ /// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
+ /// el tail.
+ buf: Vec<u8>,
+ cap: usize,
+ /// Total escrito alguna vez (no decrementado al recortar).
+ written_total: u64,
+}
+
+impl LogBuf {
+ pub fn new() -> Self {
+ Self::with_cap(MAX_LOG_BYTES)
+ }
+
+ pub fn with_cap(cap: usize) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(Inner {
+ buf: Vec::with_capacity(cap.min(4096)),
+ cap,
+ written_total: 0,
+ })),
+ }
+ }
+
+ pub fn append(&self, data: &[u8]) {
+ let Ok(mut g) = self.inner.lock() else { return };
+ g.written_total += data.len() as u64;
+ g.buf.extend_from_slice(data);
+ // Recorte cuando excede cap (con un pequeño slack para evitar
+ // shift en cada append). El usuario ve sólo el tail.
+ if g.buf.len() > g.cap + 1024 {
+ let drop = g.buf.len() - g.cap;
+ g.buf.drain(..drop);
+ }
+ }
+
+ /// Devuelve el tail de hasta `n` bytes (o todo si `n=0`).
+ pub fn tail(&self, n: usize) -> Vec<u8> {
+ let g = match self.inner.lock() {
+ Ok(g) => g,
+ Err(_) => return Vec::new(),
+ };
+ if n == 0 || n >= g.buf.len() {
+ return g.buf.clone();
+ }
+ g.buf[g.buf.len() - n..].to_vec()
+ }
+
+ /// Cuántos bytes hay actualmente en el buffer.
+ pub fn len(&self) -> usize {
+ self.inner.lock().map(|g| g.buf.len()).unwrap_or(0)
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ pub fn written_total(&self) -> u64 {
+ self.inner.lock().map(|g| g.written_total).unwrap_or(0)
+ }
+}
+
+impl Default for LogBuf {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn append_and_tail_basic() {
+ let lb = LogBuf::with_cap(100);
+ lb.append(b"hello ");
+ lb.append(b"world\n");
+ let t = lb.tail(0);
+ assert_eq!(t, b"hello world\n");
+ }
+
+ #[test]
+ fn cap_drops_oldest() {
+ let lb = LogBuf::with_cap(10);
+ lb.append(&[b'a'; 8]);
+ lb.append(&[b'b'; 8]);
+ // Después del recorte, debe quedar ~10 bytes pero el slack
+ // permite hasta 10+1024. Como pasamos slack, no se recorta aún
+ // en este caso (16 bytes < 10+1024). Forzamos un append grande.
+ lb.append(&[b'c'; 2048]);
+ assert!(lb.len() <= 10 + 1024);
+ let t = lb.tail(0);
+ // El tail debe contener 'c's (los más recientes).
+ assert!(t.iter().filter(|&&b| b == b'c').count() > 0);
+ }
+
+ #[test]
+ fn written_total_tracks_all() {
+ let lb = LogBuf::with_cap(10);
+ lb.append(b"abcdef");
+ lb.append(b"ghijkl");
+ assert_eq!(lb.written_total(), 12);
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/persist.rs b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs
new file mode 100644
index 0000000..2d34109
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs
@@ -0,0 +1,383 @@
+//! Persistencia del estado del WorkspaceManager.
+//!
+//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten —
+//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada*
+//! (Workspaces creados con su spec) sobrevive a un reboot del daemon.
+
+use crate::WorkspaceManager;
+use serde::{Deserialize, Serialize};
+use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
+use std::path::{Path, PathBuf};
+use tracing::{info, warn};
+
+/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines`. v4 agrega
+/// `stats_history` por workspace (sparkline survives daemon restart).
+/// Versiones inferiores leen campos ausentes como vacío.
+pub const SNAPSHOT_VERSION: u16 = 4;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ShipoteSnapshot {
+ pub version: u16,
+ pub timestamp_ms: u64,
+ pub workspaces: Vec<WorkspaceEntry>,
+ #[serde(default)]
+ pub saved_pipelines: Vec<PipelineEntry>,
+ /// Pipelines vivos con supervisor (`restart_on_failure=true`) al
+ /// momento del snapshot. El daemon los relanza al restore.
+ #[serde(default)]
+ pub live_pipelines: Vec<LivePipelineEntry>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WorkspaceEntry {
+ pub id: WorkspaceId,
+ pub spec: WorkspaceSpec,
+ /// Stats history persistida — cap reasonable para no inflar el JSON.
+ /// Sólo se guardan campos serializables (no Instant).
+ #[serde(default)]
+ pub stats_history: Vec<PersistedStats>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PersistedStats {
+ pub commands_alive: u32,
+ pub commands_total: u32,
+ pub rss_bytes: Option<u64>,
+ pub rss_peak_bytes: Option<u64>,
+ pub cpu_usec: Option<u64>,
+ pub cpu_percent: Option<f32>,
+ pub cpu_cores: u32,
+ pub uptime_ms: u64,
+}
+
+impl From<&crate::stats::WorkspaceStats> for PersistedStats {
+ fn from(s: &crate::stats::WorkspaceStats) -> Self {
+ Self {
+ commands_alive: s.commands_alive,
+ commands_total: s.commands_total,
+ rss_bytes: s.rss_bytes,
+ rss_peak_bytes: s.rss_peak_bytes,
+ cpu_usec: s.cpu_usec,
+ cpu_percent: s.cpu_percent,
+ cpu_cores: s.cpu_cores,
+ uptime_ms: s.uptime_ms,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PipelineEntry {
+ pub name: String,
+ pub spec: PipelineSpec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LivePipelineEntry {
+ pub workspace: WorkspaceId,
+ pub spec: PipelineSpec,
+ pub tap: bool,
+}
+
+impl ShipoteSnapshot {
+ pub fn write(&self, path: &Path) -> anyhow::Result<()> {
+ let bytes = serde_json::to_vec_pretty(self)?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).ok();
+ }
+ let tmp = path.with_extension("tmp");
+ std::fs::write(&tmp, &bytes)?;
+ std::fs::rename(&tmp, path)?;
+ Ok(())
+ }
+
+ pub fn read(path: &Path) -> anyhow::Result<Self> {
+ let bytes = std::fs::read(path)?;
+ let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?;
+ // v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío).
+ if snap.version > SNAPSHOT_VERSION {
+ anyhow::bail!(
+ "snapshot version {} no soportada (esperada ≤ {})",
+ snap.version,
+ SNAPSHOT_VERSION
+ );
+ }
+ Ok(snap)
+ }
+}
+
+/// Path canónico del snapshot: `$XDG_STATE_HOME/shuma/state.json`,
+/// fallback `$HOME/.local/state/shuma/state.json`,
+/// fallback `/tmp/shuma-state-$UID.json`.
+pub fn default_snapshot_path() -> PathBuf {
+ if let Ok(state) = std::env::var("XDG_STATE_HOME") {
+ return PathBuf::from(state).join("shuma/state.json");
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(".local/state/shuma/state.json");
+ }
+ let uid = nix::unistd::getuid().as_raw();
+ PathBuf::from(format!("/tmp/shuma-state-{uid}.json"))
+}
+
+fn now_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0)
+}
+
+impl WorkspaceManager {
+ /// Toma snapshot del estado actual.
+ pub async fn snapshot(&self) -> ShipoteSnapshot {
+ const PERSIST_STATS_CAP: usize = 16;
+ let g = self.inner.lock().await;
+ let workspaces = g
+ .workspaces
+ .iter()
+ .map(|(id, ws)| {
+ // Persist sólo los últimos N samples — el resto crece
+ // y el JSON se infla.
+ let take = ws.stats_history.len().min(PERSIST_STATS_CAP);
+ let skip = ws.stats_history.len() - take;
+ let stats_history: Vec<PersistedStats> = ws
+ .stats_history
+ .iter()
+ .skip(skip)
+ .map(PersistedStats::from)
+ .collect();
+ WorkspaceEntry {
+ id: *id,
+ spec: ws.spec.clone(),
+ stats_history,
+ }
+ })
+ .collect();
+ let saved_pipelines = g
+ .saved_pipelines
+ .iter()
+ .map(|(name, spec)| PipelineEntry {
+ name: name.clone(),
+ spec: spec.clone(),
+ })
+ .collect();
+ // Pipelines vivos con supervisor — preserva la intención. Los
+ // pids/sockets/discernments son ephemeral y se regeneran al
+ // restore (relaunch desde cero).
+ let live_pipelines = g
+ .pipeline_supervisors
+ .values()
+ .map(|sup| LivePipelineEntry {
+ workspace: sup.workspace,
+ spec: sup.spec.clone(),
+ tap: sup.tap,
+ })
+ .collect();
+ ShipoteSnapshot {
+ version: SNAPSHOT_VERSION,
+ timestamp_ms: now_ms(),
+ workspaces,
+ saved_pipelines,
+ live_pipelines,
+ }
+ }
+
+ /// Escribe snapshot a disco. Si `is_dirty()` es false **y** el path
+ /// existe (snapshot previo válido), skip la escritura.
+ pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
+ if !self.is_dirty() && path.exists() {
+ info!(path = %path.display(), "snapshot SKIPPED (clean)");
+ return Ok(());
+ }
+ let snap = self.snapshot().await;
+ snap.write(path)?;
+ // Clear dirty: lo que está en disco es el current state.
+ self.dirty
+ .store(false, std::sync::atomic::Ordering::Relaxed);
+ info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
+ Ok(())
+ }
+
+ /// Carga snapshot desde disco y restaura los Workspaces + saved
+ /// pipelines. Devuelve los `live_pipelines` para que el caller
+ /// (daemon) los relance — no podemos relanzarlos desde acá porque
+ /// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`.
+ /// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
+ pub async fn restore_snapshot(
+ self: &std::sync::Arc<Self>,
+ path: &Path,
+ ) -> anyhow::Result<RestoreOutcome> {
+ let snap = match ShipoteSnapshot::read(path) {
+ Ok(s) => s,
+ Err(e) => {
+ warn!(?e, path = %path.display(), "no snapshot — start fresh");
+ return Ok(RestoreOutcome::default());
+ }
+ };
+ let mut out = RestoreOutcome::default();
+ for entry in snap.workspaces {
+ // v2+: reusamos el id original así clients que tracking
+ // workspace_id no se rompen al restart.
+ let label = entry.spec.label.clone();
+ let id = entry.id;
+ let history = entry.stats_history;
+ match self.create_with_id(id, entry.spec).await {
+ Ok(_) => {
+ out.workspaces_restored += 1;
+ // Hidratar history persistida. Convertimos
+ // PersistedStats → WorkspaceStats (perdemos
+ // los campos no serializables como `source`).
+ if !history.is_empty() {
+ let mut g = self.inner.lock().await;
+ if let Some(ws) = g.workspaces.get_mut(&id) {
+ for ps in history {
+ ws.stats_history.push_back(crate::stats::WorkspaceStats {
+ commands_alive: ps.commands_alive,
+ commands_total: ps.commands_total,
+ rss_bytes: ps.rss_bytes,
+ rss_peak_bytes: ps.rss_peak_bytes,
+ cpu_usec: ps.cpu_usec,
+ cpu_percent: ps.cpu_percent,
+ cpu_cores: ps.cpu_cores,
+ source: "persisted".into(),
+ uptime_ms: ps.uptime_ms,
+ });
+ }
+ }
+ }
+ }
+ Err(e) => warn!(?e, %label, "skipped workspace en restore"),
+ }
+ }
+ for entry in snap.saved_pipelines {
+ self.save_pipeline(entry.name, entry.spec).await;
+ out.saved_pipelines_restored += 1;
+ }
+ out.live_pipelines = snap.live_pipelines;
+ // Restore no cuenta como mutación — lo que está en disco es lo
+ // que acabamos de cargar. Sin esto, el próximo SIGTERM siempre
+ // re-escribiría aunque no hubiese cambios reales.
+ self.dirty
+ .store(false, std::sync::atomic::Ordering::Relaxed);
+ info!(
+ workspaces = out.workspaces_restored,
+ saved_pipelines = out.saved_pipelines_restored,
+ live_pipelines = out.live_pipelines.len(),
+ "snapshot restored"
+ );
+ Ok(out)
+ }
+}
+
+/// Lo que el caller del restore obtiene. Las `live_pipelines` requieren
+/// `Incarnator + DiscernPipeline` para relanzarlas → el caller las
+/// procesa (típicamente el daemon).
+#[derive(Debug, Default)]
+pub struct RestoreOutcome {
+ pub workspaces_restored: usize,
+ pub saved_pipelines_restored: usize,
+ pub live_pipelines: Vec<LivePipelineEntry>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::WorkspaceManager;
+ use arje_incarnate::IncarnatorConfig;
+ use shuma_card::{ExitPolicy, WorkspaceSpec};
+ use std::sync::Arc;
+
+ fn sample_ws(label: &str) -> WorkspaceSpec {
+ WorkspaceSpec {
+ label: label.into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ }
+ }
+
+ #[tokio::test]
+ async fn roundtrip_snapshot_preserves_ulids() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("state.json");
+
+ let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap();
+ let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap();
+ mgr1.save_snapshot(&path).await.unwrap();
+
+ let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let out = mgr2.restore_snapshot(&path).await.unwrap();
+ assert_eq!(out.workspaces_restored, 2);
+ let listed = mgr2.list().await;
+ let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect();
+ assert!(restored_ids.contains(&id1));
+ assert!(restored_ids.contains(&id2));
+ }
+
+ #[tokio::test]
+ async fn save_snapshot_skips_when_clean() {
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("state.json");
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let _ = mgr.create(sample_ws("dirty-test")).await.unwrap();
+ assert!(mgr.is_dirty(), "create debería marcar dirty");
+ mgr.save_snapshot(&path).await.unwrap();
+ assert!(!mgr.is_dirty(), "save_snapshot debería limpiar dirty");
+ let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
+ // Esperamos un pelín para que mtime cambie si fuera re-escrito.
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ // Segundo save sin mutación → skip.
+ mgr.save_snapshot(&path).await.unwrap();
+ let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
+ assert_eq!(mtime1, mtime2, "skip cuando clean — mtime no cambia");
+ }
+
+ #[tokio::test]
+ async fn snapshot_includes_saved_pipelines() {
+ use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
+ let tmp = tempfile::tempdir().unwrap();
+ let path = tmp.path().join("state.json");
+
+ let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap();
+ let spec = PipelineSpec {
+ label: "echo-cat".into(),
+ workspace: ws_id,
+ nodes: vec![CommandRef {
+ label: "n1".into(),
+ payload: card_core::Payload::Native {
+ exec: "/bin/echo".into(),
+ argv: vec!["hi".into()],
+ envp: vec![],
+ },
+ soma: Default::default(),
+ flows: Default::default(),
+ supervision: card_core::Supervision::OneShot,
+ }],
+ edges: vec![],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ mgr1.save_pipeline("daily".into(), spec).await;
+ mgr1.save_snapshot(&path).await.unwrap();
+
+ let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ mgr2.restore_snapshot(&path).await.unwrap();
+ let saved = mgr2.list_saved_pipelines().await;
+ assert_eq!(saved, vec!["daily".to_string()]);
+ let got = mgr2.get_saved_pipeline("daily").await.expect("saved");
+ assert_eq!(got.label, "echo-cat");
+ }
+
+ #[test]
+ fn default_path_ends_with_state_json() {
+ let p = default_snapshot_path();
+ assert!(p.to_string_lossy().ends_with("state.json"));
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs
new file mode 100644
index 0000000..6f016f6
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs
@@ -0,0 +1,808 @@
+//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta
+//! cada flow para discernir su contenido.
+//!
+//! Cada nodo se encarna via [`arje_incarnate::Incarnator`] — eso significa
+//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup,
+//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con
+//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los
+//! `dup2` pre-execve sin romper la regla async-signal-safe.
+
+use crate::CoreError;
+use card_core::Payload;
+use arje_incarnate::{ChildStdio, Incarnator};
+use nix::fcntl::OFlag;
+use nix::unistd::pipe2;
+use shuma_card::PipelineSpec;
+use shuma_discern::{DiscernPipeline, Discernment, Hint};
+use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
+use std::sync::Arc;
+use tokio::io::unix::AsyncFd;
+use tokio::io::Interest;
+use tracing::{debug, info, warn};
+use ulid::Ulid;
+
+/// Resultado de lanzar un pipeline.
+#[derive(Debug)]
+pub struct PipelineLaunch {
+ pub pipeline: Ulid,
+ pub command_pids: Vec<(String, i32)>,
+ /// Discernments por edge, en el mismo orden que `spec.edges`.
+ pub edge_discernments: Vec<EdgeDiscernment>,
+}
+
+#[derive(Debug, Clone)]
+pub struct EdgeDiscernment {
+ pub from_label: String,
+ pub from_output: String,
+ pub to_label: String,
+ pub to_input: String,
+ pub discernment: Option<Discernment>,
+ /// Path del Unix socket donde otros módulos pueden suscribirse al
+ /// stream replicado por este edge. `None` cuando tap=false (no hay
+ /// data plane porque no hay sampling).
+ pub flow_socket: Option<std::path::PathBuf>,
+}
+
+/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
+/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
+///
+/// Soporta:
+/// - Pipeline lineal (1 producer → 1 consumer).
+/// - **Fan-out** (1 producer → N consumers): shuma interpone un
+/// splitter que duplica bytes a cada destino. Cuando `tap=true`, el
+/// splitter además samplea para discernir.
+/// - Múltiples predecessors por nodo NO se soporta aún (fan-in): sólo se
+/// honra el primer edge entrante.
+pub async fn run_pipeline(
+ spec: &PipelineSpec,
+ workspace_label: &str,
+ tap: bool,
+ discerner: Arc<DiscernPipeline>,
+ incarnator: Arc<Incarnator>,
+ manager: Option<Arc<crate::WorkspaceManager>>,
+) -> Result<PipelineLaunch, CoreError> {
+ spec.validate()?;
+ let n = spec.nodes.len();
+ info!(
+ nodes = n,
+ edges = spec.edges.len(),
+ tap,
+ "launching pipeline (incarnated)"
+ );
+
+ // Pre-compute grafo:
+ // - `consumers[i]` = índices de edges salientes de `i`.
+ // - `predecessors[j]` = índices de edges entrantes a `j`.
+ let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
+ let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); n];
+ for (idx, e) in spec.edges.iter().enumerate() {
+ consumers[e.from].push(idx);
+ predecessors[e.to].push(idx);
+ }
+
+ // Por cada edge: par (r_to_consumer, w_from_producer_side).
+ // El consumer recibe r_to_consumer; el producer escribe a w_from_producer_side
+ // (directa o vía splitter).
+ let mut edge_r: Vec<RawFd> = vec![-1; spec.edges.len()];
+ let mut edge_w: Vec<RawFd> = vec![-1; spec.edges.len()];
+ for i in 0..spec.edges.len() {
+ let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+ CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+ })?;
+ edge_r[i] = r.into_raw_fd();
+ edge_w[i] = w.into_raw_fd();
+ }
+
+ let mut consumer_stdin_fd: Vec<Option<RawFd>> = vec![None; n];
+ let mut producer_stdout_fd: Vec<Option<RawFd>> = vec![None; n];
+ let mut splitter_specs: Vec<SplitterSpec> = Vec::new();
+ let mut merger_specs: Vec<MergerSpec> = Vec::new();
+
+ // Stdout del producer: directo a edge_w[único] si tiene 1 consumer y NO tap;
+ // sino, pipe propio que va al splitter task.
+ for i in 0..n {
+ if consumers[i].is_empty() {
+ continue;
+ }
+ if consumers[i].len() == 1 && !tap {
+ producer_stdout_fd[i] = Some(edge_w[consumers[i][0]]);
+ continue;
+ }
+ // Splitter: pipe propio para el productor → splitter lee y replica a edge_w[*].
+ let (prod_r, prod_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+ CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+ })?;
+ producer_stdout_fd[i] = Some(prod_w.into_raw_fd());
+ let prod_r_fd = prod_r.into_raw_fd();
+ let mut consumer_writes: Vec<RawFd> = Vec::with_capacity(consumers[i].len());
+ let mut edge_meta: Vec<EdgeMeta> = Vec::with_capacity(consumers[i].len());
+ for edge_idx in &consumers[i] {
+ let edge = &spec.edges[*edge_idx];
+ consumer_writes.push(edge_w[*edge_idx]);
+ edge_meta.push(EdgeMeta {
+ from_label: spec.nodes[edge.from].label.clone(),
+ from_output: edge.from_output.clone(),
+ to_label: spec.nodes[edge.to].label.clone(),
+ to_input: edge.to_input.clone(),
+ });
+ }
+ splitter_specs.push(SplitterSpec {
+ producer_r_fd: prod_r_fd,
+ consumer_w_fds: consumer_writes,
+ edges: edge_meta,
+ tap,
+ sample_bytes: spec.discern.sample_bytes,
+ max_bytes_per_sec: spec.discern.max_bytes_per_sec,
+ });
+ }
+
+ // Stdin del consumer: edge_r[único] si tiene 1 predecessor; sino, merger.
+ for j in 0..n {
+ match predecessors[j].len() {
+ 0 => {}
+ 1 => {
+ consumer_stdin_fd[j] = Some(edge_r[predecessors[j][0]]);
+ }
+ _ => {
+ // Merger: lee de N edge_r y escribe a un nuevo pipe cuyo
+ // read end es el stdin del consumer.
+ let (cons_r, cons_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
+ CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+ })?;
+ consumer_stdin_fd[j] = Some(cons_r.into_raw_fd());
+ let inputs: Vec<RawFd> = predecessors[j]
+ .iter()
+ .map(|eidx| edge_r[*eidx])
+ .collect();
+ merger_specs.push(MergerSpec {
+ producer_r_fds: inputs,
+ consumer_w_fd: cons_w.into_raw_fd(),
+ });
+ }
+ }
+ }
+
+ // Encarnamos cada nodo con su stdin/stdout fd asignado.
+ let mut pids = Vec::with_capacity(n);
+ for (i, node) in spec.nodes.iter().enumerate() {
+ match &node.payload {
+ Payload::Native { .. } | Payload::Legacy { .. } => {}
+ _ => {
+ return Err(CoreError::Incarnate(
+ arje_incarnate::IncarnateError::NonExecutablePayload,
+ ))
+ }
+ }
+ let card = node.to_card(i, workspace_label)?;
+ let stdio = ChildStdio {
+ stdin_fd: consumer_stdin_fd[i],
+ stdout_fd: producer_stdout_fd[i],
+ stderr_fd: None,
+ };
+ let outcome = incarnator
+ .incarnate_with(&card, stdio)
+ .map_err(CoreError::Incarnate)?;
+ let pid = outcome.pid;
+ pids.push((node.label.clone(), pid.as_raw()));
+ debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
+ }
+
+ let pipeline_id_for_flows = Ulid::new();
+ // Si tap=true, creamos un FlowChannel por edge para el data plane.
+ // Cada splitter pushea al sender del channel correspondiente.
+ let pipeline_id = pipeline_id_for_flows;
+ let mut flow_channels: Vec<crate::flow_channel::FlowChannel> = Vec::new();
+ let mut splitter_channels: Vec<Vec<Option<crate::flow_channel::FlowSender>>> =
+ Vec::with_capacity(splitter_specs.len());
+ let mut edge_socket_for_splitter: Vec<Vec<Option<std::path::PathBuf>>> = Vec::new();
+ for s in &splitter_specs {
+ let mut senders_per_edge = Vec::with_capacity(s.edges.len());
+ let mut paths_per_edge = Vec::with_capacity(s.edges.len());
+ for (i, _em) in s.edges.iter().enumerate() {
+ if !s.tap {
+ senders_per_edge.push(None);
+ paths_per_edge.push(None);
+ continue;
+ }
+ // Socket name = pipeline_id full (26 chars ULID) + edge_idx.
+ // ULID es único globalmente → cero colisiones entre runs.
+ // Edge_idx desambigua múltiples sockets del mismo pipeline.
+ // No incluimos from_label en el name (puede tener chars que
+ // no van en paths Unix — los hints van en `EdgeDiscernment`).
+ let id = format!("{}-{}", pipeline_id, i);
+ let mut socket = crate::flow_channel::default_flow_socket_path(&id);
+ // Fallback: si el path existe (raro — daemon crashed sin
+ // cleanup), agregar suffix numérico hasta encontrar libre.
+ let mut suffix = 1u32;
+ while socket.exists() {
+ let alt = format!("{id}-{suffix}");
+ socket = crate::flow_channel::default_flow_socket_path(&alt);
+ suffix += 1;
+ if suffix > 1000 {
+ warn!(orig = id, "flow socket collision: 1000 retries — using as-is");
+ break;
+ }
+ }
+ match crate::flow_channel::FlowChannel::with_replay_caps(
+ socket.clone(),
+ crate::flow_channel::ReplayCaps::new(spec.discern.replay_chunks, spec.discern.replay_bytes),
+ ) {
+ Ok(fc) => {
+ senders_per_edge.push(Some(fc.sender_handle()));
+ paths_per_edge.push(Some(socket));
+ flow_channels.push(fc);
+ }
+ Err(e) => {
+ warn!(?e, "flow channel new failed");
+ senders_per_edge.push(None);
+ paths_per_edge.push(None);
+ }
+ }
+ }
+ splitter_channels.push(senders_per_edge);
+ edge_socket_for_splitter.push(paths_per_edge);
+ }
+
+ // Registramos los flow_channels en el manager AHORA, antes de await
+ // las tasks. Esto permite que clientes externos hagan `flow list` y
+ // se suscriban mientras el pipeline aún produce data.
+ if let Some(mgr) = &manager {
+ if !flow_channels.is_empty() {
+ let drained: Vec<crate::flow_channel::FlowChannel> = flow_channels.drain(..).collect();
+ mgr.retain_pipeline_flows(pipeline_id, drained).await;
+ }
+ }
+
+ // Spawn mergers + splitters después del incarnate. Cada task posee
+ // sus fds y los cierra al terminar (via Drop de OwnedFd).
+ let mut merger_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
+ for m in merger_specs {
+ merger_handles.push(spawn_merger(m));
+ }
+ let mut tap_handles: Vec<SplitterHandle> = Vec::new();
+ for (s, senders) in splitter_specs.into_iter().zip(splitter_channels.into_iter()) {
+ tap_handles.push(spawn_splitter(s, discerner.clone(), senders));
+ }
+
+ let mut edge_discernments = Vec::new();
+ for (h, paths) in tap_handles.into_iter().zip(edge_socket_for_splitter.into_iter()) {
+ match h.handle.await {
+ Ok(eds) => {
+ for (mut ed, path) in eds.into_iter().zip(paths.into_iter()) {
+ ed.flow_socket = path;
+ edge_discernments.push(ed);
+ }
+ }
+ Err(e) => warn!(?e, "splitter handle joined with error"),
+ }
+ }
+ for h in merger_handles {
+ if let Err(e) = h.await {
+ warn!(?e, "merger handle joined with error");
+ }
+ }
+
+ Ok(PipelineLaunch {
+ pipeline: pipeline_id,
+ command_pids: pids,
+ edge_discernments,
+ })
+}
+
+#[allow(dead_code)]
+fn short_ulid(u: &Ulid) -> String {
+ let s = u.to_string();
+ s[s.len() - 6..].to_string()
+}
+
+#[derive(Debug, Clone)]
+struct EdgeMeta {
+ from_label: String,
+ from_output: String,
+ to_label: String,
+ to_input: String,
+}
+
+struct SplitterSpec {
+ producer_r_fd: RawFd,
+ consumer_w_fds: Vec<RawFd>,
+ edges: Vec<EdgeMeta>,
+ tap: bool,
+ sample_bytes: usize,
+ /// Rate-limit en bytes/s (0 = sin limit). Tras cada chunk de `n`
+ /// bytes, splitter sleeps `n / max_bytes_per_sec` segundos.
+ max_bytes_per_sec: u64,
+}
+
+struct SplitterHandle {
+ handle: tokio::task::JoinHandle<Vec<EdgeDiscernment>>,
+}
+
+struct MergerSpec {
+ producer_r_fds: Vec<RawFd>,
+ consumer_w_fd: RawFd,
+}
+
+fn spawn_merger(spec: MergerSpec) -> tokio::task::JoinHandle<()> {
+ for fd in &spec.producer_r_fds {
+ set_nonblocking(*fd);
+ }
+ set_nonblocking(spec.consumer_w_fd);
+ // Patrón: una task lectora por cada producer reenvía bytes a un mpsc.
+ // El merger principal consume del mpsc y escribe al consumer.
+ // Esto evita el "block en reader idle" del enfoque round-robin sobre
+ // AsyncFd::ready() (los readers idle nunca dejan turno).
+ tokio::spawn(async move {
+ let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
+ let nr = spec.producer_r_fds.len();
+ for fd in spec.producer_r_fds {
+ let tx = tx.clone();
+ tokio::spawn(async move {
+ // SAFETY: ownership transferida.
+ let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
+ let r = match AsyncFd::with_interest(owned, Interest::READABLE) {
+ Ok(a) => a,
+ Err(e) => {
+ warn!(?e, "merger reader AsyncFd");
+ return;
+ }
+ };
+ let mut buf = [0u8; 4096];
+ loop {
+ match async_read(&r, &mut buf).await {
+ Ok(0) => break,
+ Ok(n) => {
+ if tx.send(buf[..n].to_vec()).await.is_err() {
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ // Drop de tx → cuando todos los readers cerraron, el rx
+ // recibe None y el merger termina.
+ });
+ }
+ drop(tx); // sólo los reader tasks tienen sus clones ahora.
+
+ // SAFETY: ownership transferida al task.
+ let w_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.consumer_w_fd) };
+ let w = match AsyncFd::with_interest(w_owned, Interest::WRITABLE) {
+ Ok(a) => a,
+ Err(e) => {
+ warn!(?e, "merger AsyncFd w");
+ return;
+ }
+ };
+
+ let mut total: u64 = 0;
+ while let Some(chunk) = rx.recv().await {
+ if async_write_all(&w, &chunk).await.is_err() {
+ return;
+ }
+ total += chunk.len() as u64;
+ }
+ debug!(bytes = total, readers = nr, "merger finished");
+ })
+}
+
+fn spawn_splitter(
+ spec: SplitterSpec,
+ discerner: Arc<DiscernPipeline>,
+ edge_senders: Vec<Option<crate::flow_channel::FlowSender>>,
+) -> SplitterHandle {
+ set_nonblocking(spec.producer_r_fd);
+ for fd in &spec.consumer_w_fds {
+ set_nonblocking(*fd);
+ }
+
+ let handle = tokio::spawn(async move {
+ // SAFETY: ownership transferida al task.
+ let r_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.producer_r_fd) };
+ let r = match AsyncFd::with_interest(r_owned, Interest::READABLE) {
+ Ok(a) => a,
+ Err(e) => {
+ warn!(?e, "splitter AsyncFd r");
+ return Vec::new();
+ }
+ };
+ let mut writers: Vec<AsyncFd<std::os::fd::OwnedFd>> = Vec::with_capacity(spec.consumer_w_fds.len());
+ for fd in spec.consumer_w_fds {
+ let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
+ match AsyncFd::with_interest(owned, Interest::WRITABLE) {
+ Ok(a) => writers.push(a),
+ Err(e) => warn!(?e, "splitter AsyncFd w"),
+ }
+ }
+
+ let mut sample: Vec<u8> = Vec::with_capacity(spec.sample_bytes);
+ let mut buf = [0u8; 4096];
+ let mut total: u64 = 0;
+ let mut eof = false;
+ let mut bucket = if spec.max_bytes_per_sec > 0 {
+ Some(TokenBucket::new(spec.max_bytes_per_sec))
+ } else {
+ None
+ };
+
+ // Fase 1: sampling (sólo si tap=true) + replicación.
+ while !eof && (spec.tap && sample.len() < spec.sample_bytes) {
+ let n = match async_read(&r, &mut buf).await {
+ Ok(0) => { eof = true; 0 }
+ Ok(n) => n,
+ Err(e) => { warn!(?e, "splitter read"); break; }
+ };
+ if n == 0 { break; }
+ if spec.tap {
+ let take = n.min(spec.sample_bytes - sample.len());
+ sample.extend_from_slice(&buf[..take]);
+ }
+ // Token bucket: reserva ANTES de broadcast — si hay debt,
+ // sleep antes de mandar al subscriber.
+ if let Some(b) = bucket.as_mut() {
+ let wait = b.reserve(n as u64);
+ if !wait.is_zero() {
+ tokio::time::sleep(wait).await;
+ }
+ }
+ broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
+ total += n as u64;
+ }
+
+ let d = if spec.tap {
+ discerner.discern(&sample, &Hint { path: None, size_total: None })
+ } else {
+ None
+ };
+
+ // Fase 2: replicación pura.
+ while !eof {
+ let n = match async_read(&r, &mut buf).await {
+ Ok(0) => { eof = true; 0 }
+ Ok(n) => n,
+ Err(_) => break,
+ };
+ if n == 0 { break; }
+ if let Some(b) = bucket.as_mut() {
+ let wait = b.reserve(n as u64);
+ if !wait.is_zero() {
+ tokio::time::sleep(wait).await;
+ }
+ }
+ broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
+ total += n as u64;
+ }
+ debug!(bytes = total, consumers = writers.len(), "splitter finished");
+
+ // Mismo discernment para todos los edges del splitter (es el mismo
+ // stream replicado). Devolvemos N entries (una por edge) para que
+ // la UI/CLI los liste todos. flow_socket lo rellena el caller.
+ spec.edges
+ .into_iter()
+ .map(|em| EdgeDiscernment {
+ from_label: em.from_label,
+ from_output: em.from_output,
+ to_label: em.to_label,
+ to_input: em.to_input,
+ discernment: d.clone(),
+ flow_socket: None,
+ })
+ .collect()
+ });
+ SplitterHandle { handle }
+}
+
+/// Token-bucket real con capacidad de burst.
+/// - `rate_bps`: tokens (bytes) por segundo de refill.
+/// - `capacity`: máx tokens acumulables. Default = 1 segundo de rate.
+/// - `tokens`: tokens disponibles (puede negativos para "debt").
+/// - `last_refill`: para calcular cuántos refill desde la última call.
+struct TokenBucket {
+ rate_bps: u64,
+ capacity: u64,
+ tokens: f64,
+ last_refill: std::time::Instant,
+}
+
+impl TokenBucket {
+ fn new(rate_bps: u64) -> Self {
+ Self {
+ rate_bps,
+ capacity: rate_bps, // 1 second worth of burst.
+ tokens: rate_bps as f64,
+ last_refill: std::time::Instant::now(),
+ }
+ }
+
+ /// Refill desde la última call según wall time. Reserva `cost`
+ /// tokens; si no alcanza, retorna el sleep necesario.
+ fn reserve(&mut self, cost: u64) -> std::time::Duration {
+ let now = std::time::Instant::now();
+ let elapsed_secs = now.duration_since(self.last_refill).as_secs_f64();
+ self.tokens = (self.tokens + elapsed_secs * self.rate_bps as f64)
+ .min(self.capacity as f64);
+ self.last_refill = now;
+
+ self.tokens -= cost as f64;
+ if self.tokens >= 0.0 {
+ std::time::Duration::ZERO
+ } else {
+ // Debt: tiempo para recuperar a 0 tokens.
+ let secs_needed = -self.tokens / self.rate_bps as f64;
+ std::time::Duration::from_secs_f64(secs_needed)
+ }
+ }
+}
+
+async fn broadcast_chunk(
+ writers: &[AsyncFd<std::os::fd::OwnedFd>],
+ edge_senders: &[Option<crate::flow_channel::FlowSender>],
+ data: &[u8],
+) {
+ // Internal pipes a los consumers del pipeline.
+ for w in writers {
+ let _ = async_write_all(w, data).await;
+ }
+ // Externos: broadcast a subscribers vía FlowChannel.
+ // Cada edge tiene su propio sender (mismo data — el sample/discernment
+ // viaja por broadcast separados para que un subscriber por edge vea su
+ // stream específico).
+ if edge_senders.iter().any(|s| s.is_some()) {
+ let shared = std::sync::Arc::new(data.to_vec());
+ for s in edge_senders {
+ if let Some(s) = s {
+ let _ = s.send(shared.clone());
+ }
+ }
+ }
+}
+
+async fn async_read(
+ afd: &AsyncFd<std::os::fd::OwnedFd>,
+ buf: &mut [u8],
+) -> std::io::Result<usize> {
+ loop {
+ let mut guard = afd.readable().await?;
+ let fd = afd.as_raw_fd();
+ // SAFETY: lectura sobre fd válido propiedad del AsyncFd.
+ let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
+ if r >= 0 {
+ return Ok(r as usize);
+ }
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::WouldBlock {
+ guard.clear_ready();
+ continue;
+ }
+ return Err(err);
+ }
+}
+
+async fn async_write_all(
+ afd: &AsyncFd<std::os::fd::OwnedFd>,
+ mut buf: &[u8],
+) -> std::io::Result<()> {
+ while !buf.is_empty() {
+ let mut guard = afd.writable().await?;
+ let fd = afd.as_raw_fd();
+ // SAFETY: escritura sobre fd válido propiedad del AsyncFd.
+ let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
+ if r > 0 {
+ buf = &buf[r as usize..];
+ continue;
+ }
+ if r == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::WriteZero,
+ "write 0",
+ ));
+ }
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::WouldBlock {
+ guard.clear_ready();
+ continue;
+ }
+ return Err(err);
+ }
+ Ok(())
+}
+
+fn set_nonblocking(fd: RawFd) {
+ // SAFETY: fcntl con F_SETFL es seguro para fds válidos.
+ unsafe {
+ let flags = libc::fcntl(fd, libc::F_GETFL, 0);
+ if flags >= 0 {
+ libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
+ }
+ }
+}
+
+// Extension trait para abstraer la API de OwnedFd entre versiones (compat).
+trait OwnedFdFromRawCompat: Sized {
+ unsafe fn from_raw_fd_compat(fd: RawFd) -> Self;
+}
+
+impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
+ unsafe fn from_raw_fd_compat(fd: RawFd) -> Self {
+ use std::os::fd::FromRawFd;
+ // SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`.
+ unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
+ }
+}
+
+// Re-export para que el unused warning del AsRawFd se calle si no se usa.
+#[allow(dead_code)]
+fn _keep_raw(_: &dyn AsRawFd) {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use card_core::Payload;
+ use arje_incarnate::IncarnatorConfig;
+ use shuma_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId};
+
+ fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef {
+ CommandRef {
+ label: label.into(),
+ payload: Payload::Native {
+ exec: exec.into(),
+ argv: argv.iter().map(|s| s.to_string()).collect(),
+ envp: vec![],
+ },
+ soma: Default::default(),
+ flows: Default::default(),
+ supervision: card_core::Supervision::OneShot,
+ }
+ }
+
+ #[tokio::test]
+ async fn pipeline_isolated_echo_to_cat_runs() {
+ let spec = PipelineSpec {
+ label: "echo-cat".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![
+ cmd("p1", "/bin/echo", &["hola pipeline aislado"]),
+ cmd("p2", "/bin/cat", &[]),
+ ],
+ edges: vec![FlowEdge {
+ from: 0,
+ from_output: "stdout".into(),
+ to: 1,
+ to_input: "stdin".into(),
+ }],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let disc = Arc::new(DiscernPipeline::default_pipeline());
+ let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+ let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+ assert_eq!(launch.command_pids.len(), 2);
+ // Cosecha.
+ for (_, pid) in &launch.command_pids {
+ let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+ }
+ }
+
+ #[tokio::test]
+ async fn pipeline_fanin_two_to_one() {
+ // 2 productores → 1 consumer (cat). El merger multiplexa.
+ let spec = PipelineSpec {
+ label: "fanin".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![
+ cmd("p1", "/bin/echo", &["from-p1"]),
+ cmd("p2", "/bin/echo", &["from-p2"]),
+ cmd("c", "/bin/cat", &[]),
+ ],
+ edges: vec![
+ FlowEdge {
+ from: 0,
+ from_output: "stdout".into(),
+ to: 2,
+ to_input: "stdin".into(),
+ },
+ FlowEdge {
+ from: 1,
+ from_output: "stdout".into(),
+ to: 2,
+ to_input: "stdin".into(),
+ },
+ ],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let disc = Arc::new(DiscernPipeline::default_pipeline());
+ let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+ let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+ assert_eq!(launch.command_pids.len(), 3);
+ for (_, pid) in &launch.command_pids {
+ let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+ }
+ }
+
+ #[tokio::test]
+ async fn pipeline_fanout_one_to_two() {
+ // 1 productor (echo) → 2 consumers (wc -c). Splitter replica.
+ let spec = PipelineSpec {
+ label: "fanout".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![
+ cmd("p", "/bin/echo", &["fanout-test"]),
+ cmd("c1", "/bin/cat", &[]),
+ cmd("c2", "/bin/cat", &[]),
+ ],
+ edges: vec![
+ FlowEdge {
+ from: 0,
+ from_output: "stdout".into(),
+ to: 1,
+ to_input: "stdin".into(),
+ },
+ FlowEdge {
+ from: 0,
+ from_output: "stdout".into(),
+ to: 2,
+ to_input: "stdin".into(),
+ },
+ ],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let disc = Arc::new(DiscernPipeline::default_pipeline());
+ let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+ let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
+ assert_eq!(launch.command_pids.len(), 3);
+ for (_, pid) in &launch.command_pids {
+ let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+ }
+ }
+
+ #[tokio::test]
+ async fn pipeline_isolated_with_tap_captures_discernment() {
+ let spec = PipelineSpec {
+ label: "json-cat".into(),
+ workspace: WorkspaceId::new(),
+ nodes: vec![
+ cmd("p1", "/bin/echo", &["{\"hello\": 1}"]),
+ cmd("p2", "/bin/cat", &[]),
+ ],
+ edges: vec![FlowEdge {
+ from: 0,
+ from_output: "stdout".into(),
+ to: 1,
+ to_input: "stdin".into(),
+ }],
+ discern: DiscernPolicy {
+ sample_bytes: 4096,
+ enrich_producer: true,
+ replay_chunks: 32,
+ replay_bytes: 0,
+ max_bytes_per_sec: 0,
+ },
+ restart_on_failure: false,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let disc = Arc::new(DiscernPipeline::default_pipeline());
+ let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
+ let launch = run_pipeline(&spec, "ws", true, disc, inc, None).await.unwrap();
+ assert_eq!(launch.edge_discernments.len(), 1);
+ let d = &launch.edge_discernments[0];
+ let dis = d.discernment.as_ref().expect("discernment present");
+ assert_eq!(dis.mime.as_deref(), Some("application/json"));
+ // Cosecha.
+ for (_, pid) in &launch.command_pids {
+ let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
+ }
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs
new file mode 100644
index 0000000..09910df
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs
@@ -0,0 +1,318 @@
+//! Pipelines, supervisores, flows y pipelines guardados.
+
+use super::*;
+
+impl WorkspaceManager {
+ pub fn new(cfg: IncarnatorConfig) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(Inner {
+ workspaces: HashMap::new(),
+ saved_pipelines: HashMap::new(),
+ pipeline_flows: HashMap::new(),
+ restart_specs: HashMap::new(),
+ pipeline_supervisors: HashMap::new(),
+ pending_pipeline_restarts: Vec::new(),
+ })),
+ incarnator: Arc::new(Incarnator::new(cfg)),
+ dirty: std::sync::atomic::AtomicBool::new(false),
+ }
+ }
+
+ /// Marca el manager como dirty. Cualquier mutación que afecta al
+ /// snapshot debería llamar esto.
+ #[inline]
+ pub(crate) fn mark_dirty(&self) {
+ self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+
+ /// True si hubo cambios desde el último `save_snapshot`. Útil para
+ /// chequeos cooperativos (ej. monitoring que pollea cada N).
+ pub fn is_dirty(&self) -> bool {
+ self.dirty.load(std::sync::atomic::Ordering::Relaxed)
+ }
+
+ /// Registra un supervisor para un pipeline con `restart_on_failure=true`.
+ /// El daemon llama esto tras `run_pipeline` para que `reap_dead` agregue
+ /// el pipeline a la cola de restart cuando algún command falle.
+ pub async fn register_pipeline_supervisor(
+ &self,
+ pipeline_id: Ulid,
+ workspace: WorkspaceId,
+ spec: PipelineSpec,
+ tap: bool,
+ ) {
+ if !spec.restart_on_failure {
+ return;
+ }
+ tracing::debug!(%pipeline_id, label = %spec.label, "pipeline supervisor registered");
+ let mut g = self.inner.lock().await;
+ let initial_backoff = spec.restart_backoff_ms.max(50);
+ g.pipeline_supervisors.insert(
+ pipeline_id,
+ PipelineSupervisor {
+ workspace,
+ spec,
+ tap,
+ restart_count: 0,
+ current_backoff_ms: initial_backoff,
+ },
+ );
+ drop(g);
+ self.mark_dirty();
+ }
+
+ /// Variante que preserva backoff/count del supervisor anterior (para
+ /// re-registrar tras un restart sin perder el throttle acumulado).
+ pub async fn register_pipeline_supervisor_with_state(
+ &self,
+ pipeline_id: Ulid,
+ workspace: WorkspaceId,
+ spec: PipelineSpec,
+ tap: bool,
+ restart_count: u32,
+ current_backoff_ms: u64,
+ ) {
+ if !spec.restart_on_failure {
+ return;
+ }
+ let mut g = self.inner.lock().await;
+ g.pipeline_supervisors.insert(
+ pipeline_id,
+ PipelineSupervisor {
+ workspace,
+ spec,
+ tap,
+ restart_count,
+ current_backoff_ms,
+ },
+ );
+ }
+
+ /// Drena la cola de pipelines pendientes de restart y retorna las
+ /// specs a relaunch. El daemon lo llama tras cada `reap_dead`.
+ ///
+ /// Aplica `restart_max`: si el supervisor ya pasó el límite, no se
+ /// retorna y el supervisor se elimina (give-up). El backoff
+ /// preserva el valor actual; el daemon decide cuándo aplicar el
+ /// sleep antes del relaunch.
+ pub async fn take_pending_restarts(&self) -> Vec<PipelineSupervisor> {
+ let mut g = self.inner.lock().await;
+ let pending = std::mem::take(&mut g.pending_pipeline_restarts);
+ let mut out = Vec::with_capacity(pending.len());
+ for old_id in pending {
+ if let Some(mut sup) = g.pipeline_supervisors.remove(&old_id) {
+ if sup.spec.restart_max > 0 && sup.restart_count >= sup.spec.restart_max {
+ tracing::warn!(
+ label = %sup.spec.label,
+ restart_count = sup.restart_count,
+ max = sup.spec.restart_max,
+ "pipeline restart_max reached — giving up"
+ );
+ continue; // no relaunch, supervisor discarded.
+ }
+ sup.restart_count += 1;
+ out.push(sup);
+ }
+ }
+ out
+ }
+
+ /// Registra los comandos lanzados por un pipeline en el workspace.
+ /// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
+ /// de un pipeline). `pipeline_id` se setea en cada CommandState.
+ pub async fn register_pipeline_commands(
+ &self,
+ workspace: WorkspaceId,
+ pipeline_id: Ulid,
+ commands: Vec<(String, i32)>,
+ ) {
+ let mut g = self.inner.lock().await;
+ let Some(ws) = g.workspaces.get_mut(&workspace) else { return };
+ for (label, pid) in commands {
+ let cmd_id = Ulid::new();
+ ws.commands.insert(
+ cmd_id,
+ CommandState {
+ id: cmd_id,
+ label,
+ pid: Pid::from_raw(pid),
+ alive: true,
+ exit_status: None,
+ stdout: None,
+ stderr: None,
+ pipeline_id: Some(pipeline_id),
+ },
+ );
+ }
+ }
+
+ /// Detiene selectivamente los comandos de un pipeline. SIGTERM →
+ /// `grace` → SIGKILL. Devuelve cantidad reapeada. Si no hay comandos
+ /// del pipeline en ningún workspace, retorna 0.
+ pub async fn stop_pipeline(
+ &self,
+ pipeline_id: Ulid,
+ grace: std::time::Duration,
+ ) -> u32 {
+ // 1) Recolectamos pids de ese pipeline en todos los workspaces.
+ let mut targets: Vec<Pid> = Vec::new();
+ {
+ let g = self.inner.lock().await;
+ for ws in g.workspaces.values() {
+ for cmd in ws.commands.values() {
+ if cmd.alive && cmd.pipeline_id == Some(pipeline_id) {
+ targets.push(cmd.pid);
+ }
+ }
+ }
+ }
+ if targets.is_empty() {
+ return 0;
+ }
+ let initial = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
+ for pid in &targets {
+ let _ = kill(*pid, initial);
+ }
+ let mut reaped = 0u32;
+ let mut still = targets.clone();
+ let deadline = std::time::Instant::now() + grace;
+ let poll = std::time::Duration::from_millis(20);
+ while !still.is_empty() && std::time::Instant::now() < deadline {
+ still.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
+ Ok(WaitStatus::StillAlive) => true,
+ Ok(_) => {
+ reaped += 1;
+ false
+ }
+ Err(_) => false,
+ });
+ if !still.is_empty() {
+ tokio::time::sleep(poll).await;
+ }
+ }
+ for pid in &still {
+ let _ = kill(*pid, Signal::SIGKILL);
+ let _ = waitpid(*pid, None);
+ reaped += 1;
+ }
+ // Marcar como dead en estado in-memory.
+ let mut g = self.inner.lock().await;
+ for ws in g.workspaces.values_mut() {
+ for cmd in ws.commands.values_mut() {
+ if cmd.pipeline_id == Some(pipeline_id) && cmd.alive {
+ cmd.alive = false;
+ }
+ }
+ }
+ // Drop flows del pipeline.
+ g.pipeline_flows.remove(&pipeline_id);
+ info!(%pipeline_id, reaped, "pipeline stopped");
+ reaped
+ }
+
+ /// Retiene los FlowChannels de un pipeline para que sobrevivan al
+ /// fin del request. Drop = cierre del data plane.
+ pub async fn retain_pipeline_flows(
+ &self,
+ pipeline: Ulid,
+ flows: Vec<crate::flow_channel::FlowChannel>,
+ ) {
+ self.inner.lock().await.pipeline_flows.insert(pipeline, flows);
+ }
+
+ /// Snapshot de counts agregados para health endpoint.
+ pub async fn health_counts(&self) -> HealthCounts {
+ let g = self.inner.lock().await;
+ let alive_workspaces = g.workspaces.len() as u32;
+ let alive_commands: u32 = g
+ .workspaces
+ .values()
+ .map(|ws| ws.commands.values().filter(|c| c.alive).count() as u32)
+ .sum();
+ let alive_pipelines = g.pipeline_supervisors.len() as u32;
+ let active_flows: u32 = g.pipeline_flows.values().map(|v| v.len() as u32).sum();
+ HealthCounts {
+ alive_workspaces,
+ alive_commands,
+ alive_pipelines,
+ active_flows,
+ }
+ }
+
+ /// Lista pipelines vivos con sus sockets activos.
+ pub async fn list_flow_pipelines(&self) -> Vec<(Ulid, Vec<std::path::PathBuf>)> {
+ let g = self.inner.lock().await;
+ g.pipeline_flows
+ .iter()
+ .map(|(id, flows)| {
+ (
+ *id,
+ flows.iter().map(|f| f.socket_path().to_path_buf()).collect(),
+ )
+ })
+ .collect()
+ }
+
+ /// Throughput per-socket: bytes_total + bytes_per_sec por flow socket.
+ pub async fn flow_throughput(&self) -> Vec<(std::path::PathBuf, u64, f64)> {
+ let g = self.inner.lock().await;
+ let mut out = Vec::new();
+ for flows in g.pipeline_flows.values() {
+ for fc in flows {
+ out.push((
+ fc.socket_path().to_path_buf(),
+ fc.meter().total_bytes(),
+ fc.meter().bytes_per_sec(),
+ ));
+ }
+ }
+ out
+ }
+
+ /// Cierra el data plane de un pipeline (drop = remove_file de sockets).
+ pub async fn drop_pipeline_flows(&self, pipeline: Ulid) -> bool {
+ self.inner.lock().await.pipeline_flows.remove(&pipeline).is_some()
+ }
+
+ pub fn incarnator(&self) -> &Incarnator {
+ &self.incarnator
+ }
+
+ /// Handle Arc-clonable del Incarnator, para que el pipeline lo pueda
+ /// usar fuera del manager.
+ pub fn incarnator_handle(&self) -> Arc<Incarnator> {
+ self.incarnator.clone()
+ }
+
+ // -----------------------------------------------------------------
+ // Saved pipelines (definiciones nombradas, no runs)
+ // -----------------------------------------------------------------
+
+ /// Guarda (o reemplaza) un PipelineSpec bajo `name`.
+ pub async fn save_pipeline(&self, name: String, spec: PipelineSpec) {
+ self.inner.lock().await.saved_pipelines.insert(name, spec);
+ self.mark_dirty();
+ }
+
+ /// Devuelve los nombres de los pipelines guardados.
+ pub async fn list_saved_pipelines(&self) -> Vec<String> {
+ let g = self.inner.lock().await;
+ let mut v: Vec<String> = g.saved_pipelines.keys().cloned().collect();
+ v.sort();
+ v
+ }
+
+ /// Recupera el PipelineSpec guardado bajo `name`.
+ pub async fn get_saved_pipeline(&self, name: &str) -> Option<PipelineSpec> {
+ self.inner.lock().await.saved_pipelines.get(name).cloned()
+ }
+
+ /// Elimina un saved pipeline.
+ pub async fn drop_saved_pipeline(&self, name: &str) -> bool {
+ let existed = self.inner.lock().await.saved_pipelines.remove(name).is_some();
+ if existed {
+ self.mark_dirty();
+ }
+ existed
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs
new file mode 100644
index 0000000..adce389
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs
@@ -0,0 +1,236 @@
+//! Lanzamiento de pipelines y reaping cooperativo de hijos muertos.
+
+use super::*;
+
+impl WorkspaceManager {
+ /// Lanza todas las Cards de un Pipeline. Devuelve (label, pid) por nodo.
+ /// La conexión via flows queda librada al broker (cuando haya integración
+ /// completa con sidecar; v1 sólo lanza).
+ pub async fn run_pipeline(
+ &self,
+ spec: &PipelineSpec,
+ ) -> Result<Vec<(String, Pid)>, CoreError> {
+ spec.validate()?;
+ let workspace_label = {
+ let g = self.inner.lock().await;
+ let ws = g
+ .workspaces
+ .get(&spec.workspace)
+ .ok_or(CoreError::WorkspaceNotFound(spec.workspace))?;
+ ws.spec.label.clone()
+ };
+ let mut launched = Vec::new();
+ for (i, node) in spec.nodes.iter().enumerate() {
+ let card = node.to_card(i, &workspace_label)?;
+ let out = self.incarnator.incarnate(&card)?;
+ let mut g = self.inner.lock().await;
+ if let Some(ws) = g.workspaces.get_mut(&spec.workspace) {
+ ws.commands.insert(
+ card.id,
+ CommandState {
+ id: card.id,
+ label: node.label.clone(),
+ pid: out.pid,
+ alive: true,
+ exit_status: None,
+ stdout: None, // run_pipeline NO captura (conecta por pipes).
+ stderr: None,
+ pipeline_id: None,
+ },
+ );
+ }
+ launched.push((node.label.clone(), out.pid));
+ }
+ Ok(launched)
+ }
+
+ /// Cosecha hijos terminados (no-bloqueante). Llamar periódicamente desde
+ /// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
+ pub async fn reap_dead(self: &Arc<Self>) {
+ let mut to_restart: Vec<RestartSpec> = Vec::new();
+ let mut to_enforce_kill: Vec<WorkspaceId> = Vec::new();
+ {
+ let mut g = self.inner.lock().await;
+ for ws in g.workspaces.values_mut() {
+ for cmd in ws.commands.values_mut() {
+ if !cmd.alive {
+ continue;
+ }
+ match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
+ Ok(WaitStatus::Exited(_, code)) => {
+ cmd.alive = false;
+ cmd.exit_status = Some(code);
+ }
+ Ok(WaitStatus::Signaled(_, sig, _)) => {
+ cmd.alive = false;
+ cmd.exit_status = Some(128 + (sig as i32));
+ }
+ _ => {}
+ }
+ }
+ }
+ // Quota enforcement: chequear breach por workspace y aplicar policy.
+ // Lo hacemos dentro del mismo lock para tener una lectura
+ // consistente; el kill real va fuera del lock.
+ for (ws_id, ws) in g.workspaces.iter() {
+ let rl = &ws.spec.soma.rlimits;
+ let qe = &ws.spec.quota_enforce;
+ // Sólo aplicamos si hay al menos una action != None.
+ if qe.mem == shuma_card::QuotaAction::None
+ && qe.nproc == shuma_card::QuotaAction::None
+ {
+ continue;
+ }
+ // Medir RSS y nproc vivos sin pasar por workspace_stats
+ // (que tomaría el lock recursivo). Hacemos un read directo.
+ let alive: Vec<i32> = ws
+ .commands
+ .values()
+ .filter(|c| c.alive)
+ .map(|c| c.pid.as_raw())
+ .collect();
+ let nproc_alive = alive.len() as u32;
+ let mem_used: u64 = alive
+ .iter()
+ .filter_map(|pid| read_proc_rss(*pid))
+ .sum();
+
+ let mem_breach = matches!(rl.mem_bytes, Some(limit) if mem_used > limit);
+ let nproc_breach = matches!(rl.nproc, Some(limit) if nproc_alive > limit);
+
+ let mut kill_needed = false;
+ if mem_breach {
+ match qe.mem {
+ shuma_card::QuotaAction::Log => {
+ warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: memory");
+ }
+ shuma_card::QuotaAction::Kill => {
+ warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: KILLING");
+ kill_needed = true;
+ }
+ _ => {}
+ }
+ }
+ if nproc_breach {
+ match qe.nproc {
+ shuma_card::QuotaAction::Log => {
+ warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: nproc");
+ }
+ shuma_card::QuotaAction::Kill => {
+ warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: KILLING");
+ kill_needed = true;
+ }
+ _ => {}
+ }
+ }
+ if kill_needed {
+ to_enforce_kill.push(*ws_id);
+ }
+ }
+ // Pipeline supervisor: detectar pipelines cuyos comandos tienen
+ // failure. Marca para restart si tiene supervisor.
+ // Esto se hace cuando TODOS los comandos del pipeline están
+ // dead Y al menos uno tiene exit!=0 (sino podría disparar
+ // restart mientras otros comandos aún corren — incorrecto).
+ let supervisor_ids: Vec<Ulid> = g.pipeline_supervisors.keys().copied().collect();
+ for pipe_id in supervisor_ids {
+ // ¿Hay algún comando vivo de este pipeline?
+ let mut all_dead = true;
+ let mut any_failed = false;
+ for ws in g.workspaces.values() {
+ for cmd in ws.commands.values() {
+ if cmd.pipeline_id != Some(pipe_id) {
+ continue;
+ }
+ if cmd.alive {
+ all_dead = false;
+ } else if cmd.exit_status.map_or(false, |s| s != 0) {
+ any_failed = true;
+ }
+ }
+ }
+ if all_dead && any_failed {
+ // Push a queue si no estaba ya.
+ if !g.pending_pipeline_restarts.contains(&pipe_id) {
+ g.pending_pipeline_restarts.push(pipe_id);
+ }
+ }
+ }
+ // Detectar restart_specs cuyo command_id ya está dead con exit!=0.
+ let mut to_remove: Vec<Ulid> = Vec::new();
+ for (cmd_id, spec) in g.restart_specs.iter() {
+ let mut should_restart = false;
+ let mut should_drop = false;
+ 'outer: for ws in g.workspaces.values() {
+ if let Some(cmd) = ws.commands.get(cmd_id) {
+ if !cmd.alive {
+ match cmd.exit_status {
+ Some(0) => should_drop = true,
+ Some(_) => should_restart = true,
+ None => {}
+ }
+ break 'outer;
+ }
+ }
+ }
+ if should_drop {
+ to_remove.push(*cmd_id);
+ } else if should_restart {
+ to_restart.push(spec.clone());
+ to_remove.push(*cmd_id);
+ }
+ }
+ for id in to_remove {
+ g.restart_specs.remove(&id);
+ }
+ }
+ // Quota enforcement: kill workspaces fuera del lock.
+ for ws_id in to_enforce_kill {
+ let _ = self.stop_with_grace(ws_id, std::time::Duration::ZERO).await;
+ }
+ // Schedule restart fuera del lock.
+ for mut spec in to_restart {
+ let mgr = self.clone();
+ let backoff = std::time::Duration::from_millis(spec.backoff_ms);
+ // Subir el backoff para la PRÓXIMA falla, no esta.
+ spec.backoff_ms = (spec.backoff_ms * 2).min(spec.max_backoff_ms);
+ spec.restart_count += 1;
+ let restart_n = spec.restart_count;
+ tokio::spawn(async move {
+ tokio::time::sleep(backoff).await;
+ info!(
+ backoff_ms = backoff.as_millis() as u64,
+ restart = restart_n,
+ "restarting failed command"
+ );
+ let workspace = spec.workspace;
+ if let Err(e) = mgr
+ .run_with_options(workspace, spec.exec.clone(), spec.argv.clone(), spec.envp.clone(), true)
+ .await
+ {
+ warn!(?e, "restart failed to launch");
+ return;
+ }
+ // Preservar backoff acumulado: localizar el nuevo command_id
+ // (el más reciente vivo en el workspace) y sobreescribir.
+ let new_cmd_id = {
+ let g = mgr.inner.lock().await;
+ g.workspaces.get(&workspace).and_then(|ws| {
+ ws.commands
+ .values()
+ .filter(|c| c.alive)
+ .max_by_key(|c| c.id)
+ .map(|c| c.id)
+ })
+ };
+ if let Some(new_id) = new_cmd_id {
+ let mut g = mgr.inner.lock().await;
+ if let Some(existing) = g.restart_specs.get_mut(&new_id) {
+ existing.backoff_ms = spec.backoff_ms;
+ existing.restart_count = spec.restart_count;
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/stats.rs b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs
new file mode 100644
index 0000000..33ba9ff
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs
@@ -0,0 +1,210 @@
+//! Resource accounting por workspace.
+//!
+//! Dos fuentes:
+//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
+//! los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
+//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
+//! `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
+//!
+//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
+//! el per-proc como `sample_via_proc`.
+
+use std::path::Path;
+use std::time::Instant;
+
+#[derive(Debug, Clone, Default)]
+pub struct WorkspaceStats {
+ pub commands_alive: u32,
+ pub commands_total: u32,
+ /// RSS sumado en bytes. `None` si no se pudo medir.
+ pub rss_bytes: Option<u64>,
+ /// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
+ /// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
+ pub rss_peak_bytes: Option<u64>,
+ /// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
+ pub cpu_usec: Option<u64>,
+ /// %CPU instantáneo derivado entre dos samples consecutivos. `None`
+ /// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
+ /// `400.0` con 4 cores activos = la máquina al 100%.
+ pub cpu_percent: Option<f32>,
+ /// Cores online detectados (sysconf `_SC_NPROCESSORS_ONLN`). Útil
+ /// para normalizar `cpu_percent / cpu_cores` → 0..100 absoluto.
+ pub cpu_cores: u32,
+ /// Fuente del dato: "proc" | "cgroup" | "mixed".
+ pub source: String,
+ /// Wall-clock uptime del workspace en milisegundos.
+ pub uptime_ms: u64,
+}
+
+impl WorkspaceStats {
+ /// CPU% normalizado al 100% total de la máquina (no por core).
+ /// Útil para comparar workspaces independiente del paralelismo.
+ pub fn cpu_percent_total(&self) -> Option<f32> {
+ self.cpu_percent
+ .map(|p| if self.cpu_cores == 0 { p } else { p / self.cpu_cores as f32 })
+ }
+}
+
+/// Reporte de quotas: comparación entre el accounting real y los
+/// `rlimits` declarados en `SomaSpec`. NO hace enforcement automático
+/// en v1 — sólo accounting + reporting. El caller decide qué hacer.
+#[derive(Debug, Clone, Default)]
+pub struct QuotaReport {
+ /// Límite de memoria declarado (bytes). None = sin límite.
+ pub mem_limit: Option<u64>,
+ /// Límite de procesos declarado.
+ pub nproc_limit: Option<u32>,
+ /// Lista de violaciones detectadas (strings humano-legibles).
+ /// Empty = todo dentro de quota.
+ pub breaches: Vec<String>,
+}
+
+/// Detecta cores online runtime. Cacheado vía OnceLock — el valor no
+/// cambia salvo hotplug, que es raro y aceptamos sample stale.
+fn online_cores() -> u32 {
+ static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
+ *CACHED.get_or_init(|| {
+ let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
+ if n > 0 { n as u32 } else { 1 }
+ })
+}
+
+/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
+pub fn measure(
+ alive_pids: &[i32],
+ cgroup_path: Option<&Path>,
+ workspace_started: Instant,
+) -> WorkspaceStats {
+ let mut rss_proc: u64 = 0;
+ let mut rss_peak_proc: u64 = 0;
+ let mut cpu_proc: u64 = 0;
+ let mut proc_ok = false;
+ for &pid in alive_pids {
+ if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
+ rss_proc += rss;
+ rss_peak_proc += peak;
+ cpu_proc += cpu;
+ proc_ok = true;
+ }
+ }
+
+ let cgroup = cgroup_path.and_then(read_cgroup_stats);
+
+ let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
+ (Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
+ (None, true) => (
+ Some(rss_proc),
+ Some(rss_peak_proc),
+ Some(cpu_proc),
+ "proc".to_string(),
+ ),
+ (None, false) => (None, None, None, "none".to_string()),
+ };
+
+ WorkspaceStats {
+ commands_alive: alive_pids.len() as u32,
+ commands_total: 0,
+ rss_bytes: rss,
+ rss_peak_bytes: rss_peak,
+ cpu_usec: cpu,
+ cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
+ cpu_cores: online_cores(),
+ source,
+ uptime_ms: workspace_started.elapsed().as_millis() as u64,
+ }
+}
+
+struct CgroupStats {
+ rss: u64,
+ rss_peak: Option<u64>,
+ cpu_usec: u64,
+}
+
+/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
+fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
+ let (rss_kb, hwm_kb) = {
+ let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
+ let mut rss = 0u64;
+ let mut hwm = 0u64;
+ for l in status.lines() {
+ if let Some(rest) = l.strip_prefix("VmRSS:") {
+ rss = rest
+ .trim()
+ .split_whitespace()
+ .next()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(0);
+ } else if let Some(rest) = l.strip_prefix("VmHWM:") {
+ hwm = rest
+ .trim()
+ .split_whitespace()
+ .next()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(0);
+ }
+ }
+ (rss, hwm)
+ };
+ let cpu_usec = {
+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+ // format: pid (comm) state ppid pgrp ... utime stime cutime cstime
+ // Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
+ let end_comm = stat.rfind(')')?;
+ let after = &stat[end_comm + 1..];
+ let fields: Vec<&str> = after.split_whitespace().collect();
+ // Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
+ let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
+ let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
+ let ticks = utime + stime;
+ // Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
+ let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
+ ticks * 1_000_000 / clk_tck
+ };
+ Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
+}
+
+/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
+/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
+fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
+ let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
+ .ok()
+ .and_then(|s| s.trim().parse::<u64>().ok())?;
+ let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
+ let cpu_usec = cpu_stat
+ .lines()
+ .find_map(|l| l.strip_prefix("usage_usec"))
+ .and_then(|s| s.split_whitespace().next())
+ .and_then(|s| s.parse::<u64>().ok())
+ .unwrap_or(0);
+ let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
+ .ok()
+ .and_then(|s| s.trim().parse::<u64>().ok());
+ Some(CgroupStats {
+ rss: mem,
+ rss_peak: peak,
+ cpu_usec,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn measure_with_no_pids_returns_zero() {
+ let stats = measure(&[], None, Instant::now());
+ assert_eq!(stats.commands_alive, 0);
+ assert_eq!(stats.rss_bytes, None);
+ assert_eq!(stats.source, "none");
+ }
+
+ #[test]
+ fn measure_self_pid_returns_data() {
+ let me = std::process::id() as i32;
+ let stats = measure(&[me], None, Instant::now());
+ assert_eq!(stats.commands_alive, 1);
+ // Nuestro propio RSS debería ser > 0.
+ assert!(stats.rss_bytes.unwrap_or(0) > 0);
+ assert_eq!(stats.source, "proc");
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/tests.rs b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs
new file mode 100644
index 0000000..7b25dba
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs
@@ -0,0 +1,308 @@
+use super::*;
+
+#[tokio::test]
+async fn ttl_auto_stops_workspace() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "ttl-test".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: Some(std::time::Duration::from_millis(120)),
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ assert_eq!(mgr.list().await.len(), 1);
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ assert_eq!(
+ mgr.list().await.len(),
+ 0,
+ "TTL expirado: workspace debe haber sido removido"
+ );
+ let _ = id;
+}
+
+#[tokio::test]
+async fn create_and_list_workspace() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "test".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _w) = mgr.create(spec).await.unwrap();
+ let list = mgr.list().await;
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].id, id);
+}
+
+#[tokio::test]
+async fn run_captures_stdout_to_log() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "logs".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ let summary = mgr
+ .run(id, "/bin/echo".into(), vec!["captured-output".into()], vec![])
+ .await
+ .unwrap();
+ for _ in 0..50 {
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ mgr.reap_dead().await;
+ let logs = mgr
+ .get_command_logs(id, summary.id, 0, LogStream::Stdout)
+ .await
+ .unwrap_or_default();
+ if !logs.is_empty() {
+ let s = String::from_utf8_lossy(&logs);
+ assert!(s.contains("captured-output"), "got: {s:?}");
+ return;
+ }
+ }
+ panic!("logs never captured");
+}
+
+#[tokio::test]
+async fn run_captures_stderr_separately() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "stderr".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ // sh -c "echo OUT; echo ERR >&2"
+ let summary = mgr
+ .run(
+ id,
+ "/bin/sh".into(),
+ vec!["-c".into(), "echo OUT; echo ERR >&2".into()],
+ vec![],
+ )
+ .await
+ .unwrap();
+ for _ in 0..50 {
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ mgr.reap_dead().await;
+ let so = mgr
+ .get_command_logs(id, summary.id, 0, LogStream::Stdout)
+ .await
+ .unwrap_or_default();
+ let se = mgr
+ .get_command_logs(id, summary.id, 0, LogStream::Stderr)
+ .await
+ .unwrap_or_default();
+ if !so.is_empty() && !se.is_empty() {
+ let so_s = String::from_utf8_lossy(&so);
+ let se_s = String::from_utf8_lossy(&se);
+ assert!(so_s.contains("OUT"), "stdout: {so_s:?}");
+ assert!(se_s.contains("ERR"), "stderr: {se_s:?}");
+ assert!(!so_s.contains("ERR"), "stdout no debería tener ERR");
+ assert!(!se_s.contains("OUT"), "stderr no debería tener OUT");
+ return;
+ }
+ }
+ panic!("logs never captured on both streams");
+}
+
+#[tokio::test]
+async fn restart_on_failure_relaunches_failing_command() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "restart".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ // /bin/false sale con exit=1. Con restart_on_failure=true debería
+ // relanzarse al menos 1 vez (tras el backoff inicial de 200ms).
+ let summary = mgr
+ .run_with_options(id, "/bin/false".into(), vec![], vec![], true)
+ .await
+ .unwrap();
+ let original_id = summary.id;
+ // Esperamos ~500ms para que termine + reap + restart corra.
+ for _ in 0..30 {
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ mgr.reap_dead().await;
+ let g = mgr.inner.lock().await;
+ if let Some(ws) = g.workspaces.get(&id) {
+ let new_cmds: Vec<_> = ws.commands.keys().filter(|k| **k != original_id).collect();
+ if !new_cmds.is_empty() {
+ // Hay un nuevo command_id → restart funcionó.
+ return;
+ }
+ }
+ }
+ panic!("restart never launched a new command");
+}
+
+#[tokio::test]
+async fn pipeline_supervisor_queues_restart_on_failure() {
+ use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let (ws_id, _) = mgr.create(WorkspaceSpec {
+ label: "psup".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ }).await.unwrap();
+ let spec = PipelineSpec {
+ label: "fail-pipeline".into(),
+ workspace: ws_id,
+ nodes: vec![CommandRef {
+ label: "boom".into(),
+ payload: card_core::Payload::Native {
+ exec: "/bin/false".into(),
+ argv: vec![],
+ envp: vec![],
+ },
+ soma: Default::default(),
+ flows: Default::default(),
+ supervision: card_core::Supervision::OneShot,
+ }],
+ edges: vec![],
+ discern: DiscernPolicy::default(),
+ restart_on_failure: true,
+ restart_backoff_ms: 200,
+ restart_max_backoff_ms: 30_000,
+ restart_max: 0,
+ };
+ let pipeline_id = ulid::Ulid::new();
+ // Simulamos lo que haría el daemon: registramos un comando como
+ // si fuera de pipeline. Usamos `register_pipeline_commands` con
+ // un pid fake — pero como reaper hace waitpid, mejor lanzar de verdad.
+ // Hack: usar /bin/false via run() y manualmente marcar pipeline_id.
+ let summary = mgr.run(ws_id, "/bin/false".into(), vec![], vec![]).await.unwrap();
+ // Marcar el comando con pipeline_id manualmente.
+ {
+ let mut g = mgr.inner.lock().await;
+ if let Some(ws) = g.workspaces.get_mut(&ws_id) {
+ if let Some(cmd) = ws.commands.get_mut(&summary.id) {
+ cmd.pipeline_id = Some(pipeline_id);
+ }
+ }
+ }
+ mgr.register_pipeline_supervisor(pipeline_id, ws_id, spec, true).await;
+ // Esperamos que reap detecte la falla y push a pending.
+ for _ in 0..40 {
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ mgr.reap_dead().await;
+ let pending = mgr.take_pending_restarts().await;
+ if !pending.is_empty() {
+ assert_eq!(pending[0].spec.label, "fail-pipeline");
+ return;
+ }
+ }
+ panic!("supervisor never queued a restart");
+}
+
+#[tokio::test]
+async fn quota_enforce_nproc_kill_terminates_commands() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let mut spec = WorkspaceSpec {
+ label: "qenforce".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: shuma_card::QuotaEnforcement {
+ mem: shuma_card::QuotaAction::None,
+ nproc: shuma_card::QuotaAction::Kill,
+ },
+ };
+ spec.soma.rlimits.nproc = Some(1);
+ let (id, _) = mgr.create(spec).await.unwrap();
+ // Lanzo 2 procesos (cada uno sleep). nproc_limit=1 → breach inmediato.
+ let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+ let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
+ // Reaper detecta breach y mata workspace.
+ for _ in 0..30 {
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+ mgr.reap_dead().await;
+ let alive = mgr.list().await;
+ if alive.is_empty() {
+ return; // workspace removido por stop()
+ }
+ }
+ panic!("quota enforce kill never triggered");
+}
+
+#[tokio::test]
+async fn workspace_stats_history_accumulates() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "history".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ // Necesitamos al menos un comando vivo para que `measure` no
+ // retorne source=none (que igual se appendea, pero con stats vacíos).
+ let _ = mgr
+ .run(id, "/bin/sleep".into(), vec!["5".into()], vec![])
+ .await
+ .unwrap();
+ // Llamar stats 5 veces.
+ for _ in 0..5 {
+ let _ = mgr.workspace_stats(id).await;
+ }
+ let history = mgr.workspace_stats_history(id, 0).await.unwrap();
+ assert_eq!(history.len(), 5, "history debería tener 5 samples");
+ // tail=3 retorna los últimos 3.
+ let tail3 = mgr.workspace_stats_history(id, 3).await.unwrap();
+ assert_eq!(tail3.len(), 3);
+ // Cleanup.
+ let _ = mgr.stop_with_grace(id, std::time::Duration::ZERO).await;
+}
+
+#[tokio::test]
+async fn run_true_in_workspace() {
+ let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
+ let spec = WorkspaceSpec {
+ label: "exec".into(),
+ soma: Default::default(),
+ permissions: Default::default(),
+ ttl: None,
+ flow_dirs: vec![],
+ on_exit: shuma_card::ExitPolicy::Reap,
+ quota_enforce: Default::default(),
+ };
+ let (id, _) = mgr.create(spec).await.unwrap();
+ let summary = mgr
+ .run(id, "/bin/true".into(), vec![], vec![])
+ .await
+ .unwrap();
+ assert!(summary.pid > 0);
+ // Cosecha.
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ mgr.reap_dead().await;
+}
diff --git a/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs
new file mode 100644
index 0000000..7832b0f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs
@@ -0,0 +1,414 @@
+//! Workspaces: alta/baja, stats/quota, listado, comandos.
+
+use super::*;
+
+impl WorkspaceManager {
+
+ /// Label del workspace, si existe.
+ pub async fn workspace_label(&self, id: WorkspaceId) -> Option<String> {
+ self.inner
+ .lock()
+ .await
+ .workspaces
+ .get(&id)
+ .map(|w| w.spec.label.clone())
+ }
+
+ /// Compara accounting real (RSS, commands_alive) contra los rlimits
+ /// declarados en `SomaSpec`. Devuelve violaciones humanizadas. NO
+ /// hace enforcement automático.
+ pub async fn workspace_quota(&self, id: WorkspaceId) -> Option<stats::QuotaReport> {
+ let stats_now = self.workspace_stats(id).await?;
+ let g = self.inner.lock().await;
+ let ws = g.workspaces.get(&id)?;
+ let rl = &ws.spec.soma.rlimits;
+ let mut report = stats::QuotaReport {
+ mem_limit: rl.mem_bytes,
+ nproc_limit: rl.nproc,
+ breaches: Vec::new(),
+ };
+ if let (Some(limit), Some(used)) = (rl.mem_bytes, stats_now.rss_bytes) {
+ if used > limit {
+ report.breaches.push(format!(
+ "memory: {:.2} MiB > {:.2} MiB limit",
+ used as f64 / 1024.0 / 1024.0,
+ limit as f64 / 1024.0 / 1024.0,
+ ));
+ }
+ }
+ if let Some(limit) = rl.nproc {
+ if stats_now.commands_alive > limit {
+ report.breaches.push(format!(
+ "nproc: {} alive > {} limit",
+ stats_now.commands_alive, limit
+ ));
+ }
+ }
+ Some(report)
+ }
+
+ /// Estadísticas de recursos del workspace: RSS + CPU agregado de sus
+ /// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
+ /// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
+ /// descendants).
+ ///
+ /// `cpu_percent` se calcula entre samples consecutivos. Necesita ≥2
+ /// llamadas para tener un valor (la primera siempre retorna `None`).
+ pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
+ let mut g = self.inner.lock().await;
+ let ws = g.workspaces.get_mut(&id)?;
+ let alive: Vec<i32> = ws
+ .commands
+ .values()
+ .filter(|c| c.alive)
+ .map(|c| c.pid.as_raw())
+ .collect();
+ let total = ws.commands.len() as u32;
+ let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
+ None
+ } else {
+ Some(std::path::PathBuf::from(format!(
+ "/sys/fs/cgroup{}",
+ ws.spec.soma.cgroup.path
+ )))
+ };
+ let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
+ s.commands_total = total;
+
+ // CPU%: diff entre el sample actual y el previo, dividido por
+ // wall time. 100% = 1 core saturado. >100% = varios cores.
+ let now = Instant::now();
+ if let Some(cpu_now) = s.cpu_usec {
+ if let Some((prev_t, prev_cpu)) = ws.last_cpu_sample {
+ let dt_us = now.duration_since(prev_t).as_micros() as u64;
+ let d_cpu = cpu_now.saturating_sub(prev_cpu);
+ if dt_us > 0 {
+ s.cpu_percent = Some(100.0 * d_cpu as f32 / dt_us as f32);
+ }
+ }
+ ws.last_cpu_sample = Some((now, cpu_now));
+ }
+ // Append a history (ring buffer cap).
+ if ws.stats_history.len() >= STATS_HISTORY_CAP {
+ ws.stats_history.pop_front();
+ }
+ ws.stats_history.push_back(s.clone());
+ Some(s)
+ }
+
+ /// Retorna las últimas N samples de stats (servidas desde el ring
+ /// buffer interno). Sobrevive restart del shell.
+ pub async fn workspace_stats_history(
+ &self,
+ id: WorkspaceId,
+ tail: usize,
+ ) -> Option<Vec<stats::WorkspaceStats>> {
+ let g = self.inner.lock().await;
+ let ws = g.workspaces.get(&id)?;
+ let take = if tail == 0 { ws.stats_history.len() } else { tail };
+ let skip = ws.stats_history.len().saturating_sub(take);
+ Some(ws.stats_history.iter().skip(skip).cloned().collect())
+ }
+
+ pub async fn create(
+ self: &Arc<Self>,
+ spec: WorkspaceSpec,
+ ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+ self.create_with_id(WorkspaceId::new(), spec).await
+ }
+
+ /// Variante que acepta el ID. Útil para restore_snapshot: preserva
+ /// ULIDs entre restarts, así clients que tracking workspace_id no se
+ /// rompen.
+ pub async fn create_with_id(
+ self: &Arc<Self>,
+ id: WorkspaceId,
+ spec: WorkspaceSpec,
+ ) -> Result<(WorkspaceId, Vec<String>), CoreError> {
+ let card = spec.to_card(id)?;
+ let mut warnings = self.incarnator.dry_run(&card).warnings;
+ let ttl = spec.ttl;
+
+ // Si el workspace declara cgroup path Y rlimits, intentamos
+ // crear el cgroup y escribir memory.max/pids.max. El kernel
+ // hace OOM kill al exceder memory.max — enforcement automático
+ // sin policy adicional. Falla silenciosa si no hay delegation.
+ if !spec.soma.cgroup.path.is_empty() {
+ if let Ok(abs) = arje_incarnate::cgroup::ensure_cgroup(&spec.soma.cgroup) {
+ let applied =
+ arje_incarnate::cgroup::apply_rlimits_to_cgroup(&abs, &spec.soma.rlimits);
+ if !applied.is_empty() {
+ warnings.push(format!("cgroup limits applied: {}", applied.join(", ")));
+ }
+ }
+ }
+ let state = WorkspaceState {
+ id,
+ spec,
+ root_card: card,
+ commands: HashMap::new(),
+ started: Instant::now(),
+ last_cpu_sample: None,
+ stats_history: std::collections::VecDeque::with_capacity(STATS_HISTORY_CAP),
+ };
+ self.inner.lock().await.workspaces.insert(id, state);
+ self.mark_dirty();
+ info!(%id, ?ttl, "workspace created");
+
+ // Si tiene TTL, programar auto-stop. El task captura un weak ref
+ // al manager para no impedir que se dropée si el daemon termina.
+ if let Some(duration) = ttl {
+ let mgr_weak = Arc::downgrade(self);
+ tokio::spawn(async move {
+ tokio::time::sleep(duration).await;
+ if let Some(mgr) = mgr_weak.upgrade() {
+ let exists = mgr.inner.lock().await.workspaces.contains_key(&id);
+ if exists {
+ info!(%id, "workspace TTL expired — auto-stop");
+ let _ = mgr.stop(id).await;
+ }
+ }
+ });
+ }
+
+ Ok((id, warnings))
+ }
+
+ pub async fn list(&self) -> Vec<WorkspaceSnapshot> {
+ let g = self.inner.lock().await;
+ g.workspaces
+ .values()
+ .map(|w| WorkspaceSnapshot {
+ id: w.id,
+ label: w.spec.label.clone(),
+ commands: w.commands.len() as u32,
+ uptime_ms: w.started.elapsed().as_millis() as u64,
+ })
+ .collect()
+ }
+
+ pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
+ self.stop_with_grace(id, std::time::Duration::from_millis(1000)).await
+ }
+
+ /// Variante con tiempo de gracia configurable. SIGTERM → espera `grace`
+ /// → SIGKILL si quedan vivos. `grace=0` = SIGKILL inmediato.
+ pub async fn stop_with_grace(
+ &self,
+ id: WorkspaceId,
+ grace: std::time::Duration,
+ ) -> Result<u32, CoreError> {
+ let mut g = self.inner.lock().await;
+ let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+ // También limpiamos flow_channels del workspace si los hubiera —
+ // por workspace lo retenemos por pipeline, no por workspace.
+ drop(g);
+ self.mark_dirty();
+
+ // 1) SIGTERM (o SIGKILL si grace=0) a todos vivos.
+ let initial_signal = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
+ let alive_pids: Vec<Pid> = ws
+ .commands
+ .values()
+ .filter(|c| c.alive)
+ .map(|c| c.pid)
+ .collect();
+ for pid in &alive_pids {
+ let _ = kill(*pid, initial_signal);
+ }
+
+ // 2) Esperar hasta `grace` haciendo polling WNOHANG.
+ let mut reaped = 0u32;
+ let mut still_alive: Vec<Pid> = alive_pids.clone();
+ let deadline = std::time::Instant::now() + grace;
+ let poll_interval = std::time::Duration::from_millis(20);
+ while !still_alive.is_empty() && std::time::Instant::now() < deadline {
+ still_alive.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
+ Ok(WaitStatus::StillAlive) => true,
+ Ok(_) => {
+ reaped += 1;
+ false
+ }
+ Err(_) => false,
+ });
+ if !still_alive.is_empty() {
+ tokio::time::sleep(poll_interval).await;
+ }
+ }
+
+ // 3) SIGKILL forzoso a los que quedan, y wait blocking.
+ for pid in &still_alive {
+ let _ = kill(*pid, Signal::SIGKILL);
+ let _ = waitpid(*pid, None);
+ reaped += 1;
+ }
+ info!(
+ %id,
+ reaped,
+ grace_ms = grace.as_millis() as u64,
+ sigkilled = still_alive.len(),
+ "workspace stopped"
+ );
+ Ok(reaped)
+ }
+
+ /// Ejecuta un comando one-shot dentro de un workspace existente.
+ /// Captura stdout+stderr en un ring buffer accesible vía
+ /// [`get_command_logs`](Self::get_command_logs).
+ pub async fn run(
+ &self,
+ id: WorkspaceId,
+ exec: String,
+ argv: Vec<String>,
+ envp: Vec<(String, String)>,
+ ) -> Result<CommandSummary, CoreError> {
+ self.run_with_options(id, exec, argv, envp, false).await
+ }
+
+ /// Variante con `restart_on_failure`: si el comando muere con
+ /// exit_status != 0, el reaper lo relauncha con backoff exponencial
+ /// (200ms → 400 → 800 → … cap 30s).
+ pub async fn run_with_options(
+ &self,
+ id: WorkspaceId,
+ exec: String,
+ argv: Vec<String>,
+ envp: Vec<(String, String)>,
+ restart_on_failure: bool,
+ ) -> Result<CommandSummary, CoreError> {
+ let workspace_label = {
+ let g = self.inner.lock().await;
+ let ws = g.workspaces.get(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
+ ws.spec.label.clone()
+ };
+ let cmd_ref = CommandRef {
+ label: format!("run-{}", short_ulid(&Ulid::new())),
+ payload: Payload::Native { exec, argv, envp },
+ soma: Default::default(),
+ flows: Default::default(),
+ supervision: Supervision::OneShot,
+ };
+ let card = cmd_ref.to_card(0, &workspace_label)?;
+
+ // Dos pipes O_CLOEXEC: uno para stdout, otro para stderr.
+ use std::os::fd::IntoRawFd;
+ let (sout_r, sout_w) =
+ nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
+ CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+ })?;
+ let (serr_r, serr_w) =
+ nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
+ CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
+ })?;
+ let sout_r_fd = sout_r.into_raw_fd();
+ let sout_w_fd = sout_w.into_raw_fd();
+ let serr_r_fd = serr_r.into_raw_fd();
+ let serr_w_fd = serr_w.into_raw_fd();
+
+ let stdout_buf = logbuf::LogBuf::new();
+ let stderr_buf = logbuf::LogBuf::new();
+
+ let stdio = arje_incarnate::ChildStdio {
+ stdin_fd: None,
+ stdout_fd: Some(sout_w_fd),
+ stderr_fd: Some(serr_w_fd),
+ };
+ let out = self.incarnator.incarnate_with(&card, stdio)?;
+ let cmd_id = card.id;
+ let cmd_label = cmd_ref.label.clone();
+ let pid = out.pid;
+
+ spawn_log_drainer(sout_r_fd, stdout_buf.clone());
+ spawn_log_drainer(serr_r_fd, stderr_buf.clone());
+
+ let mut g = self.inner.lock().await;
+ if let Some(ws) = g.workspaces.get_mut(&id) {
+ ws.commands.insert(
+ cmd_id,
+ CommandState {
+ id: cmd_id,
+ label: cmd_label.clone(),
+ pid,
+ alive: true,
+ exit_status: None,
+ stdout: Some(stdout_buf),
+ stderr: Some(stderr_buf),
+ pipeline_id: None,
+ },
+ );
+ }
+ if restart_on_failure {
+ // Reextract exec/argv/envp del payload del CommandRef.
+ if let Payload::Native { exec, argv, envp } = &cmd_ref.payload {
+ g.restart_specs.insert(
+ cmd_id,
+ RestartSpec {
+ workspace: id,
+ exec: exec.clone(),
+ argv: argv.clone(),
+ envp: envp.clone(),
+ backoff_ms: 200,
+ max_backoff_ms: 30_000,
+ restart_count: 0,
+ },
+ );
+ }
+ }
+ for d in &out.degradations {
+ warn!(?d, %id, "command incarnation degradation");
+ }
+ Ok(CommandSummary {
+ id: cmd_id,
+ label: cmd_label,
+ pid: pid.as_raw(),
+ })
+ }
+
+ /// Devuelve el tail del log capturado para `(workspace, command)`.
+ /// `stream` selecciona stdout/stderr/both.
+ pub async fn get_command_logs(
+ &self,
+ workspace: WorkspaceId,
+ command: Ulid,
+ tail_bytes: usize,
+ stream: LogStream,
+ ) -> Option<Vec<u8>> {
+ let g = self.inner.lock().await;
+ let ws = g.workspaces.get(&workspace)?;
+ let cmd = ws.commands.get(&command)?;
+ match stream {
+ LogStream::Stdout => cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)),
+ LogStream::Stderr => cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)),
+ LogStream::Both => {
+ let so = cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
+ let se = cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
+ let mut out = so;
+ out.extend_from_slice(&se);
+ Some(out)
+ }
+ }
+ }
+
+ /// Lista comandos de un workspace.
+ pub async fn list_commands(&self, workspace: WorkspaceId) -> Vec<CommandInfo> {
+ let g = self.inner.lock().await;
+ let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() };
+ let mut out: Vec<CommandInfo> = ws
+ .commands
+ .values()
+ .map(|c| CommandInfo {
+ id: c.id,
+ label: c.label.clone(),
+ pid: c.pid.as_raw(),
+ alive: c.alive,
+ exit_status: c.exit_status,
+ log_bytes: c.stdout.as_ref().map(|l| l.written_total()).unwrap_or(0)
+ + c.stderr.as_ref().map(|l| l.written_total()).unwrap_or(0),
+ })
+ .collect();
+ // Orden estable por ULID (temporal).
+ out.sort_by_key(|c| c.id);
+ out
+ }
+
+}
diff --git a/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml
new file mode 100644
index 0000000..7e3e0f7
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "shuma-exec"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — ejecutor de comandos del shell con salida en streaming: lanza un proceso y entrega stdout/stderr línea a línea por un canal. Agnóstico de UI."
+
+[dependencies]
+# `zerocopy` → `splice` (volcado pipe→archivo sin copia). `OFlag` (para los
+# pipes de tee por etapa con `O_CLOEXEC` vía `pipe2`) vive bajo la feature
+# `fs`, que el workspace ya habilita — `unistd::pipe2` no necesita feature.
+nix = { workspace = true, features = ["zerocopy"] }
+# PTY allocation cross-platform — usado por `Exec::Pty` para los
+# comandos TUI (vim, htop, less, claude-code, etc.).
+portable-pty = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-exec/LEEME.md b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md
new file mode 100644
index 0000000..5c99811
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-exec
+
+> Ejecutor de comandos de [shuma](../../README.md).
+
+Envuelve `std::process::Command` con job-control, signal handling, env del session. Pipes, redirects, &&/||.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `nix`
diff --git a/02_ruway/shuma/sandbox/shuma-exec/README.md b/02_ruway/shuma/sandbox/shuma-exec/README.md
new file mode 100644
index 0000000..447abc1
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/README.md
@@ -0,0 +1,9 @@
+# shuma-exec
+
+> Command executor of [shuma](../../README.md).
+
+Wraps `std::process::Command` with job-control, signal handling, session env. Pipes, redirects, &&/||.
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md), `nix`
diff --git a/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs
new file mode 100644
index 0000000..4e667c3
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs
@@ -0,0 +1,1269 @@
+//! `shuma-exec` — ejecución de comandos del shell con salida en streaming.
+//!
+//! Dos modos de ejecución, un mismo contrato de eventos:
+//!
+//! - [`Exec::Direct`] — brahman lanza y **conecta los procesos él mismo**:
+//! un `Command` por etapa del pipe, los pipes cableados con descriptores
+//! reales. Control total del árbol de procesos (matar todo el pipe de
+//! un golpe). Es el modo preferido.
+//! - [`Exec::Shell`] — delega a un shell externo (`bash -c "<line>"`).
+//! Reservado para sintaxis que el modo directo aún no absorbe (globs,
+//! `$VAR`, redirecciones, `&&`). bash es **sólo un parser de sintaxis**,
+//! no el ejecutor por defecto.
+//!
+//! **Captura acotada.** [`CommandSpec::capture_limit`] topa los bytes en
+//! RAM; pasado el tope, o se **descarta** ([`RunEvent::Truncated`]) o se
+//! **vuelca a un archivo** si hay [`CommandSpec::spill_path`]
+//! ([`RunEvent::Spilled`]). En ambos casos el pipe se sigue drenando, así
+//! el proceso no se bloquea.
+//!
+//! **Reproceso.** [`CommandSpec::stdin_data`] alimenta un texto por la
+//! entrada estándar: reprocesa la salida capturada de un comando previo
+//! sin volver a correr el original.
+
+#![forbid(unsafe_code)]
+
+use std::fs::File;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::fd::AsFd;
+use std::os::unix::process::CommandExt;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
+use std::sync::{Arc, Mutex};
+use std::thread::JoinHandle;
+
+use nix::fcntl::{splice, SpliceFFlags};
+use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
+
+/// Una etapa del pipe en ejecución directa: un binario y sus argumentos
+/// ya resueltos (sin comillas, sin metacaracteres).
+#[derive(Debug, Clone)]
+pub struct StageSpec {
+ pub program: String,
+ pub args: Vec<String>,
+}
+
+/// Cómo ejecutar.
+#[derive(Debug, Clone)]
+pub enum Exec {
+ /// Vía un shell externo — `program -c "<line>"`.
+ Shell { line: String, program: String },
+ /// Directo — brahman lanza y conecta cada etapa.
+ Direct { stages: Vec<StageSpec> },
+ /// Bajo un PTY (cross-platform vía `portable-pty`). Pensado para
+ /// comandos **TUI fullscreen** (vim, htop, less, claude code) que
+ /// detectan `isatty()` y rehúsan funcionar con pipes. Emite
+ /// [`RunEvent::Bytes`] crudos en vez de `Stdout(String)` para que
+ /// el frontend pueda alimentar un emulador vt100 propio.
+ Pty {
+ program: String,
+ args: Vec<String>,
+ cols: u16,
+ rows: u16,
+ },
+}
+
+/// Qué ejecutar y con qué política de captura.
+#[derive(Debug, Clone)]
+pub struct CommandSpec {
+ pub exec: Exec,
+ pub cwd: String,
+ /// Tope de captura en bytes; `0` = sin límite.
+ pub capture_limit: usize,
+ /// Si está, la salida que excede el tope se vuelca a este archivo.
+ pub spill_path: Option<PathBuf>,
+ /// Texto a alimentar por stdin — para reprocesar una salida previa.
+ pub stdin_data: Option<String>,
+ /// Si `true`, en un pipe `Direct` se intercepta el stdout de **cada
+ /// etapa intermedia** (tee): además de alimentar a la siguiente, cada
+ /// línea se emite como [`RunEvent::StageStdout`]. Permite ver el stream
+ /// de cada etapa **en vivo**, sin re-ejecutar. Default `false` (sólo se
+ /// captura la salida de la última etapa, como siempre).
+ pub capture_stages: bool,
+}
+
+impl CommandSpec {
+ /// Ejecución vía `bash -c "<line>"`.
+ pub fn shell(line: impl Into<String>, cwd: impl Into<String>) -> Self {
+ Self {
+ exec: Exec::Shell { line: line.into(), program: "bash".into() },
+ cwd: cwd.into(),
+ capture_limit: 0,
+ spill_path: None,
+ stdin_data: None,
+ capture_stages: false,
+ }
+ }
+
+ /// Ejecución directa de un pipe de etapas.
+ pub fn direct(stages: Vec<StageSpec>, cwd: impl Into<String>) -> Self {
+ Self {
+ exec: Exec::Direct { stages },
+ cwd: cwd.into(),
+ capture_limit: 0,
+ spill_path: None,
+ stdin_data: None,
+ capture_stages: false,
+ }
+ }
+
+ /// Activa la captura por etapa (tee) en pipes directos (encadenable).
+ pub fn with_stage_capture(mut self) -> Self {
+ self.capture_stages = true;
+ self
+ }
+
+ /// Fija el tope de captura en bytes (encadenable).
+ pub fn with_limit(mut self, bytes: usize) -> Self {
+ self.capture_limit = bytes;
+ self
+ }
+
+ /// Vuelca la salida excedente a `path` en vez de descartarla.
+ pub fn with_spill(mut self, path: PathBuf) -> Self {
+ self.spill_path = Some(path);
+ self
+ }
+
+ /// Alimenta `data` por la entrada estándar del proceso (encadenable).
+ pub fn with_stdin(mut self, data: impl Into<String>) -> Self {
+ self.stdin_data = Some(data.into());
+ self
+ }
+}
+
+/// Un evento de la ejecución de un comando.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RunEvent {
+ /// Una línea de salida estándar (de la última etapa del pipe).
+ Stdout(String),
+ /// Una línea de stdout de una etapa **intermedia** del pipe (tee). Sólo
+ /// se emite con `CommandSpec::capture_stages`. `stage` = índice 0-based
+ /// de la etapa que la produjo. El front la muestra en el desplegable de
+ /// esa etapa, sin re-ejecutar nada.
+ StageStdout { stage: usize, line: String },
+ /// Una línea de salida de error.
+ Stderr(String),
+ /// Un chunk de bytes crudos del PTY (sólo bajo [`Exec::Pty`]). El
+ /// frontend debe alimentarlo a un emulador vt100 para renderizarlo;
+ /// puede traer secuencias de cursor movement, erase, OSC, etc.
+ Bytes(Vec<u8>),
+ /// La captura alcanzó su tope; lo que sigue se descarta.
+ Truncated,
+ /// La captura alcanzó su tope; el resto se vuelca al archivo dado.
+ Spilled(String),
+ /// El proceso terminó con este código de salida.
+ Exited(i32),
+ /// El proceso no pudo siquiera lanzarse.
+ Failed(String),
+}
+
+impl RunEvent {
+ /// `true` si el evento cierra la ejecución (`Exited` o `Failed`).
+ pub fn is_terminal(&self) -> bool {
+ matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_))
+ }
+}
+
+/// Asa de un comando en ejecución. El consumidor la conserva y drena sus
+/// eventos cuando le conviene.
+pub struct RunHandle {
+ rx: Receiver<RunEvent>,
+ finished: bool,
+ /// Los procesos, compartidos con el hilo coordinador para poder
+ /// matarlos — todas las etapas de un pipe directo. Vacío para
+ /// runs PTY.
+ children: Arc<Mutex<Vec<Child>>>,
+ /// PIDs vistos de runs PTY (los gestiona `portable-pty`; no son
+ /// `std::process::Child`). Se usan para enviarles señales con
+ /// `nix::sys::signal::kill`.
+ pty_pids: Arc<Mutex<Vec<u32>>>,
+ /// Canal opcional para escribir bytes en el stdin del proceso.
+ /// Sólo está cableado en modo PTY; en los otros modos los sends
+ /// no llegan a nadie (el receptor se cae al instante).
+ stdin_tx: Sender<Vec<u8>>,
+ /// PTY master vivo — sólo en modo `Exec::Pty`. Permite resize en
+ /// caliente cuando el panel cambia de tamaño. El coordinador del
+ /// PTY lo rellena tras crear el `pair` y lo conserva hasta el exit
+ /// del child (drop al final).
+ pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+}
+
+/// Asa "fría" de un `RunHandle` que **sólo** sirve para matar el comando.
+/// No comparte el lock principal de eventos, así que se puede usar desde
+/// otro hilo/task aún cuando el dueño del `RunHandle` esté bloqueado en
+/// `next_event()`. Cloneable.
+#[derive(Clone)]
+pub struct Killer {
+ children: Arc<Mutex<Vec<Child>>>,
+ pty_pids: Arc<Mutex<Vec<u32>>>,
+}
+
+impl Killer {
+ /// Manda SIGKILL a todas las etapas vivas del comando. No hace nada
+ /// si ya terminaron. La señal va a todo el **grupo de procesos** de
+ /// cada etapa — con `bash -c "sleep 30"`, esto mata bash *y* el
+ /// sleep hijo (mismo pgid; `spawn_shell` arma cada child con
+ /// `process_group(0)`).
+ pub fn kill(&self) {
+ self.signal(nix::sys::signal::Signal::SIGKILL);
+ // Fallback: además de la señal, llamamos a `kill()` del Child
+ // para que `wait()` cosechée el exit status sin colgarse (el
+ // `kill` de std manda SIGKILL al PID directo y no falla si el
+ // proceso ya murió).
+ if let Ok(mut guard) = self.children.lock() {
+ for c in guard.iter_mut() {
+ let _ = c.kill();
+ }
+ }
+ }
+
+ /// PIDs de las etapas que aún consider vivas el coordinador. Puede
+ /// estar vacío durante una micro-ventana entre `run()` y el spawn
+ /// real — no es un bug, sólo refleja la realidad del scheduling.
+ pub fn pids(&self) -> Vec<u32> {
+ let mut out = Vec::new();
+ if let Ok(g) = self.children.lock() {
+ out.extend(g.iter().map(|c| c.id()));
+ }
+ if let Ok(g) = self.pty_pids.lock() {
+ out.extend(g.iter().copied());
+ }
+ out
+ }
+
+ /// SIGTERM — el "kill educado" (Ctrl-C estándar). El proceso suele
+ /// limpiar antes de morir. Devuelve `true` si llegó a al menos una
+ /// etapa viva.
+ pub fn term(&self) -> bool {
+ self.signal(nix::sys::signal::Signal::SIGTERM)
+ }
+
+ /// SIGSTOP — el proceso pasa a estado "stopped"; no consume CPU y
+ /// no produce salida hasta recibir SIGCONT. Útil para "pausar" un
+ /// `tail -f` o un build ruidoso sin perderlo.
+ pub fn stop(&self) -> bool {
+ self.signal(nix::sys::signal::Signal::SIGSTOP)
+ }
+
+ /// SIGCONT — reanuda un proceso parado con [`Killer::stop`].
+ pub fn cont(&self) -> bool {
+ self.signal(nix::sys::signal::Signal::SIGCONT)
+ }
+
+ fn signal(&self, sig: nix::sys::signal::Signal) -> bool {
+ let pids = self.pids();
+ let mut delivered = false;
+ for pid in pids {
+ let target = nix::unistd::Pid::from_raw(pid as i32);
+ // `killpg` busca el grupo cuyo pgid coincide con `pid`:
+ // como cada child se lanzó con `process_group(0)`, el child
+ // es líder del grupo y `pgid == pid`. Matar el grupo abarca
+ // cualquier proceso que el child hubiese forkado.
+ if nix::sys::signal::killpg(target, sig).is_ok() {
+ delivered = true;
+ } else if nix::sys::signal::kill(target, sig).is_ok() {
+ // Fallback: si el child no es líder de grupo (PTY usa
+ // `portable-pty`, que no garantiza `process_group(0)`),
+ // mandamos al PID directo.
+ delivered = true;
+ }
+ }
+ delivered
+ }
+}
+
+impl RunHandle {
+ /// Mata todos los procesos del comando. No hace nada si ya terminaron.
+ pub fn kill(&self) {
+ if let Ok(mut guard) = self.children.lock() {
+ for c in guard.iter_mut() {
+ let _ = c.kill();
+ }
+ }
+ }
+
+ /// Asa cloneable que sólo permite matar el comando — útil para usar
+ /// `kill()` desde otra tarea sin tocar el lock que tiene el reader.
+ pub fn killer(&self) -> Killer {
+ Killer {
+ children: Arc::clone(&self.children),
+ pty_pids: Arc::clone(&self.pty_pids),
+ }
+ }
+
+ /// Escribe bytes en el stdin del proceso. Sólo tiene efecto bajo
+ /// [`Exec::Pty`] — el modo TUI cablea un writer thread que recibe
+ /// estos bytes y los reenvía al PTY master. En los otros modos, el
+ /// send se descarta (no hay listener) y devuelve `false`.
+ pub fn write_input(&self, bytes: Vec<u8>) -> bool {
+ self.stdin_tx.send(bytes).is_ok()
+ }
+
+ /// Reescala el PTY. Sólo aplica bajo [`Exec::Pty`]: lockea el
+ /// master vivo y llama a `MasterPty::resize`. Devuelve `false`
+ /// silenciosamente si no hay PTY (modos Shell/Direct), el master
+ /// no se publicó todavía, o el SO devuelve error.
+ pub fn resize(&self, rows: u16, cols: u16) -> bool {
+ let Ok(mut guard) = self.pty_master.lock() else {
+ return false;
+ };
+ let Some(master) = guard.as_mut() else {
+ return false;
+ };
+ master
+ .resize(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .is_ok()
+ }
+
+ /// Próximo evento, bloqueando hasta que llegue. `None` cuando el
+ /// proceso terminó (ya se emitió `Exited`/`Failed`) o el canal se
+ /// cerró. Pensado para puentes sync→async (el daemon lo usa para
+ /// re-emitir cada evento como un frame del protocolo).
+ pub fn next_event(&mut self) -> Option<RunEvent> {
+ if self.finished {
+ return None;
+ }
+ match self.rx.recv() {
+ Ok(ev) => {
+ if ev.is_terminal() {
+ self.finished = true;
+ }
+ Some(ev)
+ }
+ Err(_) => {
+ self.finished = true;
+ None
+ }
+ }
+ }
+
+ /// Drena todos los eventos disponibles ahora mismo, sin bloquear.
+ pub fn try_events(&mut self) -> Vec<RunEvent> {
+ let mut out = Vec::new();
+ loop {
+ match self.rx.try_recv() {
+ Ok(ev) => {
+ if ev.is_terminal() {
+ self.finished = true;
+ }
+ out.push(ev);
+ }
+ Err(TryRecvError::Empty) => break,
+ Err(TryRecvError::Disconnected) => {
+ self.finished = true;
+ break;
+ }
+ }
+ }
+ out
+ }
+
+ /// Bloquea hasta que el proceso termine y devuelve todos sus eventos.
+ pub fn wait_all(&mut self) -> Vec<RunEvent> {
+ let mut out = Vec::new();
+ while let Ok(ev) = self.rx.recv() {
+ let terminal = ev.is_terminal();
+ out.push(ev);
+ if terminal {
+ self.finished = true;
+ }
+ }
+ self.finished = true;
+ out
+ }
+
+ /// `true` si ya se observó el evento terminal.
+ pub fn is_finished(&self) -> bool {
+ self.finished
+ }
+
+ /// Asa "fría" para controlar el PTY (stdin + resize) desde otra
+ /// tarea/hilo sin tocar el lock de eventos — espejo de [`Killer`]
+ /// para el lado de entrada. La usa el daemon: el `RunHandle` se mueve
+ /// a un hilo-puente que bloquea en `next_event()`, mientras la tarea
+ /// async conserva este `PtyControl` para reenviar las teclas y los
+ /// resize que llegan del cliente remoto.
+ pub fn pty_control(&self) -> PtyControl {
+ PtyControl {
+ stdin_tx: self.stdin_tx.clone(),
+ pty_master: Arc::clone(&self.pty_master),
+ }
+ }
+}
+
+/// Asa cloneable de **control de entrada** de un run PTY: escribe stdin y
+/// reescala, sin compartir el lock de eventos del [`RunHandle`]. Igual que
+/// con [`RunHandle::write_input`]/[`RunHandle::resize`], las operaciones
+/// son no-op fuera de modo [`Exec::Pty`].
+#[derive(Clone)]
+pub struct PtyControl {
+ stdin_tx: Sender<Vec<u8>>,
+ pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+}
+
+impl PtyControl {
+ /// Escribe bytes en el stdin del PTY. Ver [`RunHandle::write_input`].
+ pub fn write_input(&self, bytes: Vec<u8>) -> bool {
+ self.stdin_tx.send(bytes).is_ok()
+ }
+
+ /// Reescala el PTY. Ver [`RunHandle::resize`].
+ pub fn resize(&self, rows: u16, cols: u16) -> bool {
+ let Ok(mut guard) = self.pty_master.lock() else {
+ return false;
+ };
+ let Some(master) = guard.as_mut() else {
+ return false;
+ };
+ master
+ .resize(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .is_ok()
+ }
+}
+
+/// Vuelca el resto de un pipe a un archivo con **copia cero** (`splice`):
+/// los bytes van de pipe a archivo sin pasar por espacio de usuario.
+fn spill_rest<R: Read + AsFd>(reader: &mut BufReader<R>, path: &Path, first_line: &str) {
+ let Ok(file) = File::create(path) else {
+ return;
+ };
+ let mut file = file;
+ // La línea que cruzó el tope y lo ya bufereado van primero…
+ let _ = file.write_all(first_line.as_bytes());
+ let buffered: Vec<u8> = reader.buffer().to_vec();
+ let _ = file.write_all(&buffered);
+ reader.consume(buffered.len());
+ // …y el resto del pipe se mueve con `splice`, kernel a kernel.
+ loop {
+ match splice(reader.get_ref(), None, &file, None, 1 << 20, SpliceFFlags::empty()) {
+ Ok(0) | Err(_) => break,
+ Ok(_) => {}
+ }
+ }
+}
+
+/// Lanza un hilo lector de un flujo, con captura acotada. Pasado el tope:
+/// si hay `spill`, el resto se vuelca al archivo con `splice` (copia
+/// cero); si no, se descarta. En ambos casos el pipe se **sigue
+/// drenando** — el proceso nunca se bloquea.
+#[allow(clippy::too_many_arguments)]
+fn spawn_reader<R: Read + AsFd + Send + 'static>(
+ stream: R,
+ tx: Sender<RunEvent>,
+ make: fn(String) -> RunEvent,
+ limit: usize,
+ counter: Arc<AtomicUsize>,
+ announced: Arc<AtomicBool>,
+ spill: Option<PathBuf>,
+) -> JoinHandle<()> {
+ std::thread::spawn(move || {
+ let mut reader = BufReader::new(stream);
+ let mut buf = String::new();
+ loop {
+ buf.clear();
+ let n = match reader.read_line(&mut buf) {
+ Ok(0) => break, // EOF
+ Ok(n) => n,
+ Err(_) => break,
+ };
+ let total = counter.fetch_add(n, Ordering::Relaxed) + n;
+ if limit != 0 && total > limit {
+ let first = !announced.swap(true, Ordering::Relaxed);
+ match &spill {
+ Some(path) => {
+ if first {
+ let _ = tx.send(RunEvent::Spilled(path.display().to_string()));
+ }
+ spill_rest(&mut reader, path, &buf);
+ break; // splice se llevó el resto
+ }
+ None => {
+ if first {
+ let _ = tx.send(RunEvent::Truncated);
+ }
+ continue; // descarta, pero sigue drenando
+ }
+ }
+ }
+ let line = buf.trim_end_matches(['\n', '\r']).to_string();
+ if tx.send(make(line)).is_err() {
+ break;
+ }
+ }
+ })
+}
+
+/// Resultado de lanzar los procesos: lo que el coordinador necesita.
+struct Spawned {
+ children: Vec<Child>,
+ stdin: Option<std::process::ChildStdin>,
+ stdout: Option<std::process::ChildStdout>,
+ stderrs: Vec<std::process::ChildStderr>,
+ /// Etapas intermedias a interceptar (solo con `capture_stages`). Vacío
+ /// en el caso normal.
+ stage_tees: Vec<StageTee>,
+}
+
+/// Una etapa intermedia cuyo stdout interceptamos: el coordinador lee
+/// `stdout`, reenvía los bytes a `sink` (el stdin de la etapa siguiente) y
+/// emite cada línea como [`RunEvent::StageStdout`].
+struct StageTee {
+ stage: usize,
+ stdout: std::process::ChildStdout,
+ sink: std::fs::File,
+}
+
+/// Lanza un único proceso shell (`program -c "<line>"`).
+fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result<Spawned> {
+ let mut child = Command::new(program)
+ .arg("-c")
+ .arg(line)
+ .current_dir(cwd)
+ .stdin(if want_stdin { Stdio::piped() } else { Stdio::null() })
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ // Nuevo grupo de procesos: con `bash -c "sleep 30"` el bash se
+ // forka a un sleep hijo; matar al bash sólo no alcanza al sleep.
+ // Con el grupo, `killpg(pid, SIG)` derriba a todo el subárbol.
+ .process_group(0)
+ .spawn()?;
+ let stdin = child.stdin.take();
+ let stdout = child.stdout.take();
+ let stderrs = child.stderr.take().into_iter().collect();
+ Ok(Spawned { children: vec![child], stdin, stdout, stderrs, stage_tees: vec![] })
+}
+
+/// Lanza un pipe de etapas conectándolas con descriptores reales.
+fn spawn_direct(
+ stages: &[StageSpec],
+ cwd: &str,
+ want_stdin: bool,
+ capture_stages: bool,
+) -> std::io::Result<Spawned> {
+ if stages.is_empty() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "pipe vacío",
+ ));
+ }
+ let n = stages.len();
+ let mut children: Vec<Child> = Vec::with_capacity(n);
+ let mut stage_tees: Vec<StageTee> = Vec::new();
+ // Qué alimenta el stdin de la etapa actual (i>0): el stdout de la
+ // anterior (directo) o el read-end de un pipe de tee (capturado).
+ let mut next_stdin: Option<Stdio> = None;
+
+ for (i, st) in stages.iter().enumerate() {
+ let mut cmd = Command::new(&st.program);
+ cmd.args(&st.args)
+ .current_dir(cwd)
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ if i == 0 {
+ cmd.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() });
+ // Primera etapa abre su propio grupo de procesos; las demás
+ // se enganchan en el mismo (process_group(0) hereda el pgid
+ // del padre — pero el padre somos nosotros, no la etapa 0).
+ // En la práctica `setpgid` del kernel respeta sólo lo que
+ // pedimos: las etapas 1.. quedan en pgid de la 0 si las
+ // forkamos con CLONE_PARENT_SETTID; con Command no tenemos
+ // ese control fino, así que cada stage queda en su propio
+ // pgid. El Killer cubre todas las etapas igual porque
+ // mantiene los PIDs/Childs por separado.
+ cmd.process_group(0);
+ } else {
+ // La etapa anterior alimenta a ésta (stdout directo o tee).
+ cmd.stdin(next_stdin.take().expect("stdin de etapa previa"));
+ cmd.process_group(0);
+ }
+ match cmd.spawn() {
+ Ok(mut child) => {
+ if i + 1 < n {
+ let stdout = child.stdout.take().expect("stdout de etapa intermedia");
+ if capture_stages {
+ // Tee: pipe propio. La etapa siguiente lee del read-end;
+ // un hilo del coordinador reenvía stage[i].stdout al
+ // write-end y captura cada línea como StageStdout.
+ // `O_CLOEXEC`: estos fds NO deben heredarse a las etapas
+ // que spawneamos después. Si una etapa heredara el
+ // write-end del pipe de tee, su lado lector nunca vería
+ // EOF y se colgaría esperando más entrada (deadlock). El
+ // dup2 a fd 0 de la etapa siguiente lo hace std (limpia
+ // CLOEXEC en el fd 0 resultante), así que su stdin queda bien.
+ let (rd, wr) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
+ .map_err(|e| std::io::Error::other(format!("pipe tee: {e}")))?;
+ next_stdin = Some(Stdio::from(std::fs::File::from(rd)));
+ stage_tees.push(StageTee {
+ stage: i,
+ stdout,
+ sink: std::fs::File::from(wr),
+ });
+ } else {
+ next_stdin = Some(Stdio::from(stdout));
+ }
+ }
+ children.push(child);
+ }
+ Err(e) => {
+ // Si una etapa no arranca, se matan las ya lanzadas.
+ for mut c in children {
+ let _ = c.kill();
+ }
+ return Err(std::io::Error::new(
+ e.kind(),
+ format!("{}: {e}", st.program),
+ ));
+ }
+ }
+ }
+
+ let stdin = children.first_mut().and_then(|c| c.stdin.take());
+ let stdout = children.last_mut().and_then(|c| c.stdout.take());
+ let stderrs = children.iter_mut().filter_map(|c| c.stderr.take()).collect();
+ Ok(Spawned { children, stdin, stdout, stderrs, stage_tees })
+}
+
+/// Hilo de tee de una etapa intermedia: lee su stdout, lo reenvía a `sink`
+/// (stdin de la etapa siguiente) y emite cada línea como `StageStdout`. Al
+/// EOF cierra `sink` (drop) para que la etapa siguiente vea fin de entrada.
+fn tee_pump(
+ stage: usize,
+ mut stdout: std::process::ChildStdout,
+ mut sink: std::fs::File,
+ tx: Sender<RunEvent>,
+) {
+ let mut buf = [0u8; 8192];
+ let mut line: Vec<u8> = Vec::new();
+ loop {
+ let n = match stdout.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => n,
+ };
+ let chunk = &buf[..n];
+ // Reenviar a la etapa siguiente (si murió, igual seguimos drenando
+ // para no bloquear a la etapa actual).
+ let _ = sink.write_all(chunk);
+ // Capturar por línea para el desplegable de la etapa.
+ for &b in chunk {
+ if b == b'\n' {
+ let s = String::from_utf8_lossy(&line).into_owned();
+ let _ = tx.send(RunEvent::StageStdout { stage, line: s });
+ line.clear();
+ } else {
+ line.push(b);
+ }
+ }
+ }
+ if !line.is_empty() {
+ let s = String::from_utf8_lossy(&line).into_owned();
+ let _ = tx.send(RunEvent::StageStdout { stage, line: s });
+ }
+}
+
+/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la
+/// salida. La función vuelve de inmediato: el proceso corre en hilos.
+pub fn run(spec: &CommandSpec) -> RunHandle {
+ let (tx, rx) = mpsc::channel();
+ let (stdin_tx, stdin_rx) = mpsc::channel::<Vec<u8>>();
+ let spec = spec.clone();
+ let cell: Arc<Mutex<Vec<Child>>> = Arc::new(Mutex::new(Vec::new()));
+ let pty_pids: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
+ let pty_master: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>> =
+ Arc::new(Mutex::new(None));
+ let cell_thread = Arc::clone(&cell);
+ let pty_pids_thread = Arc::clone(&pty_pids);
+ let pty_master_thread = Arc::clone(&pty_master);
+
+ // Modo PTY: ruta separada — el proceso corre bajo un pseudo-terminal
+ // (cross-platform via `portable-pty`), los bytes crudos se emiten
+ // como [`RunEvent::Bytes`] para que el frontend los pase por su
+ // emulador vt100, y el frontend escribe en stdin con
+ // [`RunHandle::write_input`].
+ if let Exec::Pty { program, args, cols, rows } = &spec.exec {
+ let program = program.clone();
+ let args = args.clone();
+ let cols = *cols;
+ let rows = *rows;
+ let cwd = spec.cwd.clone();
+ std::thread::spawn(move || {
+ spawn_pty_thread(
+ &program,
+ &args,
+ &cwd,
+ cols,
+ rows,
+ tx,
+ stdin_rx,
+ pty_pids_thread,
+ pty_master_thread,
+ );
+ });
+ return RunHandle {
+ rx,
+ finished: false,
+ children: cell,
+ pty_pids,
+ stdin_tx,
+ pty_master,
+ };
+ }
+
+ std::thread::spawn(move || {
+ let want_stdin = spec.stdin_data.is_some();
+ let spawned = match &spec.exec {
+ Exec::Shell { line, program } => {
+ spawn_shell(line, program, &spec.cwd, want_stdin)
+ }
+ Exec::Direct { stages } => {
+ spawn_direct(stages, &spec.cwd, want_stdin, spec.capture_stages)
+ }
+ Exec::Pty { .. } => unreachable!("Pty se maneja antes"),
+ };
+ let Spawned { children, stdin, stdout, stderrs, stage_tees } = match spawned {
+ Ok(s) => s,
+ Err(e) => {
+ let _ = tx.send(RunEvent::Failed(e.to_string()));
+ return;
+ }
+ };
+
+ // Alimenta stdin (reproceso) en su propio hilo.
+ if let (Some(data), Some(mut sink)) = (spec.stdin_data.clone(), stdin) {
+ std::thread::spawn(move || {
+ let _ = sink.write_all(data.as_bytes());
+ });
+ }
+
+ // Comparte los procesos para que `kill` los alcance.
+ if let Ok(mut g) = cell_thread.lock() {
+ *g = children;
+ }
+
+ // Tee de etapas intermedias (solo con capture_stages): un hilo por
+ // etapa que reenvía su stdout a la siguiente y emite StageStdout.
+ // Guardamos los handles para joinearlos antes de `Exited` (si no, un
+ // StageStdout tardío se perdería tras cerrar el run).
+ let mut tee_handles: Vec<JoinHandle<()>> = Vec::new();
+ for tee in stage_tees {
+ let txc = tx.clone();
+ tee_handles
+ .push(std::thread::spawn(move || tee_pump(tee.stage, tee.stdout, tee.sink, txc)));
+ }
+
+ // Captura acotada: contador y aviso compartidos por todos los
+ // lectores. El volcado a archivo se aplica sólo a stdout (el
+ // contenido principal); stderr excedente se descarta.
+ let counter = Arc::new(AtomicUsize::new(0));
+ let announced = Arc::new(AtomicBool::new(false));
+ let limit = spec.capture_limit;
+
+ let mut readers: Vec<JoinHandle<()>> = Vec::new();
+ if let Some(s) = stdout {
+ readers.push(spawn_reader(
+ s,
+ tx.clone(),
+ RunEvent::Stdout,
+ limit,
+ Arc::clone(&counter),
+ Arc::clone(&announced),
+ spec.spill_path.clone(),
+ ));
+ }
+ for s in stderrs {
+ readers.push(spawn_reader(
+ s,
+ tx.clone(),
+ RunEvent::Stderr,
+ limit,
+ Arc::clone(&counter),
+ Arc::clone(&announced),
+ None,
+ ));
+ }
+ for h in readers {
+ let _ = h.join();
+ }
+ // Joinear los tees garantiza que todos los StageStdout salgan antes
+ // del Exited (drenaje completo de las etapas intermedias).
+ for h in tee_handles {
+ let _ = h.join();
+ }
+
+ // Cosecha todas las etapas; el código de salida es el de la última.
+ let code = {
+ let mut g = cell_thread.lock().expect("children lock");
+ let mut last = -1;
+ for c in g.iter_mut() {
+ last = c.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
+ }
+ last
+ };
+ let _ = tx.send(RunEvent::Exited(code));
+ });
+
+ RunHandle {
+ rx,
+ finished: false,
+ children: cell,
+ pty_pids,
+ stdin_tx,
+ pty_master,
+ }
+}
+
+/// Coordinador del modo PTY: aloja un PTY de tamaño `cols`×`rows`,
+/// lanza el comando bajo él, y mantiene tres flujos:
+///
+/// - Reader thread: lee chunks de hasta 4 KiB del master del PTY y los
+/// emite como `RunEvent::Bytes`. Termina cuando el child cierra el
+/// slave (EOF).
+/// - Writer thread: bloquea en `stdin_rx` y reenvía bytes al master
+/// del PTY (lo que el frontend manda como input crudo).
+/// - Esta función espera al child y emite `RunEvent::Exited(code)`.
+fn spawn_pty_thread(
+ program: &str,
+ args: &[String],
+ cwd: &str,
+ cols: u16,
+ rows: u16,
+ tx: Sender<RunEvent>,
+ stdin_rx: Receiver<Vec<u8>>,
+ pty_pids: Arc<Mutex<Vec<u32>>>,
+ pty_master_slot: Arc<Mutex<Option<Box<dyn MasterPty + Send>>>>,
+) {
+ let pty_system = native_pty_system();
+ let pair = match pty_system.openpty(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ }) {
+ Ok(p) => p,
+ Err(e) => {
+ let _ = tx.send(RunEvent::Failed(format!("openpty: {e}")));
+ return;
+ }
+ };
+ let mut cmd = CommandBuilder::new(program);
+ for a in args {
+ cmd.arg(a);
+ }
+ cmd.cwd(cwd);
+ // Heurística estándar: TUIs leen `TERM` para decidir capacidad de
+ // colores y movimiento. xterm-256color es el lcm más amplio.
+ cmd.env("TERM", "xterm-256color");
+ let mut child = match pair.slave.spawn_command(cmd) {
+ Ok(c) => c,
+ Err(e) => {
+ let _ = tx.send(RunEvent::Failed(format!("spawn: {e}")));
+ return;
+ }
+ };
+ // El slave fd no se necesita más en el padre; cerrarlo aquí evita
+ // que el child quede sin EOF cuando cierra su lado.
+ drop(pair.slave);
+
+ if let Some(pid) = child.process_id() {
+ if let Ok(mut g) = pty_pids.lock() {
+ g.push(pid);
+ }
+ }
+
+ let mut reader = match pair.master.try_clone_reader() {
+ Ok(r) => r,
+ Err(e) => {
+ let _ = tx.send(RunEvent::Failed(format!("try_clone_reader: {e}")));
+ return;
+ }
+ };
+ let mut writer = match pair.master.take_writer() {
+ Ok(w) => w,
+ Err(e) => {
+ let _ = tx.send(RunEvent::Failed(format!("take_writer: {e}")));
+ return;
+ }
+ };
+
+ // Publica el master vivo: el `RunHandle` lo lockea para resize en
+ // caliente. `take_writer`/`try_clone_reader` ya retornaron handles
+ // independientes; el master se mantiene como dueño del PTY.
+ if let Ok(mut slot) = pty_master_slot.lock() {
+ *slot = Some(pair.master);
+ }
+
+ let tx_reader = tx.clone();
+ let reader_thread = std::thread::spawn(move || {
+ let mut buf = [0u8; 4096];
+ loop {
+ match reader.read(&mut buf) {
+ Ok(0) => break, // child cerró el slave
+ Ok(n) => {
+ if tx_reader.send(RunEvent::Bytes(buf[..n].to_vec())).is_err() {
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ let writer_thread = std::thread::spawn(move || {
+ while let Ok(bytes) = stdin_rx.recv() {
+ if writer.write_all(&bytes).is_err() {
+ break;
+ }
+ let _ = writer.flush();
+ }
+ });
+
+ let code = match child.wait() {
+ Ok(s) => s.exit_code() as i32,
+ Err(_) => -1,
+ };
+ // El master se dropea ahora → reader ve EOF y termina. Esperamos al
+ // reader para que no se pierdan bytes finales.
+ if let Ok(mut slot) = pty_master_slot.lock() {
+ *slot = None;
+ }
+ let _ = reader_thread.join();
+ // El writer thread sale solo cuando el stdin_tx se dropee del lado
+ // del frontend; no lo joineamos sincrónicamente.
+ drop(writer_thread);
+ let _ = tx.send(RunEvent::Exited(code));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Ejecución directa de un único programa.
+ fn direct(program: &str, args: &[&str]) -> CommandSpec {
+ CommandSpec::direct(
+ vec![StageSpec {
+ program: program.into(),
+ args: args.iter().map(|s| s.to_string()).collect(),
+ }],
+ ".",
+ )
+ }
+
+ /// Pipe directo de varias etapas.
+ fn pipe(stages: &[(&str, &[&str])]) -> CommandSpec {
+ CommandSpec::direct(
+ stages
+ .iter()
+ .map(|(p, a)| StageSpec {
+ program: p.to_string(),
+ args: a.iter().map(|s| s.to_string()).collect(),
+ })
+ .collect(),
+ ".",
+ )
+ }
+
+ fn stdout_of(events: Vec<RunEvent>) -> Vec<String> {
+ events
+ .into_iter()
+ .filter_map(|e| match e {
+ RunEvent::Stdout(l) => Some(l),
+ _ => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn direct_runs_a_single_program() {
+ let mut h = run(&direct("echo", &["hola", "mundo"]));
+ let events = h.wait_all();
+ assert!(events.contains(&RunEvent::Stdout("hola mundo".into())));
+ assert!(events.contains(&RunEvent::Exited(0)));
+ }
+
+ #[test]
+ fn direct_wires_a_pipeline_itself() {
+ // printf … | sort — brahman conecta los procesos, sin shell.
+ let mut h = run(&pipe(&[
+ ("printf", &["b\\na\\nc\\n"]),
+ ("sort", &[]),
+ ]));
+ assert_eq!(stdout_of(h.wait_all()), vec!["a", "b", "c"]);
+ }
+
+ #[test]
+ fn direct_three_stage_pipeline() {
+ let mut h = run(&pipe(&[
+ ("printf", &["3\\n1\\n2\\n1\\n"]),
+ ("sort", &[]),
+ ("uniq", &[]),
+ ]));
+ assert_eq!(stdout_of(h.wait_all()), vec!["1", "2", "3"]);
+ }
+
+ fn stage_lines(events: &[RunEvent], stage: usize) -> Vec<String> {
+ events
+ .iter()
+ .filter_map(|e| match e {
+ RunEvent::StageStdout { stage: s, line } if *s == stage => Some(line.clone()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ // El supuesto "deadlock por write-end huérfano" era en realidad el error de
+ // build: se pidió la feature `fcntl` de nix (inexistente en 0.29 — `OFlag`
+ // vive bajo `fs`, ya habilitada), así que el crate ni compilaba y el fallo
+ // se confundió con un cuelgue. Con pipe2(O_CLOEXEC) ningún hijo hereda el
+ // write-end del tee y la etapa siguiente ve EOF en cuanto la anterior cierra
+ // su stdout; verificado estable (5/5).
+ #[test]
+ fn capture_stages_intercepts_each_stage_stdout() {
+ // printf "hello" | tr a-z A-Z | rev → etapa0="hello", etapa1="HELLO",
+ // salida final (rev) = "OLLEH". El tee captura las intermedias EN VIVO
+ // sin re-ejecutar, que es lo que el desplegable necesita por etapa.
+ let spec = pipe(&[
+ ("printf", &["hello\\n"]),
+ ("tr", &["a-z", "A-Z"]),
+ ("rev", &[]),
+ ])
+ .with_stage_capture();
+ let events = run(&spec).wait_all();
+
+ assert_eq!(stage_lines(&events, 0), vec!["hello"], "etapa 0 (printf)");
+ assert_eq!(stage_lines(&events, 1), vec!["HELLO"], "etapa 1 (tr)");
+ // La última etapa sigue saliendo por Stdout normal.
+ assert_eq!(stdout_of(events), vec!["OLLEH"]);
+ }
+
+ #[test]
+ fn without_capture_stages_there_are_no_stage_events() {
+ // El comportamiento por defecto no cambia: sólo la salida final.
+ let events = run(&pipe(&[("printf", &["x\\n"]), ("cat", &[])])).wait_all();
+ assert!(
+ !events
+ .iter()
+ .any(|e| matches!(e, RunEvent::StageStdout { .. })),
+ "sin with_stage_capture no debe haber StageStdout"
+ );
+ assert_eq!(stdout_of(events), vec!["x"]);
+ }
+
+ #[test]
+ fn direct_nonzero_exit_is_the_last_stage() {
+ let mut h = run(&direct("false", &[]));
+ assert!(h.wait_all().contains(&RunEvent::Exited(1)));
+ }
+
+ #[test]
+ fn direct_missing_program_fails_gracefully() {
+ let mut h = run(&direct("no-existe-binario-xyz", &[]));
+ let events = h.wait_all();
+ assert!(matches!(events.first(), Some(RunEvent::Failed(_))));
+ }
+
+ #[test]
+ fn shell_mode_still_works_for_complex_syntax() {
+ let mut h = run(&CommandSpec {
+ exec: Exec::Shell { line: "echo $((2 + 3))".into(), program: "sh".into() },
+ ..CommandSpec::shell("", ".")
+ });
+ assert!(h.wait_all().contains(&RunEvent::Stdout("5".into())));
+ }
+
+ #[test]
+ fn capture_limit_truncates_but_process_finishes() {
+ let mut h = run(&direct("seq", &["1", "20000"]).with_limit(400));
+ let events = h.wait_all();
+ assert!(events.contains(&RunEvent::Truncated));
+ assert!(events.contains(&RunEvent::Exited(0)));
+ assert!(stdout_of(events).len() < 20000);
+ }
+
+ #[test]
+ fn spill_writes_overflow_to_a_file() {
+ let path = std::env::temp_dir()
+ .join(format!("shuma-exec-spill-{}.log", std::process::id()));
+ let _ = std::fs::remove_file(&path);
+ let mut h = run(&direct("seq", &["1", "5000"])
+ .with_limit(200)
+ .with_spill(path.clone()));
+ let events = h.wait_all();
+ assert!(events.iter().any(|e| matches!(e, RunEvent::Spilled(_))));
+ assert!(events.contains(&RunEvent::Exited(0)));
+ // El archivo de volcado existe y tiene contenido.
+ let spilled = std::fs::read_to_string(&path).unwrap_or_default();
+ assert!(spilled.contains("5000"), "la cola se volcó al archivo");
+ let _ = std::fs::remove_file(&path);
+ }
+
+ #[test]
+ fn stdin_data_reprocessed_by_a_filter() {
+ let mut h = run(&direct("grep", &["beta"]).with_stdin("alfa\nbeta\nbetabel\ngamma"));
+ assert_eq!(stdout_of(h.wait_all()), vec!["beta", "betabel"]);
+ }
+
+ #[test]
+ fn kill_stops_a_long_running_pipeline() {
+ let mut h = run(&pipe(&[("sleep", &["30"]), ("cat", &[])]));
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ h.kill();
+ let events = h.wait_all();
+ assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false));
+ }
+
+ #[test]
+ fn terminal_event_detection() {
+ assert!(RunEvent::Exited(0).is_terminal());
+ assert!(!RunEvent::Truncated.is_terminal());
+ assert!(!RunEvent::Spilled("x".into()).is_terminal());
+ }
+
+ #[test]
+ fn pty_run_emits_bytes_for_a_tty_aware_command() {
+ // `tty` imprime el path del terminal cuando se le da uno, o
+ // "not a tty" cuando se le pipea. Bajo PTY debe imprimir un
+ // path con `/dev/pts/` o `/dev/ptmx` (depende del SO).
+ let spec = CommandSpec {
+ exec: Exec::Pty {
+ program: "tty".into(),
+ args: vec![],
+ cols: 80,
+ rows: 24,
+ },
+ cwd: ".".into(),
+ capture_limit: 0,
+ spill_path: None,
+ stdin_data: None,
+ capture_stages: false,
+ };
+ let mut h = run(&spec);
+ let mut bytes_seen = Vec::<u8>::new();
+ let mut exit = -999;
+ while let Some(ev) = h.next_event() {
+ match ev {
+ RunEvent::Bytes(b) => bytes_seen.extend(b),
+ RunEvent::Exited(c) => exit = c,
+ _ => {}
+ }
+ }
+ assert_eq!(exit, 0, "tty exit 0 bajo PTY (bytes='{}')",
+ String::from_utf8_lossy(&bytes_seen));
+ let out = String::from_utf8_lossy(&bytes_seen);
+ // `tty` emite el path del slave + \r\n (los PTY añaden \r).
+ assert!(
+ out.contains("/dev/pts/") || out.contains("/dev/ptmx") || out.contains("/dev/tty"),
+ "tty output inesperado: {out:?}"
+ );
+ }
+
+ #[test]
+ fn pty_write_input_reaches_the_child_stdin() {
+ // `cat` bajo PTY refleja lo que le escribamos por stdin (en
+ // modo PTY, cada caracter va con echo encendido por defecto).
+ // Le mandamos "hola\n" y esperamos verlo en los bytes de
+ // salida. Después cerramos el stdin (drop del Sender) y
+ // mandamos Ctrl-D (0x04) para que cat termine.
+ let spec = CommandSpec {
+ exec: Exec::Pty {
+ program: "cat".into(),
+ args: vec![],
+ cols: 80,
+ rows: 24,
+ },
+ cwd: ".".into(),
+ capture_limit: 0,
+ spill_path: None,
+ stdin_data: None,
+ capture_stages: false,
+ };
+ let mut h = run(&spec);
+ // Cat necesita un instante para que su slave arranque y abra stdin.
+ std::thread::sleep(std::time::Duration::from_millis(150));
+ assert!(h.write_input(b"hola\n".to_vec()));
+ // Ctrl-D para cerrar EOF en modo cooked.
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ assert!(h.write_input(b"\x04".to_vec()));
+ let mut bytes_seen = Vec::<u8>::new();
+ let started = std::time::Instant::now();
+ while let Some(ev) = h.next_event() {
+ if started.elapsed() > std::time::Duration::from_secs(5) {
+ panic!("cat no terminó en 5s tras Ctrl-D");
+ }
+ if let RunEvent::Bytes(b) = ev {
+ bytes_seen.extend(b);
+ }
+ }
+ let out = String::from_utf8_lossy(&bytes_seen);
+ assert!(out.contains("hola"), "cat no echó 'hola': {out:?}");
+ }
+
+ #[test]
+ fn pty_resize_changes_dimensions_in_running_child() {
+ // Lanzamos `bash` bajo PTY a 80×24, esperamos que el slave
+ // tenga las dims iniciales, hacemos resize, y verificamos que
+ // el child ve el nuevo tamaño tras la señal SIGWINCH.
+ let spec = CommandSpec {
+ exec: Exec::Pty {
+ program: "bash".into(),
+ args: vec!["-c".into(), "stty size; sleep 0.3; stty size".into()],
+ cols: 80,
+ rows: 24,
+ },
+ cwd: ".".into(),
+ capture_limit: 0,
+ spill_path: None,
+ stdin_data: None,
+ capture_stages: false,
+ };
+ let mut h = run(&spec);
+ // Esperar a que el master se publique (race con el coordinador).
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ assert!(h.resize(40, 132), "resize debería aplicarse");
+ let mut bytes = Vec::<u8>::new();
+ while let Some(ev) = h.next_event() {
+ if let RunEvent::Bytes(b) = ev {
+ bytes.extend(b);
+ }
+ }
+ let out = String::from_utf8_lossy(&bytes);
+ // El primer `stty size` muestra 24 80; el segundo debe mostrar
+ // las dimensiones nuevas tras el resize (40 132).
+ assert!(
+ out.contains("40 132"),
+ "stty size tras resize no muestra 40 132: {out:?}"
+ );
+ }
+
+ #[test]
+ fn killer_can_stop_and_continue_a_process() {
+ // `sleep 30` se para con SIGSTOP, se reanuda con SIGCONT y
+ // luego se mata con SIGTERM. El test acaba en <1s aunque el
+ // sleep nominal sea de 30s.
+ let h = run(&direct("sleep", &["30"]));
+ let killer = h.killer();
+ // Esperar a que aparezca el PID (el coordinador rellena el Vec
+ // tras el spawn — micro-delay).
+ let mut tries = 0;
+ while killer.pids().is_empty() && tries < 100 {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ tries += 1;
+ }
+ assert!(!killer.pids().is_empty(), "pid no apareció");
+ assert!(killer.stop(), "SIGSTOP no llegó");
+ assert!(killer.cont(), "SIGCONT no llegó");
+ assert!(killer.term(), "SIGTERM no llegó");
+ // El test no se cuelga gracias al term().
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-history/Cargo.toml b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml
new file mode 100644
index 0000000..d4dc8e8
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "shuma-history"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — historial durable de comandos (JSONL append-only) con navegación y búsqueda fuzzy."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+directories = { workspace = true }
+nucleo-matcher = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-history/LEEME.md b/02_ruway/shuma/sandbox/shuma-history/LEEME.md
new file mode 100644
index 0000000..6c4df34
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-history
+
+> History con búsqueda fuzzy de [shuma](../../README.md).
+
+`fzf`-style por defecto + per-directory history opcional. Privacy mode (`shopt -s nohist` equivalente).
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-history/README.md b/02_ruway/shuma/sandbox/shuma-history/README.md
new file mode 100644
index 0000000..7d876f1
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/README.md
@@ -0,0 +1,9 @@
+# shuma-history
+
+> Fuzzy-search history of [shuma](../../README.md).
+
+`fzf`-style by default + optional per-directory history. Privacy mode (`shopt -s nohist` equivalent).
+
+## Deps
+
+- [`shuma-core`](../shuma-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-history/src/lib.rs b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs
new file mode 100644
index 0000000..90e5b7f
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs
@@ -0,0 +1,430 @@
+//! `shuma-history` — historial **durable** de comandos.
+//!
+//! Independiente del historial vivo de [`shuma_session::WorkSession`]
+//! (que guarda salida completa para la vista en curso): aquí sólo se
+//! persisten *líneas* con su contexto mínimo, en un fichero JSONL
+//! append‑only fácil de leer, rotar y compartir entre sesiones.
+//!
+//! Diseño:
+//!
+//! - **JSONL** (`{"line":...,"cwd":...,"exit":...,"started":...,"duration_ms":...}`).
+//! Una entrada por línea, append‑only — robusto frente a kills/crashes.
+//! - **Sin lock global**: las escrituras usan `OpenOptions::append`, que
+//! en Linux son atómicas hasta `PIPE_BUF` (4096 B). Las líneas largas
+//! no se entrelazan en la práctica con los tamaños típicos de un
+//! comando.
+//! - **Búsqueda fuzzy** con [`nucleo_matcher`] — mismo matcher que
+//! helix‑editor: rápido, Unicode‑correct, ranking estable.
+//! - **Dedup**: política configurable; por defecto se ignora el
+//! duplicado *consecutivo* (estilo bash `HISTCONTROL=ignoredups`).
+
+#![forbid(unsafe_code)]
+
+use std::fs::{File, OpenOptions};
+use std::io::{self, BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Una entrada del historial durable — la línea y su contexto mínimo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Entry {
+ /// La línea de comandos tal como se ejecutó.
+ pub line: String,
+ /// Directorio en que se lanzó.
+ pub cwd: String,
+ /// Código de salida (`None` si nunca terminó —p. ej. crash del shell).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub exit: Option<i32>,
+ /// Segundo Unix en que arrancó.
+ pub started: u64,
+ /// Duración en milisegundos (`None` si no terminó).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub duration_ms: Option<u64>,
+}
+
+impl Entry {
+ /// Construye una entrada nueva con la línea y el cwd; resto a vacío.
+ pub fn new(line: impl Into<String>, cwd: impl Into<String>, started: u64) -> Self {
+ Self {
+ line: line.into(),
+ cwd: cwd.into(),
+ exit: None,
+ started,
+ duration_ms: None,
+ }
+ }
+}
+
+/// Política de deduplicación al añadir entradas nuevas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DedupPolicy {
+ /// Guardar todas las entradas sin deduplicar.
+ None,
+ /// Saltar el comando si es idéntico al último guardado (`ignoredups`).
+ IgnoreConsecutive,
+ /// Borrar duplicados previos cuando se vuelve a ver el mismo comando.
+ EraseDups,
+}
+
+impl Default for DedupPolicy {
+ fn default() -> Self {
+ Self::IgnoreConsecutive
+ }
+}
+
+/// Dirección de navegación por el historial.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Nav {
+ /// Hacia atrás en el tiempo (flecha arriba).
+ Older,
+ /// Hacia adelante en el tiempo (flecha abajo).
+ Newer,
+}
+
+/// Historial durable cargado en memoria, con su fichero de respaldo.
+pub struct History {
+ path: PathBuf,
+ entries: Vec<Entry>,
+ dedup: DedupPolicy,
+ /// Cuántas líneas inválidas se descartaron al cargar.
+ skipped: usize,
+}
+
+impl History {
+ /// Ruta por defecto: `$XDG_DATA_HOME/shuma/history.jsonl` (o el
+ /// equivalente Linux/macOS/Windows según [`directories`]). `None` si
+ /// el SO no expone un directorio de datos para el usuario.
+ pub fn default_path() -> Option<PathBuf> {
+ directories::ProjectDirs::from("", "", "shuma")
+ .map(|d| d.data_dir().join("history.jsonl"))
+ }
+
+ /// Abre (o crea) el historial en `path`. Carga todas las entradas
+ /// existentes. Las líneas inválidas se cuentan en `skipped` pero no
+ /// abortan la apertura — el shell debe poder arrancar incluso con
+ /// historial parcialmente corrupto.
+ pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
+ let path = path.into();
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let mut entries = Vec::new();
+ let mut skipped = 0usize;
+ if path.exists() {
+ let f = File::open(&path)?;
+ for line in BufReader::new(f).lines() {
+ let line = line?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ match serde_json::from_str::<Entry>(&line) {
+ Ok(e) => entries.push(e),
+ Err(_) => skipped += 1,
+ }
+ }
+ }
+ Ok(Self { path, entries, dedup: DedupPolicy::default(), skipped })
+ }
+
+ /// Política de deduplicación activa.
+ pub fn dedup(&self) -> DedupPolicy {
+ self.dedup
+ }
+
+ /// Cambia la política de deduplicación.
+ pub fn set_dedup(&mut self, policy: DedupPolicy) {
+ self.dedup = policy;
+ }
+
+ /// Líneas inválidas descartadas en la última apertura.
+ pub fn skipped_on_load(&self) -> usize {
+ self.skipped
+ }
+
+ /// Cantidad de entradas en memoria.
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ /// `true` si no hay entradas.
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ /// Ruta del fichero de respaldo.
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ /// Entradas en orden cronológico (más antigua primero).
+ pub fn entries(&self) -> &[Entry] {
+ &self.entries
+ }
+
+ /// Última entrada.
+ pub fn last(&self) -> Option<&Entry> {
+ self.entries.last()
+ }
+
+ /// Añade una entrada — aplica la política de dedup y persiste a
+ /// disco. Devuelve `true` si efectivamente se añadió (no era un
+ /// duplicado descartable). Las entradas con `line` vacía se ignoran.
+ pub fn append(&mut self, entry: Entry) -> io::Result<bool> {
+ if entry.line.trim().is_empty() {
+ return Ok(false);
+ }
+ match self.dedup {
+ DedupPolicy::None => {}
+ DedupPolicy::IgnoreConsecutive => {
+ if self.entries.last().is_some_and(|e| e.line == entry.line) {
+ return Ok(false);
+ }
+ }
+ DedupPolicy::EraseDups => {
+ self.entries.retain(|e| e.line != entry.line);
+ self.rewrite_file()?;
+ }
+ }
+ self.write_one(&entry)?;
+ self.entries.push(entry);
+ Ok(true)
+ }
+
+ /// Actualiza la última entrada con el código de salida y la duración
+ /// cuando el comando termina. Persiste reescribiendo el fichero.
+ pub fn finalize_last(&mut self, exit: i32, duration_ms: u64) -> io::Result<()> {
+ if let Some(last) = self.entries.last_mut() {
+ last.exit = Some(exit);
+ last.duration_ms = Some(duration_ms);
+ self.rewrite_file()?;
+ }
+ Ok(())
+ }
+
+ /// Navegación por el historial — devuelve el `(index, entry)`
+ /// correspondiente a moverse `dir` desde el cursor actual. El cursor
+ /// `None` parte "del final" (por debajo de la última entrada).
+ /// Convención: el índice 0 es la **entrada más reciente**, y avanza
+ /// hacia el pasado al subir el cursor.
+ pub fn navigate(&self, cursor: Option<usize>, dir: Nav) -> Option<(usize, &Entry)> {
+ if self.entries.is_empty() {
+ return None;
+ }
+ let next = match (cursor, dir) {
+ (None, Nav::Older) => 0,
+ (None, Nav::Newer) => return None,
+ (Some(i), Nav::Older) => i + 1,
+ (Some(0), Nav::Newer) => return None,
+ (Some(i), Nav::Newer) => i - 1,
+ };
+ if next >= self.entries.len() {
+ return None;
+ }
+ let entry = &self.entries[self.entries.len() - 1 - next];
+ Some((next, entry))
+ }
+
+ /// Búsqueda fuzzy sobre el campo `line`. Devuelve hasta `limit`
+ /// resultados ordenados por score descendente. Una `query` vacía
+ /// devuelve las entradas más recientes.
+ pub fn fuzzy_search(&self, query: &str, limit: usize) -> Vec<&Entry> {
+ if limit == 0 || self.entries.is_empty() {
+ return Vec::new();
+ }
+ if query.trim().is_empty() {
+ return self.entries.iter().rev().take(limit).collect();
+ }
+ use nucleo_matcher::{
+ pattern::{CaseMatching, Normalization, Pattern},
+ Config, Matcher,
+ };
+ let mut matcher = Matcher::new(Config::DEFAULT);
+ let pat = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
+ let mut scored: Vec<(u32, usize)> = Vec::new();
+ let mut buf = Vec::new();
+ for (idx, e) in self.entries.iter().enumerate() {
+ buf.clear();
+ let hay = nucleo_matcher::Utf32Str::new(&e.line, &mut buf);
+ if let Some(score) = pat.score(hay, &mut matcher) {
+ scored.push((score, idx));
+ }
+ }
+ // Score desc, y a igualdad de score, el más reciente primero.
+ scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
+ scored
+ .into_iter()
+ .take(limit)
+ .map(|(_, i)| &self.entries[i])
+ .collect()
+ }
+
+ // --- I/O ---
+
+ fn write_one(&self, entry: &Entry) -> io::Result<()> {
+ let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
+ let mut s = serde_json::to_string(entry).map_err(io::Error::other)?;
+ s.push('\n');
+ f.write_all(s.as_bytes())?;
+ f.flush()
+ }
+
+ fn rewrite_file(&self) -> io::Result<()> {
+ // Escritura atómica vía rename — nunca dejamos un historial a medias.
+ let tmp = self.path.with_extension("jsonl.tmp");
+ {
+ let mut f = OpenOptions::new()
+ .create(true)
+ .truncate(true)
+ .write(true)
+ .open(&tmp)?;
+ for e in &self.entries {
+ let mut s = serde_json::to_string(e).map_err(io::Error::other)?;
+ s.push('\n');
+ f.write_all(s.as_bytes())?;
+ }
+ f.flush()?;
+ }
+ std::fs::rename(tmp, &self.path)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn h(dir: &Path) -> History {
+ History::open(dir.join("history.jsonl")).unwrap()
+ }
+
+ #[test]
+ fn empty_history_round_trip() {
+ let d = tempdir().unwrap();
+ let h1 = h(d.path());
+ assert!(h1.is_empty());
+ let h2 = h(d.path());
+ assert!(h2.is_empty());
+ }
+
+ #[test]
+ fn append_persists_across_reopen() {
+ let d = tempdir().unwrap();
+ {
+ let mut h = h(d.path());
+ h.append(Entry::new("ls", "/tmp", 1000)).unwrap();
+ h.append(Entry::new("pwd", "/tmp", 1001)).unwrap();
+ }
+ let h = h(d.path());
+ assert_eq!(h.len(), 2);
+ assert_eq!(h.entries()[0].line, "ls");
+ assert_eq!(h.entries()[1].line, "pwd");
+ }
+
+ #[test]
+ fn ignore_consecutive_dedup_skips_repeats() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ assert!(h.append(Entry::new("ls", "/tmp", 1)).unwrap());
+ assert!(!h.append(Entry::new("ls", "/tmp", 2)).unwrap());
+ assert!(h.append(Entry::new("pwd", "/tmp", 3)).unwrap());
+ assert!(h.append(Entry::new("ls", "/tmp", 4)).unwrap());
+ assert_eq!(h.len(), 3);
+ }
+
+ #[test]
+ fn erase_dups_purges_prior_copies() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ h.set_dedup(DedupPolicy::EraseDups);
+ h.append(Entry::new("ls", "/tmp", 1)).unwrap();
+ h.append(Entry::new("pwd", "/tmp", 2)).unwrap();
+ h.append(Entry::new("ls", "/tmp", 3)).unwrap();
+ assert_eq!(h.len(), 2);
+ assert_eq!(h.entries()[0].line, "pwd");
+ assert_eq!(h.entries()[1].line, "ls");
+ }
+
+ #[test]
+ fn empty_line_is_ignored() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ assert!(!h.append(Entry::new("", "/tmp", 1)).unwrap());
+ assert!(!h.append(Entry::new(" ", "/tmp", 2)).unwrap());
+ assert!(h.is_empty());
+ }
+
+ #[test]
+ fn finalize_writes_exit_and_duration() {
+ let d = tempdir().unwrap();
+ {
+ let mut h = h(d.path());
+ h.append(Entry::new("sleep 1", "/tmp", 0)).unwrap();
+ h.finalize_last(0, 1000).unwrap();
+ }
+ let h = h(d.path());
+ assert_eq!(h.last().unwrap().exit, Some(0));
+ assert_eq!(h.last().unwrap().duration_ms, Some(1000));
+ }
+
+ #[test]
+ fn navigate_walks_from_newest_to_oldest() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ for (i, l) in ["a", "b", "c"].iter().enumerate() {
+ h.append(Entry::new(*l, "/tmp", i as u64)).unwrap();
+ }
+ // Empezando sin cursor, Older da la más reciente.
+ let (i0, e0) = h.navigate(None, Nav::Older).unwrap();
+ assert_eq!((i0, e0.line.as_str()), (0, "c"));
+ let (i1, e1) = h.navigate(Some(i0), Nav::Older).unwrap();
+ assert_eq!((i1, e1.line.as_str()), (1, "b"));
+ let (i2, e2) = h.navigate(Some(i1), Nav::Older).unwrap();
+ assert_eq!((i2, e2.line.as_str()), (2, "a"));
+ // En el extremo no hay más viejas.
+ assert!(h.navigate(Some(i2), Nav::Older).is_none());
+ // Volvemos hacia las nuevas.
+ let (i3, e3) = h.navigate(Some(i2), Nav::Newer).unwrap();
+ assert_eq!((i3, e3.line.as_str()), (1, "b"));
+ }
+
+ #[test]
+ fn fuzzy_search_ranks_matches() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ for l in ["cargo build --release", "cargo test", "git status", "cargo run"] {
+ h.append(Entry::new(l, "/tmp", 0)).unwrap();
+ }
+ let hits = h.fuzzy_search("cgo", 10);
+ // Las 3 entradas con "cargo" matchean; "git status" no.
+ assert_eq!(hits.len(), 3);
+ assert!(hits.iter().all(|e| e.line.contains("cargo")));
+ }
+
+ #[test]
+ fn empty_query_returns_most_recent_first() {
+ let d = tempdir().unwrap();
+ let mut h = h(d.path());
+ for l in ["a", "b", "c", "d"] {
+ h.append(Entry::new(l, "/tmp", 0)).unwrap();
+ }
+ let hits = h.fuzzy_search("", 2);
+ assert_eq!(hits.len(), 2);
+ assert_eq!(hits[0].line, "d");
+ assert_eq!(hits[1].line, "c");
+ }
+
+ #[test]
+ fn corrupt_lines_are_skipped_not_fatal() {
+ let d = tempdir().unwrap();
+ let path = d.path().join("history.jsonl");
+ std::fs::write(
+ &path,
+ "{\"line\":\"ok\",\"cwd\":\"/tmp\",\"started\":1}\ngarbage\n{\"line\":\"ok2\",\"cwd\":\"/tmp\",\"started\":2}\n",
+ )
+ .unwrap();
+ let h = History::open(&path).unwrap();
+ assert_eq!(h.len(), 2);
+ assert_eq!(h.skipped_on_load(), 1);
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml
new file mode 100644
index 0000000..2e0939c
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-infer"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — motor de inferencia de intenciones secuenciales: detecta patrones de comandos repetidos en el historial y abstrae sus argumentos variables."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-infer/LEEME.md b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md
new file mode 100644
index 0000000..fd25c86
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-infer
+
+> Inferencia para [`intent`](../shuma-intent/README.md) de [shuma](../../README.md).
+
+Prompts + heurísticas que rodean al LLM. Mantiene el modelo "confinado" al lenguaje shell.
+
+## Deps
+
+- [`shuma-intent`](../shuma-intent/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-infer/README.md b/02_ruway/shuma/sandbox/shuma-infer/README.md
new file mode 100644
index 0000000..f5de26a
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/README.md
@@ -0,0 +1,9 @@
+# shuma-infer
+
+> Inference for [`intent`](../shuma-intent/README.md) of [shuma](../../README.md).
+
+Prompts + heuristics around the LLM. Keeps the model "confined" to shell command language.
+
+## Deps
+
+- [`shuma-intent`](../shuma-intent/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs
new file mode 100644
index 0000000..076e08b
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs
@@ -0,0 +1,460 @@
+//! `shuma-infer` — el motor de inferencia de intenciones secuenciales.
+//!
+//! El shell observa cómo trabajas. Cuando una *coreografía* de comandos
+//! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor
+//! la detecta, la abstrae (los argumentos que cambian se vuelven
+//! variables) y la ofrece como un patrón reutilizable. Automatización
+//! que nace de la repetición orgánica, no de escribir scripts.
+//!
+//! Es agnóstico y determinista: recibe el historial reducido a
+//! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni
+//! la red, ni ningún frontend — el shell se encarga de eso.
+//!
+//! ```text
+//! historial ──► detect_patterns ──► [EmergingPattern]
+//! · firma de binarios (ventana deslizante)
+//! · sólo ventanas 100% exitosas
+//! · abstracción: args que varían → Varies
+//! · se quedan los patrones maximales
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+/// Un comando ejecutado, reducido a lo que importa para inferir.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CommandRecord {
+ /// El binario invocado — la primera palabra de la línea.
+ pub binary: String,
+ /// Los argumentos, en orden.
+ pub args: Vec<String>,
+ /// Directorio en que se ejecutó.
+ pub cwd: String,
+ /// Si terminó con éxito (código 0).
+ pub success: bool,
+}
+
+impl CommandRecord {
+ /// Reduce una línea de comando a un registro. La división es simple
+ /// (`split_whitespace`) — suficiente para comparar firmas.
+ pub fn parse(line: &str, cwd: impl Into<String>, success: bool) -> Self {
+ let mut words = line.split_whitespace().map(str::to_string);
+ let binary = words.next().unwrap_or_default();
+ Self { binary, args: words.collect(), cwd: cwd.into(), success }
+ }
+}
+
+/// Ajustes del detector.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct InferConfig {
+ /// Largo mínimo de una secuencia para considerarla patrón.
+ pub min_len: usize,
+ /// Largo máximo de ventana a buscar.
+ pub max_len: usize,
+ /// Cuántas veces debe repetirse una firma para emerger.
+ pub min_occurrences: usize,
+}
+
+impl Default for InferConfig {
+ fn default() -> Self {
+ Self { min_len: 2, max_len: 5, min_occurrences: 2 }
+ }
+}
+
+/// Los argumentos de un paso del patrón, tras la abstracción.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum StepArgs {
+ /// Los argumentos son idénticos en todas las ocurrencias.
+ Fixed(Vec<String>),
+ /// Los argumentos cambian entre ocurrencias — son una variable.
+ Varies,
+}
+
+/// Un paso abstracto del patrón: el binario + sus argumentos.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PatternStep {
+ pub binary: String,
+ pub args: StepArgs,
+}
+
+impl PatternStep {
+ /// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`.
+ pub fn render(&self) -> String {
+ match &self.args {
+ StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(),
+ StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")),
+ StepArgs::Varies => format!("{} <…>", self.binary),
+ }
+ }
+}
+
+/// Un patrón de comandos que emergió del historial.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct EmergingPattern {
+ /// Firma: la secuencia de binarios.
+ pub signature: Vec<String>,
+ /// Pasos abstractos — para mostrar al usuario.
+ pub steps: Vec<PatternStep>,
+ /// Las líneas reales de la ocurrencia más reciente — ejecutables.
+ pub example: Vec<String>,
+ /// Cuántas veces apareció el patrón.
+ pub occurrences: usize,
+ /// Directorios donde arrancó el patrón, sin repetir.
+ pub directories: Vec<String>,
+}
+
+impl EmergingPattern {
+ /// Puntaje de interés: más largo y más frecuente, más arriba.
+ pub fn score(&self) -> usize {
+ self.occurrences * self.signature.len()
+ }
+
+ /// Nombre sugerido para el patrón — los binarios significativos
+ /// (sin el `cd` inicial) unidos por `+`.
+ pub fn suggested_name(&self) -> String {
+ let significant: Vec<&str> = self
+ .signature
+ .iter()
+ .filter(|b| b.as_str() != "cd")
+ .map(String::as_str)
+ .collect();
+ if significant.is_empty() {
+ self.signature.join("+")
+ } else {
+ significant.join("+")
+ }
+ }
+}
+
+/// `true` si `needle` aparece como sub-secuencia contigua de `haystack`.
+fn contains_subslice(haystack: &[String], needle: &[String]) -> bool {
+ needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle)
+}
+
+/// Construye el patrón abstracto a partir de su firma y las posiciones
+/// donde ocurrió.
+fn build_pattern(
+ history: &[CommandRecord],
+ signature: &[String],
+ starts: &[usize],
+) -> EmergingPattern {
+ let len = signature.len();
+ let mut steps = Vec::with_capacity(len);
+ for i in 0..len {
+ // Argumentos de este paso a lo largo de todas las ocurrencias.
+ let first = &history[starts[0] + i].args;
+ let all_same = starts.iter().all(|&s| &history[s + i].args == first);
+ let args = if all_same {
+ StepArgs::Fixed(first.clone())
+ } else {
+ StepArgs::Varies
+ };
+ steps.push(PatternStep { binary: signature[i].clone(), args });
+ }
+
+ // La ocurrencia más reciente da las líneas reales, ejecutables.
+ let last = *starts.iter().max().expect("hay ocurrencias");
+ let example: Vec<String> = (0..len)
+ .map(|i| {
+ let c = &history[last + i];
+ if c.args.is_empty() {
+ c.binary.clone()
+ } else {
+ format!("{} {}", c.binary, c.args.join(" "))
+ }
+ })
+ .collect();
+
+ // El directorio "de trabajo" de una ocurrencia: el cwd de su último
+ // comando — para entonces todos los `cd` ya se hicieron.
+ let mut directories: Vec<String> = Vec::new();
+ for &s in starts {
+ let d = &history[s + len - 1].cwd;
+ if !directories.contains(d) {
+ directories.push(d.clone());
+ }
+ }
+
+ EmergingPattern {
+ signature: signature.to_vec(),
+ steps,
+ example,
+ occurrences: starts.len(),
+ directories,
+ }
+}
+
+/// Detecta los patrones de comandos repetidos en `history`.
+///
+/// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito.
+/// Se devuelven los patrones *maximales* (uno contenido en otro más
+/// largo no se reporta), ordenados por puntaje descendente.
+pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<EmergingPattern> {
+ // firma → posiciones de inicio de las ventanas que la producen.
+ let mut windows: BTreeMap<Vec<String>, Vec<usize>> = BTreeMap::new();
+ for len in cfg.min_len..=cfg.max_len {
+ if history.len() < len {
+ break;
+ }
+ for start in 0..=history.len() - len {
+ let win = &history[start..start + len];
+ if !win.iter().all(|c| c.success) {
+ continue; // una ventana con un fallo no es un patrón
+ }
+ let signature: Vec<String> = win.iter().map(|c| c.binary.clone()).collect();
+ windows.entry(signature).or_default().push(start);
+ }
+ }
+
+ // Firmas que se repiten lo suficiente.
+ let qualifying: Vec<(Vec<String>, Vec<usize>)> = windows
+ .into_iter()
+ .filter(|(_, starts)| starts.len() >= cfg.min_occurrences)
+ .collect();
+
+ // Sólo las maximales: una firma contenida en otra más larga que
+ // también califica se descarta (la larga la subsume).
+ let mut patterns: Vec<EmergingPattern> = qualifying
+ .iter()
+ .filter(|(sig, _)| {
+ !qualifying
+ .iter()
+ .any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig))
+ })
+ .map(|(sig, starts)| build_pattern(history, sig, starts))
+ .collect();
+
+ patterns.sort_by(|a, b| {
+ b.score()
+ .cmp(&a.score())
+ .then(a.signature.cmp(&b.signature))
+ });
+ patterns
+}
+
+/// Predice la continuación de un patrón en curso.
+///
+/// Mira el final del historial `recent`: si sus últimos comandos
+/// coinciden con el prefijo de la firma de algún patrón, devuelve las
+/// líneas que faltan para completarlo —tomadas de la ocurrencia más
+/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
+/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
+/// Devuelve `(índice del patrón en `patterns`, líneas de continuación)`.
+pub fn predict_next(
+ recent: &[CommandRecord],
+ patterns: &[EmergingPattern],
+) -> Option<(usize, Vec<String>)> {
+ let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
+ // best = (longitud del prefijo coincidente, índice del patrón).
+ let mut best: Option<(usize, usize)> = None;
+ for (pi, p) in patterns.iter().enumerate() {
+ // Tiene que quedar al menos un paso por predecir.
+ let max_k = p.signature.len().saturating_sub(1).min(bins.len());
+ for k in (1..=max_k).rev() {
+ let tail = &bins[bins.len() - k..];
+ let prefix_matches = p
+ .signature
+ .iter()
+ .take(k)
+ .map(String::as_str)
+ .eq(tail.iter().copied());
+ if prefix_matches {
+ if best.map(|(bk, _)| k > bk).unwrap_or(true) {
+ best = Some((k, pi));
+ }
+ break;
+ }
+ }
+ }
+ best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Atajo: un `CommandRecord` exitoso.
+ fn ok(line: &str, cwd: &str) -> CommandRecord {
+ CommandRecord::parse(line, cwd, true)
+ }
+
+ #[test]
+ fn parse_splits_binary_and_args() {
+ let r = CommandRecord::parse("git commit -m mensaje", "/p", true);
+ assert_eq!(r.binary, "git");
+ assert_eq!(r.args, vec!["commit", "-m", "mensaje"]);
+ }
+
+ #[test]
+ fn detects_a_repeated_sequence() {
+ // cd → git pull → cargo build, dos veces, en dos directorios.
+ let history = vec![
+ ok("cd /proj/a", "/home"),
+ ok("git pull", "/proj/a"),
+ ok("cargo build", "/proj/a"),
+ ok("cd /proj/b", "/home"),
+ ok("git pull", "/proj/b"),
+ ok("cargo build", "/proj/b"),
+ ];
+ let patterns = detect_patterns(&history, &InferConfig::default());
+ assert_eq!(patterns.len(), 1);
+ let p = &patterns[0];
+ assert_eq!(p.signature, vec!["cd", "git", "cargo"]);
+ assert_eq!(p.occurrences, 2);
+ }
+
+ #[test]
+ fn abstracts_varying_arguments() {
+ let history = vec![
+ ok("cd /proj/a", "/home"),
+ ok("git pull", "/proj/a"),
+ ok("cd /proj/b", "/home"),
+ ok("git pull", "/proj/b"),
+ ];
+ let patterns = detect_patterns(&history, &InferConfig::default());
+ let p = &patterns[0];
+ // El `cd` cambia de argumento → Varies; `git pull` es constante.
+ assert_eq!(p.steps[0].args, StepArgs::Varies);
+ assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()]));
+ assert_eq!(p.steps[0].render(), "cd <…>");
+ assert_eq!(p.steps[1].render(), "git pull");
+ }
+
+ #[test]
+ fn example_is_the_most_recent_occurrence() {
+ let history = vec![
+ ok("cd /proj/a", "/home"),
+ ok("git pull", "/proj/a"),
+ ok("cd /proj/b", "/home"),
+ ok("git pull", "/proj/b"),
+ ];
+ let p = &detect_patterns(&history, &InferConfig::default())[0];
+ // Las líneas reales y ejecutables de la última ocurrencia.
+ assert_eq!(p.example, vec!["cd /proj/b", "git pull"]);
+ }
+
+ #[test]
+ fn a_failed_command_breaks_the_pattern() {
+ let history = vec![
+ ok("cd /proj/a", "/home"),
+ ok("git pull", "/proj/a"),
+ ok("cd /proj/b", "/home"),
+ CommandRecord::parse("git pull", "/proj/b", false), // falló
+ ];
+ // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón.
+ assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
+ }
+
+ #[test]
+ fn no_repetition_yields_no_patterns() {
+ let history = vec![
+ ok("ls", "/a"),
+ ok("pwd", "/a"),
+ ok("date", "/a"),
+ ];
+ assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
+ }
+
+ #[test]
+ fn longer_pattern_subsumes_its_subsequences() {
+ // [cd, git, cargo] repetido → no se reporta también [cd, git].
+ let history = vec![
+ ok("cd /a", "/h"),
+ ok("git pull", "/a"),
+ ok("cargo build", "/a"),
+ ok("cd /b", "/h"),
+ ok("git pull", "/b"),
+ ok("cargo build", "/b"),
+ ];
+ let patterns = detect_patterns(&history, &InferConfig::default());
+ assert_eq!(patterns.len(), 1);
+ assert_eq!(patterns[0].signature.len(), 3);
+ }
+
+ #[test]
+ fn directories_are_collected() {
+ let history = vec![
+ ok("cd /a", "/home"),
+ ok("git pull", "/a"),
+ ok("cd /b", "/work"),
+ ok("git pull", "/b"),
+ ];
+ let p = &detect_patterns(&history, &InferConfig::default())[0];
+ // El directorio de cada ocurrencia es el de su último comando.
+ assert_eq!(p.directories, vec!["/a", "/b"]);
+ }
+
+ #[test]
+ fn suggested_name_drops_the_cd() {
+ let history = vec![
+ ok("cd /a", "/h"),
+ ok("git pull", "/a"),
+ ok("cargo build", "/a"),
+ ok("cd /b", "/h"),
+ ok("git pull", "/b"),
+ ok("cargo build", "/b"),
+ ];
+ let p = &detect_patterns(&history, &InferConfig::default())[0];
+ assert_eq!(p.suggested_name(), "git+cargo");
+ }
+
+ /// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos
+ /// veces, y la lista de patrones que produce.
+ fn pattern_world() -> Vec<EmergingPattern> {
+ let history = vec![
+ ok("cd /a", "/h"),
+ ok("git pull", "/a"),
+ ok("cargo build", "/a"),
+ ok("cd /b", "/h"),
+ ok("git pull", "/b"),
+ ok("cargo build", "/b"),
+ ];
+ detect_patterns(&history, &InferConfig::default())
+ }
+
+ #[test]
+ fn predicts_the_rest_after_a_cd() {
+ let patterns = pattern_world();
+ // El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
+ let recent = vec![ok("cd /nuevo", "/h")];
+ let (_, next) = predict_next(&recent, &patterns).unwrap();
+ assert_eq!(next, vec!["git pull", "cargo build"]);
+ }
+
+ #[test]
+ fn prediction_shrinks_as_the_pattern_advances() {
+ let patterns = pattern_world();
+ let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
+ let (_, next) = predict_next(&recent, &patterns).unwrap();
+ assert_eq!(next, vec!["cargo build"]);
+ }
+
+ #[test]
+ fn no_prediction_when_nothing_matches() {
+ let patterns = pattern_world();
+ let recent = vec![ok("ls", "/h"), ok("pwd", "/h")];
+ assert!(predict_next(&recent, &patterns).is_none());
+ }
+
+ #[test]
+ fn score_ranks_longer_and_more_frequent_higher() {
+ let short = EmergingPattern {
+ signature: vec!["a".into(), "b".into()],
+ steps: vec![],
+ example: vec![],
+ occurrences: 2,
+ directories: vec![],
+ };
+ let long = EmergingPattern {
+ signature: vec!["a".into(), "b".into(), "c".into()],
+ steps: vec![],
+ example: vec![],
+ occurrences: 3,
+ directories: vec![],
+ };
+ assert!(long.score() > short.score());
+ }
+}
diff --git a/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml
new file mode 100644
index 0000000..ce32375
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-intent"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — núcleo agnóstico del shell: parser de intenciones (tokens %cN/%pN) + grafo de contexto de la sesión."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/02_ruway/shuma/sandbox/shuma-intent/LEEME.md b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md
new file mode 100644
index 0000000..75ed321
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md
@@ -0,0 +1,9 @@
+# shuma-intent
+
+> Intent → comando (predictor) de [shuma](../../README.md).
+
+Toma texto natural y predice el comando shell. Usa [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Sugiere; nunca ejecuta sin confirmación.
+
+## Deps
+
+- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-intent/README.md b/02_ruway/shuma/sandbox/shuma-intent/README.md
new file mode 100644
index 0000000..1a91a08
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/README.md
@@ -0,0 +1,9 @@
+# shuma-intent
+
+> Intent → command (predictor) of [shuma](../../README.md).
+
+Takes natural-language text and predicts the matching shell command. Uses [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Suggests; never executes without confirmation.
+
+## Deps
+
+- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs
new file mode 100644
index 0000000..c858dfb
--- /dev/null
+++ b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs
@@ -0,0 +1,175 @@
+//! Grafo de contexto de una sesión de shuma.
+//!
+//! Registra cada intención ejecutada como un nodo `%cN`; al terminar, el
+//! nodo expone su buffer de salida `%pN`. El grafo permite resolver las
+//! referencias del prompt, validar intenciones nuevas antes de ejecutar,
+//! y colapsar nodos exitosos para la quietud visual.
+
+use crate::parse::{Intention, Ref};
+use serde::{Deserialize, Serialize};
+
+/// Estado de un nodo del grafo de contexto.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NodeStatus {
+ Running,
+ Ok,
+ Failed,
+}
+
+/// Un comando registrado en la sesión.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandNode {
+ /// Identificador `%cN`.
+ pub id: u32,
+ /// Texto de la intención original.
+ pub intention: String,
+ /// Buffer `%pN` producido como salida, si el comando ya terminó.
+ pub output_buffer: Option<u32>,
+ pub status: NodeStatus,
+ /// Colapsado en la UI (nodo exitoso retraído por quietud visual).
+ pub collapsed: bool,
+ /// Bytes del buffer de salida (para dimensionar el grafo visual).
+ pub output_bytes: u64,
+}
+
+/// Grafo de intenciones y flujos de una sesión de shell.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionGraph {
+ commands: Vec<CommandNode>,
+ next_command: u32,
+ next_buffer: u32,
+}
+
+impl Default for SessionGraph {
+ fn default() -> Self {
+ Self { commands: Vec::new(), next_command: 1, next_buffer: 1 }
+ }
+}
+
+impl SessionGraph {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn len(&self) -> usize {
+ self.commands.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.commands.is_empty()
+ }
+
+ pub fn commands(&self) -> &[CommandNode] {
+ &self.commands
+ }
+
+ /// Registra una intención nueva en estado `Running`. Devuelve su `%cN`.
+ pub fn record(&mut self, intention: impl Into<String>) -> u32 {
+ let id = self.next_command;
+ self.next_command += 1;
+ self.commands.push(CommandNode {
+ id,
+ intention: intention.into(),
+ output_buffer: None,
+ status: NodeStatus::Running,
+ collapsed: false,
+ output_bytes: 0,
+ });
+ id
+ }
+
+ /// Marca un comando como terminado y le asigna un buffer de salida.
+ /// Devuelve el `%pN` asignado, o `None` si el `%cN` no existe.
+ pub fn complete(&mut self, command_id: u32, ok: bool, output_bytes: u64) -> Option<u32> {
+ let buffer = self.next_buffer;
+ let node = self.commands.iter_mut().find(|c| c.id == command_id)?;
+ node.status = if ok { NodeStatus::Ok } else { NodeStatus::Failed };
+ node.output_bytes = output_bytes;
+ node.output_buffer = Some(buffer);
+ self.next_buffer += 1;
+ Some(buffer)
+ }
+
+ /// Resuelve una referencia a su nodo de comando.
+ pub fn resolve(&self, r: Ref) -> Option<&CommandNode> {
+ match r {
+ Ref::Command(n) => self.commands.iter().find(|c| c.id == n),
+ Ref::Buffer(n) => self.commands.iter().find(|c| c.output_buffer == Some(n)),
+ }
+ }
+
+ /// Referencias de la intención que NO se pueden resolver en esta
+ /// sesión. Vacío = la intención es ejecutable (validación previa
+ /// del prompt).
+ pub fn dangling_refs(&self, intention: &Intention) -> Vec<Ref> {
+ intention
+ .refs()
+ .into_iter()
+ .filter(|r| self.resolve(*r).is_none())
+ .collect()
+ }
+
+ /// Colapsa los nodos exitosos (quietud visual: los flujos que ya
+ /// funcionaron se retraen).
+ pub fn collapse_succeeded(&mut self) {
+ for c in &mut self.commands {
+ if c.status == NodeStatus::Ok {
+ c.collapsed = true;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn record_assigns_increasing_command_ids() {
+ let mut g = SessionGraph::new();
+ assert_eq!(g.record("cat a"), 1);
+ assert_eq!(g.record("cat b"), 2);
+ assert_eq!(g.len(), 2);
+ }
+
+ #[test]
+ fn complete_assigns_buffer_and_status() {
+ let mut g = SessionG
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.