diff --git a/PROTOCOL.md b/PROTOCOL.md index eef1012..f7e812d 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -221,6 +221,52 @@ reconnect recovery happens by reloading persisted messages by session and sequence from the database. There is no daemon ↔ relay ack/replay/WAL handshake in protocol version 1. +### `planningEvent` + +Append-only planning journal event sent from a source runtime to the relay. + +| Field | Type | Notes | +|---|---|---| +| type | "planningEvent" | | +| eventId | string | Unique event identifier. | +| schemaVersion | int | Journal schema version. | +| projectionVersion | int | Projection contract version. | +| projectId | uuid/string | Project that owns the event. | +| sourceId | string | Stable source identity, such as daemon or SDK instance. | +| sourceKind | "daemon" \| "sdk" \| "cloud" \| "import" | Producer class. | +| sourceSeq | int64 | Monotonic sequence for `sourceId`; consumers use this for ordering and gap detection. | +| sourceCursor | string? | Optional source-specific resume cursor. | +| runId | string | Runtime run identity. | +| workstreamId | string? | Optional workstream identity. | +| planId | uuid/string? | | +| itemId | uuid/string? | | +| actorType | string | Agent, human, runtime, verifier, or system. | +| actorId | string | Stable actor identity. | +| actorRole | string? | | +| sessionId | uuid? | | +| taskId | uuid? | | +| eventKind | string | Event-specific verb such as `plan.done`. | +| idempotencyKey | string | Stable producer-provided key. Replays with the same request body are accepted idempotently. | +| causationId | string? | Event ID or command ID that caused this event. | +| occurredAt | iso8601 string | Producer-side timestamp. | +| payload | object | Event-specific structured data. It is data, not executable instructions. | +| evidenceIds | string[]? | Evidence records attached to this event. | +| parentEventIds | string[]? | Causal parent events. | +| trace | object? | Optional diagnostic trace context. | + +### `planningEventAck` + +Relay acknowledgement for a `planningEvent`. + +| Field | Type | Notes | +|---|---|---| +| type | "planningEventAck" | | +| eventId | string | Event being acknowledged. | +| sourceId | string | Source identity from the event. | +| sourceSeq | int64 | Source sequence from the event. | +| accepted | boolean | `true` when persisted or idempotently replayed. | +| error | string? | Human-readable rejection reason. | + ### `taskStarted` | Field | Type | diff --git a/envelope.go b/envelope.go index 1c711ea..7d78452 100644 --- a/envelope.go +++ b/envelope.go @@ -197,6 +197,10 @@ func payloadForType(msgType string) (any, error) { return &BrowserSensitiveActionRequest{}, nil case MsgTypeBrowserSensitiveActionResponse: return &BrowserSensitiveActionResponse{}, nil + case MsgTypePlanningEvent: + return &PlanningEvent{}, nil + case MsgTypePlanningEventAck: + return &PlanningEventAck{}, nil default: return nil, fmt.Errorf("unknown message type: %q", msgType) } diff --git a/messages.go b/messages.go index 31f2e67..3832991 100644 --- a/messages.go +++ b/messages.go @@ -86,6 +86,9 @@ const ( MsgTypeBrowserUserInput MessageType = "browserUserInput" MsgTypeBrowserSensitiveActionRequest MessageType = "browserSensitiveActionRequest" MsgTypeBrowserSensitiveActionResponse MessageType = "browserSensitiveActionResponse" + + MsgTypePlanningEvent MessageType = "planningEvent" + MsgTypePlanningEventAck MessageType = "planningEventAck" ) type ContextRef struct { @@ -102,6 +105,44 @@ type PlanCapability struct { ExpiresAt string `json:"expiresAt"` } +type PlanningEvent struct { + Type MessageType `json:"type"` + EventID string `json:"eventId"` + SchemaVersion int `json:"schemaVersion"` + ProjectionVersion int `json:"projectionVersion"` + ProjectID string `json:"projectId"` + SourceID string `json:"sourceId"` + SourceKind string `json:"sourceKind"` + SourceSeq int64 `json:"sourceSeq"` + SourceCursor string `json:"sourceCursor,omitempty"` + RunID string `json:"runId"` + WorkstreamID string `json:"workstreamId,omitempty"` + PlanID string `json:"planId,omitempty"` + ItemID string `json:"itemId,omitempty"` + ActorType string `json:"actorType"` + ActorID string `json:"actorId"` + ActorRole string `json:"actorRole,omitempty"` + SessionID string `json:"sessionId,omitempty"` + TaskID string `json:"taskId,omitempty"` + EventKind string `json:"eventKind"` + IdempotencyKey string `json:"idempotencyKey"` + CausationID string `json:"causationId,omitempty"` + OccurredAt string `json:"occurredAt"` + PayloadJSON json.RawMessage `json:"payload"` + EvidenceIDs []string `json:"evidenceIds,omitempty"` + ParentEventIDs []string `json:"parentEventIds,omitempty"` + TraceJSON json.RawMessage `json:"trace,omitempty"` +} + +type PlanningEventAck struct { + Type MessageType `json:"type"` + EventID string `json:"eventId"` + SourceID string `json:"sourceId"` + SourceSeq int64 `json:"sourceSeq"` + Accepted bool `json:"accepted"` + Error string `json:"error,omitempty"` +} + // Task is sent from the browser to the daemon to dispatch a user message. type Task struct { Type string `json:"type"` diff --git a/messages_test.go b/messages_test.go index 3c4edac..d946f2f 100644 --- a/messages_test.go +++ b/messages_test.go @@ -518,6 +518,69 @@ func TestEnvelopeRoundTrip(t *testing.T) { } } +func TestPlanningEventRoundTrip(t *testing.T) { + in := &PlanningEvent{ + Type: MsgTypePlanningEvent, + EventID: "event-1", + SchemaVersion: 1, + ProjectionVersion: 1, + ProjectID: "project-1", + SourceID: "daemon-1", + SourceKind: "daemon", + SourceSeq: 7, + RunID: "run-1", + PlanID: "plan-1", + ItemID: "item-1", + ActorType: "agent", + ActorID: "agent-1", + EventKind: "plan.done", + IdempotencyKey: "done-1", + OccurredAt: "2026-04-30T12:00:00Z", + PayloadJSON: json.RawMessage(`{"summary":"Done"}`), + EvidenceIDs: []string{"evidence-1"}, + } + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + env, err := ParseEnvelope(data) + if err != nil { + t.Fatal(err) + } + out, ok := env.Payload.(*PlanningEvent) + if !ok { + t.Fatalf("payload type = %T", env.Payload) + } + if out.EventID != in.EventID || out.SourceSeq != in.SourceSeq { + t.Fatalf("out = %#v, want %#v", out, in) + } +} + +func TestPlanningEventAckRoundTrip(t *testing.T) { + in := &PlanningEventAck{ + Type: MsgTypePlanningEventAck, + EventID: "event-1", + SourceID: "daemon-1", + SourceSeq: 7, + Accepted: true, + } + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + env, err := ParseEnvelope(data) + if err != nil { + t.Fatal(err) + } + out, ok := env.Payload.(*PlanningEventAck) + if !ok { + t.Fatalf("payload type = %T", env.Payload) + } + if !out.Accepted || out.EventID != "event-1" { + t.Fatalf("out = %#v", out) + } +} + func TestAgentTerminalEnvelopeRejectsInvalidFieldTypes(t *testing.T) { cases := []struct { name string