08512ad58b
Two review threads from PR 4, and the answer to the third. The routine status was a bare string with its legal set in a comment. Nothing caught a typo at compile time, nothing enumerated the set for a test, and a bad value surfaced as a /routines row that neither accepts nor dismisses. It is a RoutineStatus now, with the three constants, a RoutineStatuses slice as the single source of truth, and Valid(). Listing by an unknown status is refused with ErrRoutineStatus instead of answering "no rows", which is what a correct query says about an empty table. A round-trip test moves a routine into each state and reads it back, so a constant that drifts from the inline SQL fails loudly. The hand-rolled framing stays, and frame.go now says why: ninety lines, readable with socat, and every standard replacement brings schema machinery this boundary does not want. What was wrong was inheriting it untested. frame_test.go covers the paths a real socket produces and the round-trip test never does — truncated header, truncated body, one byte per Read, two frames back to back, and a non-JSON body. Empty input is the only EOF. The unanswered question in the same file is answered in place: a routine object stays a local string, not a Nexus ref, because nothing acts on it. It is the word he used, replayed back to him, compared only against itself for the UNIQUE key. Canonical refs arrive if a routine ever drives a Hexis call, which is V-272. The mood enum has the same shape and is not done here: it is spelled in the GBNF grammar, three prompts and the parse, so it is its own change.
94 lines
3.7 KiB
Go
94 lines
3.7 KiB
Go
// Package ipc is maven's core↔module boundary.
|
|
//
|
|
// Core = the only key-holder: the daemon process holds the unlocked sqlcipher
|
|
// db + the trigger loop. Modules (stt/tts, router/classifier, tool executors,
|
|
// delivery) are separate processes — restart-free, key-free, fail-independent.
|
|
// "a crashing tts can't read the key page" only holds if there IS a page
|
|
// boundary between core and modules; this package IS that boundary.
|
|
//
|
|
// Transport: unix domain socket, local-only. The socket's filesystem perms
|
|
// (0700 dir, 0600 socket) are the current auth floor — "same unix user" —
|
|
// carrying the same instinct as wg-floor at the network radius. The full
|
|
// 4-layer auth cascade (wg/mTLS/passkey/step-up) is a later module; IPC
|
|
// threads a Caller (uid/pid via SO_PEERCRED) so the auth layer can scope
|
|
// module authority without restructuring the wire.
|
|
//
|
|
// Core mediates, never hands back a db handle. The methods here are the only
|
|
// state operations a module can perform: write a fact (provenance-scoped by
|
|
// source), read a fact / presence, create/complete reminders, record/resolve
|
|
// nudges. Anything needing raw db access lives in core and is unreachable.
|
|
package ipc
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// maxFrame — 4 MiB. a single fact/reminder/nudge is tiny; this is a safety cap
|
|
// against a confused peer sending a terabyte of length prefix, not a real
|
|
// operational limit. away-channel bodies are minimal by spec.
|
|
const maxFrame = 4 << 20
|
|
|
|
// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now desynchronized
|
|
// (we read the length but not the body), so the caller must close it.
|
|
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
|
|
|
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
|
|
// lines, readable on the wire with socat, and every standard replacement
|
|
// brings schema machinery this boundary does not want. The condition is that
|
|
// it is defended by tests rather than inherited untested — truncated header,
|
|
// truncated body, partial reads, frame boundaries and a non-JSON body all
|
|
// live in frame_test.go.
|
|
//
|
|
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
|
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
|
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
|
// evolve while the protocol settles, and at single-user local scale the
|
|
// encode cost is invisible next to a db round-trip.
|
|
func writeFrame(w io.Writer, v any) error {
|
|
body, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("ipc: marshal frame: %w", err)
|
|
}
|
|
if len(body) > maxFrame {
|
|
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body))
|
|
}
|
|
var hdr [4]byte
|
|
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
|
if _, err := w.Write(hdr[:]); err != nil {
|
|
return fmt.Errorf("ipc: write frame header: %w", err)
|
|
}
|
|
if _, err := w.Write(body); err != nil {
|
|
return fmt.Errorf("ipc: write frame body: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readFrame reads one length-prefixed frame into v. A zero-length frame is
|
|
// legal JSON (e.g. `null`/`{}` encode to a few bytes, never zero) — we don't
|
|
// treat it as EOF; only an EOF on the header read does.
|
|
func readFrame(r io.Reader, v any) error {
|
|
var hdr [4]byte
|
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return io.EOF
|
|
}
|
|
return fmt.Errorf("ipc: read frame header: %w", err)
|
|
}
|
|
n := binary.BigEndian.Uint32(hdr[:])
|
|
if n > maxFrame {
|
|
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n)
|
|
}
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
return fmt.Errorf("ipc: read frame body: %w", err)
|
|
}
|
|
if err := json.Unmarshal(buf, v); err != nil {
|
|
return fmt.Errorf("ipc: unmarshal frame: %w", err)
|
|
}
|
|
return nil
|
|
}
|