ScuttleBot

feat: implement wire format envelope (pkg/protocol)

lmata 2026-03-31 04:47 trunk
Commit 60feebc5bcdea46d39fcfa646a415d45b132bff53cd9bfda07d350bb6682d65f
M go.mod
+2
--- go.mod
+++ go.mod
@@ -1,3 +1,5 @@
11
module github.com/conflicthq/scuttlebot
22
33
go 1.26.1
4
+
5
+require github.com/oklog/ulid/v2 v2.1.1
46
57
ADDED go.sum
--- go.mod
+++ go.mod
@@ -1,3 +1,5 @@
1 module github.com/conflicthq/scuttlebot
2
3 go 1.26.1
 
 
4
5 DDED go.sum
--- go.mod
+++ go.mod
@@ -1,3 +1,5 @@
1 module github.com/conflicthq/scuttlebot
2
3 go 1.26.1
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 DDED go.sum
A go.sum
+4
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,4 @@
1
+Ob4Z+uGqUqGaJmn9CehxcKcls=
2
+github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
3
+github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
4
+github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,4 @@
 
 
 
 
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,4 @@
1 Ob4Z+uGqUqGaJmn9CehxcKcls=
2 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
3 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
4 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -1,1 +1,109 @@
1
+// Package protocol defines the scuttlebot wire format.
2
+//
3
+// Agent messages are JSON envelopes sent as IRC PRIVMSG.
4
+// System/status messages use NOTICE and are human-readable only.
15
package protocol
6
+
7
+import (
8
+ "encoding/json"
9
+ "fmt"
10
+ "math/rand"
11
+ "time"
12
+
13
+ "github.com/oklog/ulid/v2"
14
+)
15
+
16
+// Version is the current envelope version.
17
+const Version = 1
18
+
19
+// Message types.
20
+const (
21
+ TypeTaskCreate = "task.create"
22
+ TypeTaskUpdate = "task.update"
23
+ TypeTaskComplete = "task.complete"
24
+ TypeAgentHello = "agent.hello"
25
+ TypeAgentBye = "agent.bye"
26
+)
27
+
28
+// Envelope is the standard wrapper for all agent messages over IRC.
29
+type Envelope struct {
30
+ V int `json:"v"`
31
+ Type string `json:"type"`
32
+ ID string `json:"id"`
33
+ From string `json:"from"`
34
+ TS int64 `json:"ts"`
35
+ Payload json.RawMessage `json:"payload,omitempty"`
36
+}
37
+
38
+// New creates a new Envelope with a generated ID and current timestamp.
39
+func New(msgType, from string, payload any) (*Envelope, error) {
40
+ var raw json.RawMessage
41
+ if payload != nil {
42
+ b, err := json.Marshal(payload)
43
+ if err != nil {
44
+ return nil, fmt.Errorf("protocol: marshal payload: %w", err)
45
+ }
46
+ raw = b
47
+ }
48
+ return &Envelope{
49
+ V: Version,
50
+ Type: msgType,
51
+ ID: newID(),
52
+ From: from,
53
+ TS: time.Now().UnixMilli(),
54
+ Payload: raw,
55
+ }, nil
56
+}
57
+
58
+// Marshal encodes the envelope to JSON.
59
+func Marshal(e *Envelope) ([]byte, error) {
60
+ b, err := json.Marshal(e)
61
+ if err != nil {
62
+ return nil, fmt.Errorf("protocol: marshal envelope: %w", err)
63
+ }
64
+ return b, nil
65
+}
66
+
67
+// Unmarshal decodes a JSON envelope and validates it.
68
+func Unmarshal(data []byte) (*Envelope, error) {
69
+ var e Envelope
70
+ if err := json.Unmarshal(data, &e); err != nil {
71
+ return nil, fmt.Errorf("protocol: unmarshal envelope: %w", err)
72
+ }
73
+ if err := validate(&e); err != nil {
74
+ return nil, err
75
+ }
76
+ return &e, nil
77
+}
78
+
79
+// UnmarshalPayload decodes the envelope payload into dst.
80
+func UnmarshalPayload(e *Envelope, dst any) error {
81
+ if len(e.Payload) == 0 {
82
+ return nil
83
+ }
84
+ if err := json.Unmarshal(e.Payload, dst); err != nil {
85
+ return fmt.Errorf("protocol: unmarshal payload: %w", err)
86
+ }
87
+ return nil
88
+}
89
+
90
+func validate(e *Envelope) error {
91
+ if e.V != Version {
92
+ return fmt.Errorf("protocol: unsupported version %d", e.V)
93
+ }
94
+ if e.Type == "" {
95
+ return fmt.Errorf("protocol: missing type")
96
+ }
97
+ if e.ID == "" {
98
+ return fmt.Errorf("protocol: missing id")
99
+ }
100
+ if e.From == "" {
101
+ return fmt.Errorf("protocol: missing from")
102
+ }
103
+ return nil
104
+}
105
+
106
+func newID() string {
107
+ entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
108
+ return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
109
+}
2110
3111
ADDED pkg/protocol/protocol_test.go
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -1,1 +1,109 @@
 
 
 
 
1 package protocol
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED pkg/protocol/protocol_test.go
--- pkg/protocol/protocol.go
+++ pkg/protocol/protocol.go
@@ -1,1 +1,109 @@
1 // Package protocol defines the scuttlebot wire format.
2 //
3 // Agent messages are JSON envelopes sent as IRC PRIVMSG.
4 // System/status messages use NOTICE and are human-readable only.
5 package protocol
6
7 import (
8 "encoding/json"
9 "fmt"
10 "math/rand"
11 "time"
12
13 "github.com/oklog/ulid/v2"
14 )
15
16 // Version is the current envelope version.
17 const Version = 1
18
19 // Message types.
20 const (
21 TypeTaskCreate = "task.create"
22 TypeTaskUpdate = "task.update"
23 TypeTaskComplete = "task.complete"
24 TypeAgentHello = "agent.hello"
25 TypeAgentBye = "agent.bye"
26 )
27
28 // Envelope is the standard wrapper for all agent messages over IRC.
29 type Envelope struct {
30 V int `json:"v"`
31 Type string `json:"type"`
32 ID string `json:"id"`
33 From string `json:"from"`
34 TS int64 `json:"ts"`
35 Payload json.RawMessage `json:"payload,omitempty"`
36 }
37
38 // New creates a new Envelope with a generated ID and current timestamp.
39 func New(msgType, from string, payload any) (*Envelope, error) {
40 var raw json.RawMessage
41 if payload != nil {
42 b, err := json.Marshal(payload)
43 if err != nil {
44 return nil, fmt.Errorf("protocol: marshal payload: %w", err)
45 }
46 raw = b
47 }
48 return &Envelope{
49 V: Version,
50 Type: msgType,
51 ID: newID(),
52 From: from,
53 TS: time.Now().UnixMilli(),
54 Payload: raw,
55 }, nil
56 }
57
58 // Marshal encodes the envelope to JSON.
59 func Marshal(e *Envelope) ([]byte, error) {
60 b, err := json.Marshal(e)
61 if err != nil {
62 return nil, fmt.Errorf("protocol: marshal envelope: %w", err)
63 }
64 return b, nil
65 }
66
67 // Unmarshal decodes a JSON envelope and validates it.
68 func Unmarshal(data []byte) (*Envelope, error) {
69 var e Envelope
70 if err := json.Unmarshal(data, &e); err != nil {
71 return nil, fmt.Errorf("protocol: unmarshal envelope: %w", err)
72 }
73 if err := validate(&e); err != nil {
74 return nil, err
75 }
76 return &e, nil
77 }
78
79 // UnmarshalPayload decodes the envelope payload into dst.
80 func UnmarshalPayload(e *Envelope, dst any) error {
81 if len(e.Payload) == 0 {
82 return nil
83 }
84 if err := json.Unmarshal(e.Payload, dst); err != nil {
85 return fmt.Errorf("protocol: unmarshal payload: %w", err)
86 }
87 return nil
88 }
89
90 func validate(e *Envelope) error {
91 if e.V != Version {
92 return fmt.Errorf("protocol: unsupported version %d", e.V)
93 }
94 if e.Type == "" {
95 return fmt.Errorf("protocol: missing type")
96 }
97 if e.ID == "" {
98 return fmt.Errorf("protocol: missing id")
99 }
100 if e.From == "" {
101 return fmt.Errorf("protocol: missing from")
102 }
103 return nil
104 }
105
106 func newID() string {
107 entropy := ulid.Monotonic(rand.New(rand.NewSource(time.Now().UnixNano())), 0) //nolint:gosec
108 return ulid.MustNew(ulid.Timestamp(time.Now()), entropy).String()
109 }
110
111 DDED pkg/protocol/protocol_test.go
--- a/pkg/protocol/protocol_test.go
+++ b/pkg/protocol/protocol_test.go
@@ -0,0 +1,60 @@
1
+package protocol_test
2
+
3
+import (
4
+ "encoding/json"
5
+ "testing"
6
+
7
+ "github.com/conflicthq/scuttlebot/pkg/protocol"
8
+)
9
+
10
+func TestRoundTrip(t *testing.T) {
11
+ type testPayload struct {
12
+ Task string `json:"task"`
13
+ }
14
+
15
+ env, err := protocol.New(protocol.TypeTaskCreate, "claude-01", testPayload{Task: "write tests"})
16
+ if err != nil {
17
+ t.Fatalf("New: %v", err)
18
+ }
19
+
20
+ data, err := protocol.Marshal(env)
21
+ if err != nil {
22
+ t.Fatalf("Marshal: %v", err)
23
+ }
24
+
25
+ got, err := protocol.Unmarshal(data)
26
+ if err != nil {
27
+ t.Fatalf("Unmarshal: %v", err)
28
+ }
29
+
30
+ if got.V != protocol.Version {
31
+ t.Errorf("V: got %d, want %d", got.V, protocol.Version)
32
+ }
33
+ if got.Type != protocol.TypeTaskCreate {
34
+ t.Errorf("Type: got %q, want %q", got.Type, protocol.TypeTaskCreate)
35
+ }
36
+ if got.ID == "" {
37
+ t.Error("ID is empAllMessageTypes(t *testing.T) {
38
+ types := []string{
39
+ protocol.TypeTaskCreate,
40
+ protocol.TypeTaskUpdate,
41
+ protocol.TypeTaskComplete,
42
+ protocol.TypeAgentHello,
43
+ protocol.TypeAgentBye,
44
+ }
45
+ for _, msgType := range types {
46
+ t.Run(msgType, func(t *testing.T) {
47
+ env, err := protocol.New(msgType, "agent", json.RawMessage(`{"key":"val"}`))
48
+ if err != nil {
49
+ t.Fatalf("New: %v", err)
50
+ }
51
+ data, err := protocol.Marshal(env)
52
+ if err != nil {
53
+ t.Fatalf("Marshal: %v", err)
54
+ }
55
+ got, err := protocol.Unmarshal(data)
56
+ if err != nil {
57
+ t.Fatalf("Unmarshal: %v", err)
58
+ }
59
+ if got.Type != msgType {
60
+ t.Errorf("Type: got %q, want %q", got.Typ
--- a/pkg/protocol/protocol_test.go
+++ b/pkg/protocol/protocol_test.go
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/protocol/protocol_test.go
+++ b/pkg/protocol/protocol_test.go
@@ -0,0 +1,60 @@
1 package protocol_test
2
3 import (
4 "encoding/json"
5 "testing"
6
7 "github.com/conflicthq/scuttlebot/pkg/protocol"
8 )
9
10 func TestRoundTrip(t *testing.T) {
11 type testPayload struct {
12 Task string `json:"task"`
13 }
14
15 env, err := protocol.New(protocol.TypeTaskCreate, "claude-01", testPayload{Task: "write tests"})
16 if err != nil {
17 t.Fatalf("New: %v", err)
18 }
19
20 data, err := protocol.Marshal(env)
21 if err != nil {
22 t.Fatalf("Marshal: %v", err)
23 }
24
25 got, err := protocol.Unmarshal(data)
26 if err != nil {
27 t.Fatalf("Unmarshal: %v", err)
28 }
29
30 if got.V != protocol.Version {
31 t.Errorf("V: got %d, want %d", got.V, protocol.Version)
32 }
33 if got.Type != protocol.TypeTaskCreate {
34 t.Errorf("Type: got %q, want %q", got.Type, protocol.TypeTaskCreate)
35 }
36 if got.ID == "" {
37 t.Error("ID is empAllMessageTypes(t *testing.T) {
38 types := []string{
39 protocol.TypeTaskCreate,
40 protocol.TypeTaskUpdate,
41 protocol.TypeTaskComplete,
42 protocol.TypeAgentHello,
43 protocol.TypeAgentBye,
44 }
45 for _, msgType := range types {
46 t.Run(msgType, func(t *testing.T) {
47 env, err := protocol.New(msgType, "agent", json.RawMessage(`{"key":"val"}`))
48 if err != nil {
49 t.Fatalf("New: %v", err)
50 }
51 data, err := protocol.Marshal(env)
52 if err != nil {
53 t.Fatalf("Marshal: %v", err)
54 }
55 got, err := protocol.Unmarshal(data)
56 if err != nil {
57 t.Fatalf("Unmarshal: %v", err)
58 }
59 if got.Type != msgType {
60 t.Errorf("Type: got %q, want %q", got.Typ

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button