Files
Maven/internal/worker/wire.go
T
2026-07-03 00:32:48 +02:00

123 lines
4.6 KiB
Go

// Package worker is the audio-job boundary between core and its stt/tts
// modules.
//
// It is a SIBLING to internal/ipc, not a reuse of it:
//
// - internal/ipc is the core↔module *state* boundary. Modules call INTO
// core to write facts / read presence. core is the server; the module
// is the client. The verbs (WriteFact, Since, ...) are authority-bound;
// the auth layer scopes them per Caller.
// - internal/worker is the core↔module *job* boundary. Core calls OUT to
// the stt/tts module: "transcribe these bytes", "synthesize this text."
// Core is the client here; the worker module is the server. The verbs
// are not authority-bound — they are pure compute over the payload core
// already owns (audio bytes / reply text). Nothing about authority moves
// across this boundary because the worker has no key, no facts, nothing
// stateful to read or write.
//
// The reversal matters: a module that ships audio work to core would have
// to hold a Caller identity, an enrolled daemon surface, etc.; core that
// ships work to a module ships only the work. Same unix-socket floor (0600
// dir + socket perms carry "same user") but the flow is reversed, and the
// surface stays out of the auth cascade — the worker never reaches into
// core's state, so it has nothing to be capped against.
//
// Transport: unix domain socket, local-only, same-host as core. Same wire
// shape as ipc (4-byte big-endian length + JSON body) so `socat`/`nc` can
// debug it identically. Frame cap is 64 MiB (vs ipc's 4) so audio blobs fit
// — a minute of 16k mono int16 is ~1.9 MiB, comfortably under; an hour's
// sleep-clip transcription is implausible to send as one frame but the cap
// permits long-enough live clips without framing the worker into chunks.
package worker
import (
"encoding/json"
"errors"
"fmt"
)
// maxFrame — 64 MiB. Audio up to ~3 minutes @ 16k mono int16 fits one frame
// with headroom; longer audio is chunked by the caller (meeting-record mode
// is post-MVP) or rejected with ErrFrameTooLarge. Sized for stt inputs and
// tts outputs at single-utterance scale.
const maxFrame = 64 << 20
// Method — one RPC verb. Adding one is a worker-API change, not an authority
// change (worker has no authority surface — see package doc). The two verbs
// mirror the stt/tts interfaces the daemon wires; new verbs go with a new
// module kind (e.g. translation), not a new feature of an existing one.
type Method string
const (
MethodTranscribe Method = "transcribe"
MethodSynthesize Method = "synthesize"
)
// Request — one frame from core to a worker module.
type Request struct {
Method Method `json:"m"`
Params json.RawMessage `json:"p,omitempty"`
}
// Response — one frame back from the worker. Exactly one of Result/Error is set.
type Response struct {
Result json.RawMessage `json:"r,omitempty"`
Error *RpcError `json:"e,omitempty"`
}
// RpcError — a typed wire error. Mirrors internal/ipc's shape for tooling
// parity (same args to socat, same `errors.Is` rehydration pattern); worker
// sentinels are independent since this boundary is independent.
type RpcError struct {
Code string `json:"c"`
Message string `json:"m,omitempty"`
}
func (e *RpcError) Error() string {
if e.Message != "" {
return fmt.Sprintf("worker: %s: %s", e.Code, e.Message)
}
return fmt.Sprintf("worker: %s", e.Code)
}
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
// sentinels 1:1.
const (
codeUnknownMethod = "unknown_method"
codeBadParams = "bad_params"
codeInternal = "internal"
)
// Sentinel errors. The client rehydrates a wire RpcError into one of these so
// callers use errors.Is the same way they would in-process.
var (
ErrUnknownMethod = errors.New("worker: unknown method")
ErrBadParams = errors.New("worker: bad params")
)
// codeOf maps a server-side sentinel to its wire code. Anything not matched
// is codeInternal — internal Go error text never ships to the caller; the
// server logs the real text and the client sees a generic code.
func codeOf(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, ErrUnknownMethod):
return codeUnknownMethod
case errors.Is(err, ErrBadParams):
return codeBadParams
default:
return codeInternal
}
}
// rpcErr builds the wire error for a server-side error. message is omitted
// for sentinel codes (the Code carries the meaning). internal errors carry
// the message text — it's not authority-bearing text, just a diagnostic.
func rpcErr(err error) *RpcError {
c := codeOf(err)
if c == codeInternal || c == codeBadParams {
return &RpcError{Code: c, Message: err.Error()}
}
return &RpcError{Code: c}
}