From bc26a73aeb421925db70bf98f4d65ab20e03b59b Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 24 Oct 2026 09:49:16 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20ein=20Binary=20=E2=80=94=20talk=20s?= =?UTF-8?q?tartet=20den=20Daemon=20selbst,=20talkd=20und=20Degraded=20Mode?= =?UTF-8?q?=20entfallen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der Degraded Mode kannte weder Rate-Limit noch Ruhezeiten. Jetzt: - internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage); Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite Zusammensetzung der Kette mehr. - internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei daemon.pid, Shutdown-Trigger. - `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg und die PID-Datei gelöscht ist (Prozess wirklich beendet). - Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt den Daemon des anderen — kein Timeout-Warten. - Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis + Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst nicht; `talk status` zeigt „läuft nicht" (Exit 1). - cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt neu. Tests: Harness baut nur noch talk und startet `talk daemon run`; TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile aller sechs Targets mit CGO_ENABLED=0 verifiziert. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 - README.md | 64 +++++--- cmd/talk/cli.go | 12 +- cmd/talk/control.go | 13 +- cmd/talk/control_test.go | 12 +- cmd/talk/daemon.go | 116 ++++++++++--- cmd/talk/daemon_cmd.go | 117 ++++++++++++++ cmd/talk/degraded.go | 117 -------------- cmd/talk/main.go | 34 ++-- cmd/talk/say.go | 83 ++++++---- cmd/talk/say_test.go | 6 +- cmd/talk/wrap.go | 21 +-- cmd/talkd/main.go | 270 ------------------------------- internal/api/client.go | 5 + internal/api/server.go | 20 +++ internal/api/types.go | 5 + internal/config/config.go | 20 +++ internal/config/config_test.go | 45 ++++++ internal/daemon/daemon.go | 214 ++++++++++++++++++++++++ internal/daemon/spawn.go | 41 +++++ internal/daemon/spawn_other.go | 8 + internal/daemon/spawn_unix.go | 14 ++ internal/daemon/spawn_windows.go | 20 +++ internal/speak/engine.go | 136 ++++++++++++++++ test/autostart_test.go | 215 ++++++++++++++++++++++++ test/cli_test.go | 64 ++------ test/harness_test.go | 39 ++--- test/mute_test.go | 4 +- test/wrap_test.go | 12 +- 29 files changed, 1145 insertions(+), 584 deletions(-) create mode 100644 cmd/talk/daemon_cmd.go delete mode 100644 cmd/talk/degraded.go delete mode 100644 cmd/talkd/main.go create mode 100644 internal/daemon/daemon.go create mode 100644 internal/daemon/spawn.go create mode 100644 internal/daemon/spawn_other.go create mode 100644 internal/daemon/spawn_unix.go create mode 100644 internal/daemon/spawn_windows.go create mode 100644 internal/speak/engine.go create mode 100644 test/autostart_test.go diff --git a/.gitignore b/.gitignore index b228a29..f6337e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ # Go-Binaries /talk -/talkd /talk.exe -/talkd.exe *.test *.out diff --git a/README.md b/README.md index fd97291..6342bc1 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` — spricht eine kurze Nachricht über eine TTS-Provider-Kette -(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon -`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries, -keine Runtime-Abhängigkeiten. +(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen +Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein +statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine +Runtime-Abhängigkeiten. ## Befehle @@ -20,6 +21,7 @@ keine Runtime-Abhängigkeiten. | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben | | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) | | `talk cancel` | laufende und geplante Ansagen abbrechen | +| `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) | | `talk voices` | verfügbare Stimmen anzeigen | | `talk test` | Test-Ansage ausgeben | | `talk doctor` | Selbstdiagnose des Setups | @@ -29,17 +31,37 @@ Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. **Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`) -mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über -`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`. -Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code -≠ 0). +mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking, +Autostart, siehe unten), `mute`, `unmute`, `status`, `cancel`, `daemon` +sowie `wrap`. Die übrigen Befehle sind Stubs und melden `not implemented +yet` (Exit-Code ≠ 0). -## Daemon talkd (Task 04–06) +## Daemon -`talkd` besitzt die Ansage-Queue und spricht seriell ab — genau eine Ansage -gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` (FIFO je -Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default `7800`, -config `[server] port`, Env `TALK_PORT` (schlagen den Default). +Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code, +im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau +eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low` +(FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default +`7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default). + +**Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn +beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in +`daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet +einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet +nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart. +Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen, +benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet +(systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den +Autostart ab: + +```toml +[daemon] +autostart = false # Env TALK_AUTOSTART=false +``` + +Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon +bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute` +schreibt die Mute-Datei direkt. | Endpunkt | Wirkung | | --- | --- | @@ -48,8 +70,9 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default). | `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "mcp": true, "cors": [origins]}` | | `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` | | `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` | +| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` | | `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) | -| `GET /mcp` | `405` — talkd bietet keine server-initiierten Streams an | +| `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an | - **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via `GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking, @@ -61,10 +84,11 @@ config `[server] port`, Env `TALK_PORT` (schlagen den Default). `false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu, der Zustand greift also auch offline. -- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und - sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im - Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als - verfallen). Mute wird auch hier respektiert. Nichts bricht still. +- **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar + (Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk` + direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele + Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird + auch hier respektiert. Nichts bricht still. ### Rate-Limit + Ruhezeiten (Task 05) @@ -129,7 +153,7 @@ allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS - **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb - lehnt talkd jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die + lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die nicht POST /speak von einer gelisteten Origin ist — also ungelistete Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`, `/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der @@ -261,8 +285,8 @@ talk wrap --wait -- make test Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention), Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein Befehl, ungültige Flags) scheitern vor dem Start des Befehls. -- **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking; - `--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei. +- **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking; + `--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad. wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`) und reicht sie an die Ansage durch. diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index 8b7650e..aca67d0 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -67,6 +67,13 @@ var commands = []command{ detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).", handler: runStatus, }, + { + name: "daemon", + short: "Hintergrundprozess steuern (run | start | stop)", + args: "[run|start|stop]", + detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).", + handler: runDaemon, + }, { name: "cancel", short: "Laufende und geplante Ansagen abbrechen", @@ -286,10 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { // versionString rendert die Versionsausgabe: `talk version ` plus // Commit-Hash in Klammern, falls per ldflags injiziert. func versionString() string { - if commit == "" { - return fmt.Sprintf("talk version %s", version) - } - return fmt.Sprintf("talk version %s (%s)", version, commit) + return "talk version " + buildVersion() } // notImplemented meldet einen noch nicht implementierten Befehl auf stderr diff --git a/cmd/talk/control.go b/cmd/talk/control.go index d11190a..8bf7e94 100644 --- a/cmd/talk/control.go +++ b/cmd/talk/control.go @@ -19,7 +19,9 @@ import ( // runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein // Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe // Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der -// Zustand greift also auch offline und überlebt Daemon-Restarts. +// Zustand greift also auch offline und überlebt Daemon-Restarts. Die +// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das): +// Stummschalten braucht keinen laufenden Prozess. func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { return setMute(true, stdout, stderr) } @@ -72,9 +74,9 @@ func printMuteState(muted bool, stdout io.Writer) { // runStatus implementiert `talk status`: plain Block aus GET /status — // Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State, -// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon: -// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode -// hat keinen Status). +// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon, +// sagt status genau das (Exit 1) und startet ihn nicht — status soll die +// Wirklichkeit zeigen, nicht verändern. func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { cfg, err := config.Load(config.Options{Getenv: getenv}) if err != nil { @@ -86,7 +88,8 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr defer cancel() st, err := client.Status(ctx) if err != nil { - fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL) + fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL) + fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)") return exitFailure } diff --git a/cmd/talk/control_test.go b/cmd/talk/control_test.go index cb6a717..cd67c76 100644 --- a/cmd/talk/control_test.go +++ b/cmd/talk/control_test.go @@ -16,7 +16,9 @@ import ( // Prozessgrenzen-Tests in /test. // TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler -// mit Hinweis (kein Stillversagen). +// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem +// als Status-Zeile, damit der Block nie leer bleibt. Status startet den +// Daemon nicht. func TestStatusOhneDaemon(t *testing.T) { setGetenv(t, nil) var out, errBuf bytes.Buffer @@ -24,11 +26,11 @@ func TestStatusOhneDaemon(t *testing.T) { if code != exitFailure { t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure) } - if !strings.Contains(errBuf.String(), "nicht erreichbar") { + if !strings.Contains(errBuf.String(), "kein Daemon") { t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String()) } - if out.Len() != 0 { - t.Errorf("stdout = %q, will leer", out.String()) + if !strings.HasPrefix(out.String(), "daemon: läuft nicht") { + t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String()) } } @@ -112,7 +114,7 @@ func TestSayOhneConfigDir(t *testing.T) { if _, err := os.Stat(seen); err != nil { t.Errorf("fake espeak nicht gerufen: %v", err) } - if !strings.Contains(errBuf.String(), "Degraded Mode") { + if !strings.Contains(errBuf.String(), "spreche direkt") { t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String()) } } diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go index d83b511..7ae5ebb 100644 --- a/cmd/talk/daemon.go +++ b/cmd/talk/daemon.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "time" "code.m3ta.dev/m3tam3re/talk/internal/api" "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/daemon" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) @@ -19,31 +22,106 @@ import ( const ( daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status) daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe + daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten + daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz ) -// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status, -// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking), -// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder -// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage — -// info sagt „muted". info ist der Writer für Informations-Token (Item-ID, -// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines -// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden. -// -// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den -// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue -// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus -// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon -// angekommen und wurde bewusst abgelehnt. -func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) { - client := api.NewClient(cfg.Server.Port) +// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status +// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst +// mit anderem Status — zählt als „kein Daemon". +func daemonAlive(client *api.Client, timeout time.Duration) bool { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, err := client.Status(ctx) + return err == nil +} - probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout) - defer cancelProbe() - if _, err := client.Status(probeCtx); err != nil { - return 0, false +// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet +// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt: +// kein Daemon erreichbar und keiner startbar (Autostart aus, Start +// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf +// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir. +func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) { + client = api.NewClient(cfg.Server.Port) + if daemonAlive(client, daemonProbeTimeout) { + return client, true } + if !cfg.Daemon.Autostart { + return client, false + } + return client, startDaemon(client, stderr) +} +// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit +// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind +// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller +// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso. +func startDaemon(client *api.Client, stderr io.Writer) bool { + exe, err := os.Executable() + if err != nil { + fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err) + return false + } + logPath := "" + if dir, err := config.Dir(getenv); err == nil { + logPath = filepath.Join(dir, daemon.LogFileName) + if err := os.MkdirAll(dir, 0o755); err != nil { + logPath = "" + } + } + exited, err := daemon.Spawn(exe, logPath, os.Environ()) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return false + } + logHint := "" + if logPath != "" { + logHint = " (Log: " + logPath + ")" + } + deadline := time.Now().Add(daemonStartTimeout) + for { + if daemonAlive(client, daemonStartPoll*4) { + fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint) + return true + } + select { + case err := <-exited: + if daemonAlive(client, daemonProbeTimeout) { + return true // ein anderer Aufruf hat den Daemon gestartet + } + fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint) + return false + case <-time.After(daemonStartPoll): + } + if time.Now().After(deadline) { + fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint) + return false + } + } +} + +// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar. +func exitReason(err error) string { + if err == nil { + return "Prozess hat sich sofort beendet" + } + return err.Error() +} + +// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default: +// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt +// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute +// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den +// Grund. info ist der Writer für diese Informations-Token: say reicht +// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um +// talk-Zeilen verunreinigt werden). +// +// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf +// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit +// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim +// Daemon angekommen und wurde bewusst abgelehnt. +func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) { ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) defer cancel() resp, err := client.Speak(ctx, api.SpeakRequest{ diff --git a/cmd/talk/daemon_cmd.go b/cmd/talk/daemon_cmd.go new file mode 100644 index 0000000..dfe5888 --- /dev/null +++ b/cmd/talk/daemon_cmd.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/daemon" +) + +// daemonStopTimeout ist die Frist, in der ein per /shutdown angestoßener +// Daemon verschwunden sein muss. +const daemonStopTimeout = 5 * time.Second + +// runDaemon implementiert `talk daemon [run|start|stop]`: +// +// - run (Default): Daemon im Vordergrund bis SIGINT/SIGTERM — der Modus +// für systemd/launchd/Task Scheduler oder ein Terminal. Protokoll auf +// stderr. +// - start: Daemon im Hintergrund starten (dasselbe wie der Autostart von +// say, nur explizit und unabhängig von daemon.autostart) und melden, +// wo er läuft. Läuft schon einer: kein Fehler. +// - stop: laufenden Daemon per POST /shutdown herunterfahren und warten, +// bis er weg ist. Läuft keiner: Hinweis, Exit 0 (idempotent). +func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { + sub := "run" + if len(args) > 0 { + sub = args[0] + } + if len(args) > 1 { + fmt.Fprintln(stderr, "talk daemon: zu viele Argumente — Verwendung: talk daemon [run|start|stop]") + return exitUsage + } + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk daemon: %v\n", err) + return exitFailure + } + switch sub { + case "run": + return daemonRun(cfg, stderr) + case "start": + return daemonStart(cfg, stdout, stderr) + case "stop": + return daemonStop(cfg, stdout, stderr) + default: + fmt.Fprintf(stderr, "talk daemon: unbekanntes Unterkommando %q (erlaubt: run, start, stop)\n", sub) + return exitUsage + } +} + +func daemonRun(cfg config.Config, stderr io.Writer) int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err := daemon.Run(ctx, daemon.Options{ + Config: cfg, + Getenv: getenv, + Version: buildVersion(), + Log: stderr, + }) + if err != nil { + fmt.Fprintf(stderr, "talk daemon: %v\n", err) + return exitFailure + } + return exitOK +} + +func daemonStart(cfg config.Config, stdout, stderr io.Writer) int { + client := api.NewClient(cfg.Server.Port) + if daemonAlive(client, daemonProbeTimeout) { + fmt.Fprintf(stdout, "daemon: läuft bereits unter %s\n", client.BaseURL) + return exitOK + } + if !startDaemon(client, stderr) { + return exitFailure + } + fmt.Fprintf(stdout, "daemon: gestartet unter %s\n", client.BaseURL) + return exitOK +} + +func daemonStop(cfg config.Config, stdout, stderr io.Writer) int { + client := api.NewClient(cfg.Server.Port) + if !daemonAlive(client, daemonProbeTimeout) { + fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL) + return exitOK + } + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + if err := client.Shutdown(ctx); err != nil { + fmt.Fprintf(stderr, "talk daemon stop: %v\n", err) + return exitFailure + } + // Warten, bis der Daemon wirklich weg ist: erst verstummt HTTP, dann + // bricht er die laufende Ansage ab und löscht als Letztes die PID-Datei. + // Ohne Config-Dir (keine PID-Datei) genügt das HTTP-Ende. + pidDir := "" + if dir, err := config.Dir(getenv); err == nil { + pidDir = dir + } + deadline := time.Now().Add(daemonStopTimeout) + for daemonAlive(client, daemonStartPoll*4) || (pidDir != "" && daemon.ReadPID(pidDir) != 0) { + if time.Now().After(deadline) { + fmt.Fprintf(stderr, "talk daemon stop: Daemon ist nach %s noch nicht beendet — PID siehe %s im Config-Verzeichnis\n", + daemonStopTimeout, daemon.PIDFileName) + return exitFailure + } + time.Sleep(daemonStartPoll) + } + fmt.Fprintln(stdout, "daemon: gestoppt") + return exitOK +} diff --git a/cmd/talk/degraded.go b/cmd/talk/degraded.go deleted file mode 100644 index 5fad80c..0000000 --- a/cmd/talk/degraded.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "code.m3ta.dev/m3tam3re/talk/internal/audio" - "code.m3ta.dev/m3tam3re/talk/internal/config" - "code.m3ta.dev/m3tam3re/talk/internal/langdetect" - "code.m3ta.dev/m3tam3re/talk/internal/provider" - "code.m3ta.dev/m3tam3re/talk/internal/queue" -) - -// Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen; -// nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir -// serialisiert konkurrierende talk-Prozesse; Kollision heißt warten. -const ( - lockFileName = "talk.lock" - lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais - lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten - lockPollInterval = 50 * time.Millisecond -) - -// sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe -// Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren, -// dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im -// Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das -// Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und -// Mute weiter, statt zu scheitern. info ist der Writer für Informations- -// Token („muted"): say reicht stdout, wrap stderr durch. -func sayDegraded(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int { - if dir, err := config.Dir(getenv); err == nil { - mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)} - if mute.Muted() { - fmt.Fprintln(info, "muted") - fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") - return exitOK - } - release, err := acquireLock(filepath.Join(dir, lockFileName)) - if err != nil { - fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err) - } else { - defer release() - } - } - - lang := opts.lang - if lang == "" { - lang = langdetect.Detect(text) - } - - sink, err := audio.NewSink(cfg.Audio.Sink, stderr) - if err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - - chain, err := provider.NewChain(provider.ChainConfig{ - Pin: pickProvider(opts.provider, cfg.Provider), - Voice: opts.voice, - Voices: cfg.Voice, - Endpoints: endpointsOf(cfg), - Model: cfg.Providers["openai"].Model, - Getenv: getenv, - Player: sink, - Log: stderr, - }) - if err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - - if err := chain.Speak(context.Background(), text, lang); err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - return exitOK -} - -// acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine -// stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt -// (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach -// lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion -// (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt — -// Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe -// nur bis zur Stale-Frist. -func acquireLock(path string) (release func(), err error) { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, err - } - deadline := time.Now().Add(lockWaitTimeout) - for { - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err == nil { - return func() { - f.Close() - _ = os.Remove(path) - }, nil - } - if !errors.Is(err, os.ErrExist) { - return nil, err - } - if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter { - _ = os.Remove(path) - continue - } - if !time.Now().Before(deadline) { - return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path) - } - time.Sleep(lockPollInterval) - } -} diff --git a/cmd/talk/main.go b/cmd/talk/main.go index b12a5fe..d52a4b0 100644 --- a/cmd/talk/main.go +++ b/cmd/talk/main.go @@ -1,17 +1,20 @@ -// talk ist die CLI für Sprach-Ausgaben (talk 2.0). +// talk ist das eine Binary von talk 2.0: CLI und Daemon. // -// Dieser Stand (Task 07): say (und der Default-Aufruf `talk "Text"`) reicht -// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur -// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded -// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/ -// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- ` -// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code -// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die -// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den -// Tasks 08–09. +// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon +// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt +// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund +// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne +// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run` +// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit. +// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output; +// `talk wrap -- ` führt Befehle aus und sagt das Ergebnis an. Die +// übrigen Befehle sind Stubs. package main -import "os" +import ( + "fmt" + "os" +) // Build-Informationen. Werden beim Release per -ldflags injiziert, z. B. // @@ -23,6 +26,15 @@ var ( commit = "" ) +// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert +// — der Wert, den der Daemon in /status und MCP serverInfo meldet. +func buildVersion() string { + if commit == "" { + return version + } + return fmt.Sprintf("%s (%s)", version, commit) +} + func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } diff --git a/cmd/talk/say.go b/cmd/talk/say.go index f4dd7ae..f132b84 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -1,13 +1,16 @@ package main import ( + "context" "fmt" "io" "os" + "path/filepath" "strings" "code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/queue" + "code.m3ta.dev/m3tam3re/talk/internal/speak" ) // getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK, @@ -27,13 +30,11 @@ type sayOptions struct { } // runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln -// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd -// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking -// als Default, --wait pollt bis zur Fertigstellung, --now springt vor, -// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein -// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal, -// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden -// Pfarden dieselbe (internal/provider, unverändert). +// und validieren, dann über den Daemon sprechen — non-blocking als +// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft +// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das +// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne +// Warteschlange, aber nie still. func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { text := strings.Join(args, " ") if text == "" { @@ -64,13 +65,52 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io. fmt.Fprintf(stderr, "talk: %v\n", err) return exitFailure } + return announce(cfg, text, opts, stdout, stderr) +} - if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled { - return code +// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher- +// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info +// ist der Writer für Informations-Token (Item-ID, muted, discarded). +func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int { + if client, ok := ensureDaemon(cfg, stderr); ok { + if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled { + return code + } } + fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)") + return sayDirect(cfg, text, opts, info, stderr) +} - fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)") - return sayDegraded(cfg, text, opts, stdout, stderr) +// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe +// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen — +// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe +// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft +// es ohne Mute weiter, statt zu scheitern. +func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int { + if dir, err := config.Dir(getenv); err == nil { + mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)} + if mute.Muted() { + fmt.Fprintln(info, "muted") + fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") + return exitOK + } + } + engine, err := speak.New(cfg, getenv, stderr) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + err = engine.Speak(context.Background(), speak.Request{ + Text: text, + Lang: opts.lang, + Provider: opts.provider, + Voice: opts.voice, + }) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + return exitOK } // validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de | @@ -88,27 +128,6 @@ func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) { return 0, false } -// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt -// `provider = "…"` aus der config.toml; leer = komplette Default-Kette. -func pickProvider(flagValue, cfgValue string) string { - if flagValue != "" { - return flagValue - } - return cfgValue -} - -// endpointsOf überführt die Provider-Einstellungen der Config in die -// Endpunkt-Map der Kette. -func endpointsOf(cfg config.Config) map[string]string { - endpoints := make(map[string]string, len(cfg.Providers)) - for name, settings := range cfg.Providers { - if settings.Endpoint != "" { - endpoints[name] = settings.Endpoint - } - } - return endpoints -} - // isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf // stdin-Input warten, sondern meldet einen Benutzungsfehler). func isCharDevice(r io.Reader) bool { diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go index 9854f7b..7babc4f 100644 --- a/cmd/talk/say_test.go +++ b/cmd/talk/say_test.go @@ -16,14 +16,16 @@ import ( // Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt // anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung // von say zeigt auf einen toten Port, damit die Tests hier Deterministisch -// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd -// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt +// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon +// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary +// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt // den Key. func setGetenv(t *testing.T, kv map[string]string) { t.Helper() base := map[string]string{ "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", "TALK_PORT": "1", + "TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten } for k, v := range kv { base[k] = v diff --git a/cmd/talk/wrap.go b/cmd/talk/wrap.go index db128d9..2ce8363 100644 --- a/cmd/talk/wrap.go +++ b/cmd/talk/wrap.go @@ -22,8 +22,8 @@ const exitNotFound = 127 // sagt danach das Ergebnis an: „