Layer 1 keystone. The internal/inbox package writes ping-shaped JSONL
events to recipient inbox files in a format bit-identical to the
agent-ping CLI's output, so the existing UserPromptSubmit hook and the
future MCP Watcher cannot tell whether a line came from `ping` or the
Collector.
- O_APPEND opens for atomic line writes (POSIX guarantees writes <=
PIPE_BUF, our lines are well under).
- Per-recipient sync.Mutex bounds contention; multiple goroutines
writing to one inbox stay correctly serialized.
- 7 tests passing: shape, ID/TS preservation, omitempty for optional
fields, key-set + compactness match against ping CLI's separators=
(",",":") output, 100-goroutine concurrent-write torn-line check,
bad-input rejection, empty-dir rejection.
go.mod at git.botbought.ai/foreman/agent-watcher; module name matches
the public Forgejo path so eventual consumers can `go get` it.
Next milestones:
- Source plugin interface
- Drop folder source (inotify, via fsnotify)
- HTTP webhook source
- Config loader (YAML)
- main.go wiring
- systemd unit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
4.4 KiB
Go
182 lines
4.4 KiB
Go
package inbox
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestWriter_Write_BasicShape(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w, err := NewWriter(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := w.Write("bob", &Event{
|
|
From: "foreman",
|
|
Type: "INFO",
|
|
Priority: "normal",
|
|
Payload: "hi",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := readLines(t, filepath.Join(dir, "bob.inbox"))
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 line, got %d", len(got))
|
|
}
|
|
|
|
var ev Event
|
|
if err := json.Unmarshal([]byte(got[0]), &ev); err != nil {
|
|
t.Fatalf("not JSON: %v", err)
|
|
}
|
|
if ev.To != "bob" {
|
|
t.Errorf("To = %q, want bob", ev.To)
|
|
}
|
|
if ev.From != "foreman" {
|
|
t.Errorf("From = %q, want foreman", ev.From)
|
|
}
|
|
if ev.Payload != "hi" {
|
|
t.Errorf("Payload = %q", ev.Payload)
|
|
}
|
|
if !strings.HasPrefix(ev.ID, "ping-") {
|
|
t.Errorf("ID %q missing ping- prefix", ev.ID)
|
|
}
|
|
if len(ev.ID) != len("ping-")+8 {
|
|
t.Errorf("ID %q wrong length", ev.ID)
|
|
}
|
|
if ev.TS == "" {
|
|
t.Errorf("TS unset")
|
|
}
|
|
}
|
|
|
|
func TestWriter_Write_PreservesProvidedIDAndTS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w, err := NewWriter(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := &Event{
|
|
TS: "2026-05-06T12:00:00Z",
|
|
ID: "ping-deadbeef",
|
|
From: "x",
|
|
Type: "INFO",
|
|
Priority: "normal",
|
|
Payload: "p",
|
|
}
|
|
if err := w.Write("r", want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := readLines(t, filepath.Join(dir, "r.inbox"))
|
|
var ev Event
|
|
json.Unmarshal([]byte(got[0]), &ev)
|
|
if ev.ID != want.ID {
|
|
t.Errorf("ID overwritten: got %q want %q", ev.ID, want.ID)
|
|
}
|
|
if ev.TS != want.TS {
|
|
t.Errorf("TS overwritten: got %q want %q", ev.TS, want.TS)
|
|
}
|
|
}
|
|
|
|
func TestWriter_Write_OmitsEmptyOptionalFields(t *testing.T) {
|
|
dir := t.TempDir()
|
|
w, _ := NewWriter(dir)
|
|
w.Write("r", &Event{From: "f", Type: "INFO", Priority: "normal", Payload: "p"})
|
|
|
|
line := readLines(t, filepath.Join(dir, "r.inbox"))[0]
|
|
if strings.Contains(line, "sentinel") {
|
|
t.Errorf("empty sentinel leaked into JSON: %s", line)
|
|
}
|
|
if strings.Contains(line, "source") {
|
|
t.Errorf("empty source leaked into JSON: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestWriter_Write_PingShapeMatchesCLI(t *testing.T) {
|
|
// The ping CLI emits JSON with separators=(",", ":") (compact) and these
|
|
// keys in this order: ts, id, from, to, type, priority, payload [, sentinel].
|
|
// Verify our marshaled output preserves the key set and compactness.
|
|
dir := t.TempDir()
|
|
w, _ := NewWriter(dir)
|
|
w.Write("r", &Event{
|
|
From: "f", Type: "INFO", Priority: "normal", Payload: "p", Sentinel: "/x",
|
|
})
|
|
line := strings.TrimSpace(readLines(t, filepath.Join(dir, "r.inbox"))[0])
|
|
|
|
wantKeys := []string{`"ts":`, `"id":`, `"from":`, `"to":`, `"type":`, `"priority":`, `"payload":`, `"sentinel":`}
|
|
for _, k := range wantKeys {
|
|
if !strings.Contains(line, k) {
|
|
t.Errorf("missing key %s in: %s", k, line)
|
|
}
|
|
}
|
|
// compact (no spaces around colons or commas)
|
|
if strings.Contains(line, ": ") || strings.Contains(line, ", ") {
|
|
t.Errorf("non-compact JSON: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestWriter_Write_ConcurrentSameRecipient(t *testing.T) {
|
|
// 100 goroutines writing to the same inbox should produce 100 valid JSON
|
|
// lines, none torn or interleaved.
|
|
dir := t.TempDir()
|
|
w, _ := NewWriter(dir)
|
|
|
|
const n = 100
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
w.Write("r", &Event{From: "f", Type: "INFO", Priority: "normal", Payload: "x"})
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
lines := readLines(t, filepath.Join(dir, "r.inbox"))
|
|
if len(lines) != n {
|
|
t.Fatalf("got %d lines, want %d (torn writes?)", len(lines), n)
|
|
}
|
|
for i, l := range lines {
|
|
var ev Event
|
|
if err := json.Unmarshal([]byte(l), &ev); err != nil {
|
|
t.Errorf("line %d not JSON: %v: %s", i, err, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriter_Write_RejectsBadInputs(t *testing.T) {
|
|
w, _ := NewWriter(t.TempDir())
|
|
if err := w.Write("", &Event{}); err == nil {
|
|
t.Error("empty recipient: expected error")
|
|
}
|
|
if err := w.Write("r", nil); err == nil {
|
|
t.Error("nil event: expected error")
|
|
}
|
|
}
|
|
|
|
func TestNewWriter_RejectsEmptyDir(t *testing.T) {
|
|
if _, err := NewWriter(""); err == nil {
|
|
t.Error("expected error for empty dir")
|
|
}
|
|
}
|
|
|
|
func readLines(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := []string{}
|
|
for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") {
|
|
if l != "" {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
return out
|
|
}
|