package app import ( "context" "errors" "log/slog" "strings" "sync" "time" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/shortcutui" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type tentacleInput interface { SetTentacleBindings(context.Context, []input.BindingOverride) error TentacleBindings() []input.BindingOverride } type tentacleCommand struct { session *tentacle.Session entryID string id, title, description string presentation bool interactive bool settings bool } // tentacleRuntime owns local sessions for one reader lifetime. Its worker owns // native publication; mutex protects dispatch snapshots and immediate disable. // Only explicitly granted presentation commands reach the core output owner. type tentacleRuntime struct { forms tentacleForms focusedItem func(context.Context) (tentaclewire.Item, error) interactionSlots chan struct{} registry *tentacle.Registry requests chan tentacleManagementCall snapshot tentacle.Snapshot guardianProgram []string // Private fixture seam; production uses this executable. output *tentacleOutput logger *slog.Logger input tentacleInput path, parent string newManager func() (*tentacle.Manager, error) // private fixture seam fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam repositoryBusy bool mutex sync.Mutex commands map[input.Command]tentacleCommand sessions []*tentacle.Session disabled bool cancel context.CancelFunc readerContext context.Context lifecycleContext context.Context done chan struct{} failures chan error announcements chan string stopError error results sync.WaitGroup resultSlots chan struct{} } func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime { owner, _ := commands.(tentacleInput) return &tentacleRuntime{logger: logger, input: owner, path: path, parent: parent, commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)} } func (runtime *tentacleRuntime) Start(ctx context.Context) error { runtime.mutex.Lock() defer runtime.mutex.Unlock() runtime.readerContext = ctx ctx, runtime.cancel = context.WithCancel(ctx) runtime.lifecycleContext = ctx runtime.done = make(chan struct{}) go func() { defer close(runtime.done) run := runtime.run if runtime.registry != nil { run = runtime.run_managed } if err := run(ctx); err != nil { runtime.logger.Warn("tentacle startup failed", "error", err) runtime.report("Tentacles could not start.") } }() return nil } func (runtime *tentacleRuntime) Disable() { if runtime == nil { return } runtime.mutex.Lock() defer runtime.mutex.Unlock() runtime.disabled = true runtime.output.Stop() clear(runtime.commands) for _, session := range runtime.sessions { session.Disable() } if runtime.cancel != nil { runtime.cancel() } } func (runtime *tentacleRuntime) Stop(ctx context.Context) error { if runtime == nil { return nil } runtime.Disable() if runtime.done == nil { return nil } select { case <-runtime.done: return runtime.stopError case <-ctx.Done(): return ctx.Err() } } func (runtime *tentacleRuntime) run(ctx context.Context) error { if runtime.input == nil { return errors.New("tentacles require native binding support") } specs, err := tentacle.LoadLocalSpecs(runtime.path) if err != nil || len(specs) == 0 { return err } var manager *tentacle.Manager if runtime.newManager != nil { manager, err = runtime.newManager() } else { parent := runtime.parent if parent == "auto" { parent, err = tentacle.DelegatedParent() } if err == nil { manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits()) } } if err != nil { return err } var watchers sync.WaitGroup defer func() { runtime.Disable() cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() bindingError := runtime.input.SetTentacleBindings(cleanup, nil) if runtime.readerContext.Err() != nil { bindingError = nil } runtime.stopError = errors.Join(bindingError, manager.Shutdown(cleanup)) watchers.Wait() runtime.results.Wait() if runtime.stopError != nil && runtime.readerContext.Err() == nil { select { case runtime.failures <- runtime.stopError: default: } } }() wake := make(chan struct{}, 1) var current *tentacle.Session var ready <-chan struct{} var active []*tentacle.Session index := 0 for { if ctx.Err() != nil { return nil } if current == nil && index < len(specs) { spec := specs[index] index++ session, startError := manager.Start(ctx, spec) if startError != nil { runtime.logger.Warn("start local tentacle", "tentacle", spec.ID, "error", startError) runtime.report("A tentacle could not start.") continue } runtime.mutex.Lock() runtime.sessions = append(runtime.sessions, session) if runtime.disabled { session.Disable() } runtime.mutex.Unlock() current, ready = session, session.Ready() watchers.Add(1) go func() { defer watchers.Done() select { case <-session.Revoked(): case <-ctx.Done(): } runtime.output.RetireRevoked() select { case wake <- struct{}{}: default: } }() } select { case <-ctx.Done(): return nil case <-wake: retained := active[:0] for _, session := range active { if state, failure := session.Status(); state == tentacle.Active { retained = append(retained, session) } else if state == tentacle.Failed { runtime.logger.Warn("local tentacle failed", "session", session.Identity(), "error", failure) runtime.report("A tentacle stopped unexpectedly.") } } active = retained runtime.publishCommands(active) if err := runtime.bind(ctx, active); err != nil { if ctx.Err() != nil { return nil } runtime.failures <- err // Stop the reader if stale grabs cannot be removed. return err } case <-ready: session := current current, ready = nil, nil if state, failure := session.Status(); state != tentacle.Ready { runtime.logger.Warn("local tentacle handshake failed", "error", failure) runtime.report("A tentacle could not start.") continue } candidate := append(append([]*tentacle.Session(nil), active...), session) if err := runtime.bind(ctx, candidate); err != nil { session.Disable() runtime.logger.Warn("local tentacle bindings rejected", "error", err) runtime.report("A tentacle could not acquire its keyboard shortcuts.") continue } if err := session.Activate(); err != nil { session.Disable() if restore := runtime.bind(ctx, active); restore != nil && ctx.Err() == nil { runtime.failures <- restore return restore } continue } active = candidate runtime.publishCommands(active) runtime.logger.Info("local tentacle active", "session", session.Identity()) } } } func (runtime *tentacleRuntime) bind(ctx context.Context, sessions []*tentacle.Session) error { var bindings []input.BindingOverride for _, session := range sessions { for _, command := range session.Commands() { for _, binding := range command.Bindings { layout := input.DesktopKeyboardLayout if binding.Layout == "laptop" { layout = input.LaptopKeyboardLayout } bindings = append(bindings, input.BindingOverride{Layout: layout, Scope: binding.Scope, Command: input.Command("tentacle/" + session.Identity() + "/" + command.ID), Keys: []string{binding.Key}}) } } } ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() return runtime.input.SetTentacleBindings(ctx, bindings) } func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) { runtime.mutex.Lock() defer runtime.mutex.Unlock() clear(runtime.commands) if runtime.disabled { return } for _, session := range sessions { for _, command := range session.Commands() { runtime.commands[input.Command("tentacle/"+session.Identity()+"/"+command.ID)] = tentacleCommand{ session: session, entryID: session.TentacleID(), id: command.ID, title: command.Title, description: command.Description, presentation: command.Presentation, interactive: command.Interaction, settings: command.Settings, } } } } // Invoke never waits for a child or native binding transaction. Every result // worker has a bounded slot and joins before lifecycle shutdown completes. func (runtime *tentacleRuntime) Invoke(ctx context.Context, command input.Command) bool { if runtime == nil || !strings.HasPrefix(string(command), "tentacle/") { return false } runtime.mutex.Lock() defer runtime.mutex.Unlock() registered, ok := runtime.commands[command] if !ok || runtime.disabled { return true } if err := runtime.invokeLocked(ctx, command, registered); err != nil { runtime.logger.Debug("tentacle command rejected", "command", command, "error", err) } return true } // Caller holds runtime.mutex so shutdown cannot race worker admission. func (runtime *tentacleRuntime) invokeLocked(ctx context.Context, command input.Command, registered tentacleCommand) error { select { case runtime.resultSlots <- struct{}{}: default: return tentacle.ErrBusy } result, err := registered.session.Invoke(ctx, registered.id, nil) if err != nil { <-runtime.resultSlots return err } var ticket tentacleOutputTicket if registered.presentation { ticket = runtime.output.Capture(registered.session.OutputContext()) } runtime.results.Add(1) go func() { defer runtime.results.Done() defer func() { <-runtime.resultSlots }() value := <-result runtime.logger.Debug("tentacle command completed", "command", command, "request", value.ID, "error", value.Err) if value.Err != nil || !registered.presentation { return } if registered.interactive { if ticket.ctx == nil || ticket.ctx.Err() != nil { return } runtime.interact(ctx, registered, value.Value) return } presentation, err := tentaclewire.DecodePresentation(value.Value) if err != nil { registered.session.Disable() runtime.output.RetireRevoked() runtime.logger.Warn("tentacle returned invalid presentation", "command", command, "error", err) runtime.report("A tentacle returned an invalid message and was disabled.") return } runtime.mutex.Lock() defer runtime.mutex.Unlock() if !runtime.disabled && value.Session == registered.session.Identity() { runtime.output.Present(ticket, presentation, string(command)) } }() return nil } func (runtime *tentacleRuntime) Describe(chord input.CommandChord, layout input.KeyboardLayout, browse bool) string { if runtime == nil || runtime.input == nil { return "" } bindings := runtime.input.TentacleBindings() runtime.mutex.Lock() defer runtime.mutex.Unlock() for _, binding := range bindings { if binding.Layout != layout || binding.Scope == "browse" && !browse { continue } bound, _ := input.ParseCommandChord(binding.Keys[0]) registered, ok := runtime.commands[binding.Command] if bound != chord || !ok { continue } if state, _ := registered.session.Status(); state != tentacle.Active { continue } if registered.description != "" { return registered.description } return registered.title } return "" } // ShortcutRows uses the acknowledged native assignments, including core remaps. func (runtime *tentacleRuntime) ShortcutRows(layout input.KeyboardLayout) []shortcutui.Row { if runtime == nil || runtime.input == nil { return nil } bindings := runtime.input.TentacleBindings() runtime.mutex.Lock() defer runtime.mutex.Unlock() var rows []shortcutui.Row for _, binding := range bindings { registered, ok := runtime.commands[binding.Command] if binding.Layout != layout || !ok { continue } if state, _ := registered.session.Status(); state != tentacle.Active { continue } label := registered.description if label == "" { label = registered.title } group := "Tentacles" if binding.Scope == "browse" { group = "Tentacles in browse mode" } rows = append(rows, shortcutui.Row{Group: group, Label: label, Keys: binding.Keys[0]}) } return rows } func (runtime *tentacleRuntime) report(message string) { select { case runtime.announcements <- message: default: } }