feat: add notifications v1 (#181)

* feat: add notifications v1

* fix: address notification review feedback

* fix: require passing CI for merge-ready notifications

* fix: simplify notification listing

* fix: ignore missing sessions for scm notifications

* fix: project notifications from cdc

* fix: stream notifications without cdc
This commit is contained in:
Adil Shaikh 2026-06-14 20:02:32 +05:30 committed by GitHub
parent 7da911711f
commit 17df922652
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2119 additions and 54 deletions

View File

@ -14,7 +14,9 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
@ -82,11 +84,14 @@ func Run() error {
// Built before the Lifecycle Manager so the LCM can use it for SCM-driven
// agent nudges (CI failure, review feedback, merge conflict).
messenger := newSessionMessenger(store, runtimeAdapter, log)
notificationHub := notify.NewHub()
notifier := notificationsvc.New(notificationsvc.Deps{Store: store})
notificationWriter := notify.New(notify.Deps{Store: store, Publisher: notificationHub})
// Bring up the Lifecycle Manager and the reaper first: it makes the session
// lifecycle write path live (reducer write -> store -> DB trigger ->
// change_log -> poller -> broadcaster) and gives startSession the shared LCM.
lcStack := startLifecycle(ctx, store, runtimeAdapter, messenger, log)
lcStack := startLifecycle(ctx, store, runtimeAdapter, messenger, notificationWriter, log)
lcStack.scmDone = startSCMObserver(ctx, store, lcStack.LCM, log)
// Wire the controller-facing session service over the same store + LCM, the
@ -107,6 +112,8 @@ func Run() error {
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc}),
Sessions: sessionSvc,
Reviews: reviewsvc.NewInMemory(),
Notifications: notifier,
NotificationStream: notificationHub,
CDC: store,
Events: cdcPipe.Broadcaster,
Activity: lcStack.LCM,

View File

@ -20,6 +20,10 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
type notificationSink interface {
Notify(context.Context, ports.NotificationIntent) error
}
// lifecycleStack owns the runtime reaper goroutine started with the lifecycle
// reducer. The reducer itself is only used for wiring observations into storage.
type lifecycleStack struct {
@ -35,8 +39,8 @@ type lifecycleStack struct {
// reaper. The goroutine stops when ctx is cancelled; Stop waits for it to drain.
// The messenger is the per-daemon agent messenger the LCM uses to nudge agents
// in response to SCM observations (CI failure, review feedback, merge conflict).
func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, messenger ports.AgentMessenger, logger *slog.Logger) *lifecycleStack {
lcm := lifecycle.New(store, messenger)
func startLifecycle(ctx context.Context, store *sqlite.Store, runtime ports.Runtime, messenger ports.AgentMessenger, notifier notificationSink, logger *slog.Logger) *lifecycleStack {
lcm := lifecycle.New(store, messenger, lifecycle.WithNotificationSink(notifier))
rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger})
return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}
}

View File

@ -315,7 +315,7 @@ func TestWiring_StartLifecycleThreadsMessengerIntoLCM(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
messenger := &captureMessenger{}
stack := startLifecycle(ctx, store, zellij.New(zellij.Options{}), messenger, log)
stack := startLifecycle(ctx, store, zellij.New(zellij.Options{}), messenger, nil, log)
t.Cleanup(stack.Stop)
t.Cleanup(cancel)

View File

@ -0,0 +1,86 @@
package domain
import (
"errors"
"time"
)
// NotificationType identifies a user-facing notification kind persisted for the dashboard.
type NotificationType string
const (
// NotificationNeedsInput means an agent session is waiting for user input.
NotificationNeedsInput NotificationType = "needs_input"
// NotificationReadyToMerge means a PR has no known merge blockers.
NotificationReadyToMerge NotificationType = "ready_to_merge"
// NotificationPRMerged means a tracked PR was merged.
NotificationPRMerged NotificationType = "pr_merged"
// NotificationPRClosedUnmerged means a tracked PR closed without merging.
NotificationPRClosedUnmerged NotificationType = "pr_closed_unmerged"
)
// Valid reports whether t is one of the v1 notification kinds.
func (t NotificationType) Valid() bool {
switch t {
case NotificationNeedsInput, NotificationReadyToMerge, NotificationPRMerged, NotificationPRClosedUnmerged:
return true
default:
return false
}
}
// NotificationStatus is the read state for a stored notification.
type NotificationStatus string
const (
// NotificationUnread marks a notification that has not been acknowledged.
NotificationUnread NotificationStatus = "unread"
// NotificationRead marks a notification that has been acknowledged.
NotificationRead NotificationStatus = "read"
)
// Valid reports whether s is a supported notification read state.
func (s NotificationStatus) Valid() bool {
switch s {
case NotificationUnread, NotificationRead:
return true
default:
return false
}
}
// NotificationRecord is the durable notification persistence shape.
type NotificationRecord struct {
ID string
SessionID SessionID
ProjectID ProjectID
PRURL string
Type NotificationType
Title string
Body string
Status NotificationStatus
CreatedAt time.Time
}
var (
// ErrInvalidNotificationType reports an unknown notification type.
ErrInvalidNotificationType = errors.New("invalid notification type")
// ErrInvalidNotificationStatus reports an unknown notification status.
ErrInvalidNotificationStatus = errors.New("invalid notification status")
// ErrInvalidNotificationRecord reports a missing required notification field.
ErrInvalidNotificationRecord = errors.New("invalid notification record")
)
// Validate checks the required fields and enum values for a stored notification.
func (r NotificationRecord) Validate() error {
if r.SessionID == "" || r.ProjectID == "" || r.Title == "" || r.CreatedAt.IsZero() {
return ErrInvalidNotificationRecord
}
if !r.Type.Valid() {
return ErrInvalidNotificationType
}
if !r.Status.Valid() {
return ErrInvalidNotificationStatus
}
return nil
}

View File

@ -23,6 +23,8 @@ type APIDeps struct {
Activity controllers.ActivityRecorder
PRs prsvc.ActionManager
Reviews reviewsvc.Manager
Notifications controllers.NotificationService
NotificationStream controllers.NotificationStream
CDC cdc.Source
Events cdcSubscriber
}
@ -35,6 +37,7 @@ type API struct {
sessions *controllers.SessionsController
prs *controllers.PRsController
reviews *controllers.ReviewsController
notifications *controllers.NotificationsController
events *EventsController
}
@ -53,6 +56,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API {
},
prs: &controllers.PRsController{Svc: deps.PRs},
reviews: &controllers.ReviewsController{Svc: deps.Reviews},
notifications: &controllers.NotificationsController{Svc: deps.Notifications, Stream: deps.NotificationStream},
events: &EventsController{Source: deps.CDC, Live: deps.Events},
}
}
@ -75,9 +79,11 @@ func (a *API) Register(root chi.Router) {
a.sessions.Register(r)
a.prs.Register(r)
a.reviews.Register(r)
a.notifications.Register(r)
// Sibling REST controllers plug in here.
})
// Long-lived streams intentionally bypass the REST timeout middleware.
a.notifications.RegisterStream(r)
a.events.Register(r)
})
}

View File

@ -51,6 +51,87 @@ paths:
summary: Stream CDC events with durable replay
tags:
- events
/api/v1/notifications:
get:
operationId: listNotifications
parameters:
- description: Notification status filter. V1 supports only unread.
in: query
name: status
schema:
description: Notification status filter. V1 supports only unread.
enum:
- unread
type: string
- description: Maximum notifications to return. Defaults to 50; capped at 100.
in: query
name: limit
schema:
description: Maximum notifications to return. Defaults to 50; capped at
100.
maximum: 100
minimum: 1
type: integer
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ListNotificationsResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Bad Request
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: List unread notifications
tags:
- notifications
/api/v1/notifications/stream:
get:
operationId: streamNotifications
parameters:
- description: Optional project id filter for live notifications.
in: query
name: projectId
schema:
description: Optional project id filter for live notifications.
type: string
responses:
"200":
content:
text/event-stream:
schema:
type: string
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Stream created notifications
tags:
- notifications
/api/v1/orchestrators:
get:
operationId: listOrchestrators
@ -1186,6 +1267,15 @@ components:
- ok
- sessionId
type: object
ListNotificationsResponse:
properties:
notifications:
items:
$ref: '#/components/schemas/NotificationResponse'
type: array
required:
- notifications
type: object
ListProjectsResponse:
properties:
projects:
@ -1238,6 +1328,64 @@ components:
- prNumber
- method
type: object
NotificationResponse:
properties:
body:
type: string
createdAt:
format: date-time
type: string
id:
type: string
prUrl:
type: string
projectId:
type: string
sessionId:
type: string
status:
enum:
- unread
- read
type: string
target:
$ref: '#/components/schemas/NotificationTarget'
title:
type: string
type:
enum:
- needs_input
- ready_to_merge
- pr_merged
- pr_closed_unmerged
type: string
required:
- id
- sessionId
- projectId
- prUrl
- type
- title
- body
- status
- createdAt
- target
type: object
NotificationTarget:
properties:
kind:
enum:
- session
- pr
type: string
prUrl:
type: string
sessionId:
type: string
required:
- kind
- sessionId
type: object
OrchestratorResponse:
properties:
id:
@ -1691,5 +1839,7 @@ tags:
name: prs
- description: Code-review runs and findings
name: reviews
- description: Durable dashboard notifications
name: notifications
- description: Server-sent CDC event stream with durable replay
name: events

View File

@ -63,6 +63,8 @@ func Build() ([]byte, error) {
"Pull-request actions (SCM lane)"),
*(&openapi31.Tag{Name: "reviews"}).WithDescription(
"Code-review runs and findings"),
*(&openapi31.Tag{Name: "notifications"}).WithDescription(
"Durable dashboard notifications"),
*(&openapi31.Tag{Name: "events"}).WithDescription(
"Server-sent CDC event stream with durable replay"),
}
@ -155,6 +157,11 @@ var schemaNames = map[string]string{
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
"ControllersOrchestratorResponse": "OrchestratorResponse",
"ControllersListNotificationsQuery": "ListNotificationsQuery",
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
"ControllersNotificationTarget": "NotificationTarget",
"ControllersNotificationResponse": "NotificationResponse",
"ControllersListNotificationsResponse": "ListNotificationsResponse",
// httpd/controllers — PR wire envelopes
"ControllersMergePRResponse": "MergePRResponse",
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
@ -252,9 +259,37 @@ func operations() []operation {
ops = append(ops, sessionOperations()...)
ops = append(ops, prOperations()...)
ops = append(ops, reviewOperations()...)
ops = append(ops, notificationOperations()...)
return ops
}
func notificationOperations() []operation {
return []operation{
{
method: http.MethodGet, path: "/api/v1/notifications", id: "listNotifications", tag: "notifications",
summary: "List unread notifications",
pathParams: []any{controllers.ListNotificationsQuery{}},
resps: []respUnit{
{http.StatusOK, controllers.ListNotificationsResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodGet, path: "/api/v1/notifications/stream", id: "streamNotifications", tag: "notifications",
summary: "Stream created notifications",
pathParams: []any{controllers.NotificationStreamQuery{}},
resps: []respUnit{
{http.StatusOK, ""},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
contentTypes: map[int]string{http.StatusOK: "text/event-stream"},
},
}
}
// reviewOperations declares the /reviews operations. Must stay 1:1 with the
// routes ReviewsController.Register mounts (enforced by the parity test).
func reviewOperations() []operation {

View File

@ -262,6 +262,43 @@ type OrchestratorResponse struct {
ProjectName string `json:"projectName,omitempty"`
}
// ListNotificationsQuery is the query string accepted by GET /api/v1/notifications.
type ListNotificationsQuery struct {
Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."`
Limit int `query:"limit,omitempty" minimum:"1" maximum:"100" description:"Maximum notifications to return. Defaults to 50; capped at 100."`
}
// NotificationStreamQuery is the query string accepted by GET /api/v1/notifications/stream.
type NotificationStreamQuery struct {
ProjectID string `query:"projectId,omitempty" description:"Optional project id filter for live notifications."`
}
// NotificationTarget is the dashboard navigation target for a notification.
type NotificationTarget struct {
Kind string `json:"kind" enum:"session,pr"`
SessionID string `json:"sessionId"`
PRURL string `json:"prUrl,omitempty"`
}
// NotificationResponse is one stored notification returned by the API.
type NotificationResponse struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
ProjectID string `json:"projectId"`
PRURL string `json:"prUrl"`
Type string `json:"type" enum:"needs_input,ready_to_merge,pr_merged,pr_closed_unmerged"`
Title string `json:"title"`
Body string `json:"body"`
Status string `json:"status" enum:"unread,read"`
CreatedAt time.Time `json:"createdAt"`
Target NotificationTarget `json:"target"`
}
// ListNotificationsResponse is the body of GET /api/v1/notifications.
type ListNotificationsResponse struct {
Notifications []NotificationResponse `json:"notifications"`
}
// PRIDParam is the {id} path parameter shared by the /prs/{id} routes.
type PRIDParam struct {
ID string `path:"id" description:"PR number."`

View File

@ -0,0 +1,185 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
)
// NotificationService is the controller-facing notification service contract.
type NotificationService interface {
ListUnread(ctx context.Context, filter notificationsvc.ListFilter) ([]notificationsvc.Notification, error)
}
// NotificationStream is the live notification stream used by SSE clients.
type NotificationStream interface {
Subscribe(projectID domain.ProjectID) (<-chan domain.NotificationRecord, func())
}
// NotificationsController owns the /notifications routes.
type NotificationsController struct {
Svc NotificationService
Stream NotificationStream
}
// Register mounts bounded notification REST routes on the supplied router.
func (c *NotificationsController) Register(r chi.Router) {
r.Get("/notifications", c.list)
}
// RegisterStream mounts long-lived notification stream routes on the supplied router.
func (c *NotificationsController) RegisterStream(r chi.Router) {
r.Get("/notifications/stream", c.stream)
}
func (c *NotificationsController) list(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/notifications")
return
}
filter, err := parseNotificationListFilter(r)
if err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_QUERY", err.Error(), nil)
return
}
notifications, err := c.Svc.ListUnread(r.Context(), filter)
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ListNotificationsResponse{Notifications: notificationResponses(notifications)})
}
func (c *NotificationsController) stream(w http.ResponseWriter, r *http.Request) {
if c.Stream == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/notifications/stream")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "SSE_UNSUPPORTED", "Streaming is not supported by this server", nil)
return
}
ch, unsubscribe := c.Stream.Subscribe(domain.ProjectID(r.URL.Query().Get("projectId")))
defer unsubscribe()
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case rec, ok := <-ch:
if !ok {
return
}
if err := writeNotificationSSE(w, flusher, rec); err != nil {
return
}
}
}
}
func writeNotificationSSE(w http.ResponseWriter, flusher http.Flusher, rec domain.NotificationRecord) error {
data, err := json.Marshal(notificationResponseFromRecord(rec))
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: notification_created\ndata: %s\n\n", data); err != nil {
return err
}
flusher.Flush()
return nil
}
func parseNotificationListFilter(r *http.Request) (notificationsvc.ListFilter, error) {
q := r.URL.Query()
status := q.Get("status")
if status == "" {
status = "unread"
}
if status != "unread" {
return notificationsvc.ListFilter{}, errNotificationStatusUnsupported
}
limit := notificationsvc.DefaultListLimit
if raw := q.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return notificationsvc.ListFilter{}, errNotificationLimitInvalid
}
limit = parsed
}
if limit > notificationsvc.MaxListLimit {
limit = notificationsvc.MaxListLimit
}
return notificationsvc.ListFilter{Limit: limit}, nil
}
var (
errNotificationStatusUnsupported = notificationQueryError("status must be unread")
errNotificationLimitInvalid = notificationQueryError("limit must be a positive integer")
)
type notificationQueryError string
func (e notificationQueryError) Error() string { return string(e) }
func notificationResponses(in []notificationsvc.Notification) []NotificationResponse {
out := make([]NotificationResponse, 0, len(in))
for _, n := range in {
out = append(out, NotificationResponse{
ID: n.ID,
SessionID: string(n.SessionID),
ProjectID: string(n.ProjectID),
PRURL: n.PRURL,
Type: string(n.Type),
Title: n.Title,
Body: n.Body,
Status: string(n.Status),
CreatedAt: n.CreatedAt,
Target: NotificationTarget{
Kind: string(n.Target.Kind),
SessionID: string(n.Target.SessionID),
PRURL: n.Target.PRURL,
},
})
}
return out
}
func notificationResponseFromRecord(rec domain.NotificationRecord) NotificationResponse {
return NotificationResponse{
ID: rec.ID,
SessionID: string(rec.SessionID),
ProjectID: string(rec.ProjectID),
PRURL: rec.PRURL,
Type: string(rec.Type),
Title: rec.Title,
Body: rec.Body,
Status: string(rec.Status),
CreatedAt: rec.CreatedAt,
Target: notificationTargetFromRecord(rec),
}
}
func notificationTargetFromRecord(rec domain.NotificationRecord) NotificationTarget {
if rec.PRURL != "" {
return NotificationTarget{Kind: "pr", SessionID: string(rec.SessionID), PRURL: rec.PRURL}
}
return NotificationTarget{Kind: "session", SessionID: string(rec.SessionID)}
}

View File

@ -0,0 +1,157 @@
package controllers_test
import (
"bufio"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
)
type fakeNotificationService struct {
gotFilter notificationsvc.ListFilter
items []notificationsvc.Notification
err error
}
type fakeNotificationStream struct {
gotProject domain.ProjectID
ch chan domain.NotificationRecord
}
func (f *fakeNotificationService) ListUnread(_ context.Context, filter notificationsvc.ListFilter) ([]notificationsvc.Notification, error) {
f.gotFilter = filter
return f.items, f.err
}
func (f *fakeNotificationStream) Subscribe(projectID domain.ProjectID) (<-chan domain.NotificationRecord, func()) {
f.gotProject = projectID
if f.ch == nil {
f.ch = make(chan domain.NotificationRecord, 1)
}
return f.ch, func() {}
}
func newNotificationTestServer(t *testing.T, svc controllers.NotificationService) *httptest.Server {
t.Helper()
return newNotificationStreamTestServer(t, svc, nil)
}
func newNotificationStreamTestServer(t *testing.T, svc controllers.NotificationService, stream controllers.NotificationStream) *httptest.Server {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Notifications: svc, NotificationStream: stream}, httpd.ControlDeps{}))
t.Cleanup(srv.Close)
return srv
}
func TestNotificationsAPI_ListUnread(t *testing.T) {
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC)
svc := &fakeNotificationService{items: []notificationsvc.Notification{{
NotificationRecord: domain.NotificationRecord{ID: "ntf_1", SessionID: "mer-1", ProjectID: "mer", Type: domain.NotificationNeedsInput, Title: "checkout-flow needs input", Body: "The agent is waiting for your response.", Status: domain.NotificationUnread, CreatedAt: now},
Target: notificationsvc.Target{Kind: notificationsvc.TargetSession, SessionID: "mer-1"},
}}}
srv := newNotificationTestServer(t, svc)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/notifications?limit=10", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
if svc.gotFilter.Limit != 10 {
t.Fatalf("filter = %+v", svc.gotFilter)
}
var resp struct {
Notifications []struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
ProjectID string `json:"projectId"`
Type string `json:"type"`
Status string `json:"status"`
Target struct {
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
} `json:"target"`
} `json:"notifications"`
}
mustJSON(t, body, &resp)
if len(resp.Notifications) != 1 || resp.Notifications[0].ID != "ntf_1" || resp.Notifications[0].Target.Kind != "session" {
t.Fatalf("resp = %+v", resp)
}
}
func TestNotificationsAPI_DefaultsAndCapsLimit(t *testing.T) {
svc := &fakeNotificationService{}
srv := newNotificationTestServer(t, svc)
_, status, _ := doRequest(t, srv, "GET", "/api/v1/notifications?limit=999", "")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if svc.gotFilter.Limit != notificationsvc.MaxListLimit {
t.Fatalf("limit = %d, want cap %d", svc.gotFilter.Limit, notificationsvc.MaxListLimit)
}
}
func TestNotificationsAPI_RejectsUnsupportedStatus(t *testing.T) {
srv := newNotificationTestServer(t, &fakeNotificationService{})
body, status, _ := doRequest(t, srv, "GET", "/api/v1/notifications?status=read", "")
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_QUERY")
}
func TestNotificationsAPI_WithoutServiceIs501(t *testing.T) {
srv := newNotificationTestServer(t, nil)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/notifications", "")
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}
func TestNotificationsAPI_StreamCreatedNotifications(t *testing.T) {
stream := &fakeNotificationStream{ch: make(chan domain.NotificationRecord, 1)}
srv := newNotificationStreamTestServer(t, &fakeNotificationService{}, stream)
resp, err := srv.Client().Get(srv.URL + "/api/v1/notifications/stream?projectId=mer")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q", ct)
}
if stream.gotProject != "mer" {
t.Fatalf("project filter = %q", stream.gotProject)
}
stream.ch <- domain.NotificationRecord{ID: "ntf_1", SessionID: "mer-1", ProjectID: "mer", Type: domain.NotificationNeedsInput, Title: "needs input", Status: domain.NotificationUnread, CreatedAt: time.Now()}
reader := bufio.NewReader(resp.Body)
eventLine, err := reader.ReadString('\n')
if err != nil {
t.Fatal(err)
}
dataLine, err := reader.ReadString('\n')
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(eventLine) != "event: notification_created" || !strings.Contains(dataLine, `"id":"ntf_1"`) {
t.Fatalf("eventLine=%q dataLine=%q", eventLine, dataLine)
}
}
func TestNotificationsAPI_StreamWithoutPublisherIs501(t *testing.T) {
srv := newNotificationStreamTestServer(t, &fakeNotificationService{}, nil)
body, status, _ := doRequest(t, srv, "GET", "/api/v1/notifications/stream", "")
assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED")
}

View File

@ -304,6 +304,10 @@ func (c *SessionsController) activity(w http.ResponseWriter, r *http.Request) {
return
}
if err := c.Activity.ApplyActivitySignal(r.Context(), sessionID(r), ports.ActivitySignal{Valid: true, State: state}); err != nil {
if errors.Is(err, ports.ErrSessionNotFound) {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "SESSION_NOT_FOUND", "Unknown session", nil)
return
}
envelope.WriteError(w, r, err)
return
}

View File

@ -84,6 +84,13 @@ func TestSessionsAPI_ActivityRejectsBadJSON(t *testing.T) {
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
}
func TestSessionsAPI_ActivityMissingSessionIs404(t *testing.T) {
srv := newActivityTestServer(t, &fakeActivityRecorder{err: ports.ErrSessionNotFound})
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/missing/activity", `{"state":"idle"}`)
assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND")
}
func TestSessionsAPI_ActivityRecorderErrorIs500(t *testing.T) {
srv := newActivityTestServer(t, &fakeActivityRecorder{err: errors.New("boom")})

View File

@ -7,6 +7,7 @@ package lifecycle
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
@ -23,11 +24,25 @@ type sessionStore interface {
UpdatePRLastNudgeSignature(ctx context.Context, prURL, payload string) error
}
// notificationSink is the optional lifecycle-to-notification-producer boundary.
type notificationSink interface {
Notify(ctx context.Context, intent ports.NotificationIntent) error
}
// Option customizes a Manager.
type Option func(*Manager)
// WithNotificationSink wires lifecycle notification intents to a write-side producer.
func WithNotificationSink(sink notificationSink) Option {
return func(m *Manager) { m.notifications = sink }
}
// Manager reduces runtime, activity, spawn, and termination observations into durable session facts.
// It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts.
type Manager struct {
store sessionStore
messenger ports.AgentMessenger
notifications notificationSink
mu sync.Mutex
window time.Duration
@ -36,8 +51,12 @@ type Manager struct {
}
// New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges.
func New(store sessionStore, messenger ports.AgentMessenger) *Manager {
return &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: time.Now, react: newReactionState()}
func New(store sessionStore, messenger ports.AgentMessenger, opts ...Option) *Manager {
m := &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: time.Now, react: newReactionState()}
for _, opt := range opts {
opt(m)
}
return m
}
func (m *Manager) mutate(ctx context.Context, id domain.SessionID, fn func(domain.SessionRecord, time.Time) (domain.SessionRecord, bool)) error {
@ -79,19 +98,32 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID,
if !s.Valid {
return nil
}
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated {
return cur, false
var intent *ports.NotificationIntent
m.mu.Lock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
m.mu.Unlock()
return err
}
next := cur
if !ok {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ports.ErrSessionNotFound, id)
}
now := m.clock()
if rec.IsTerminated {
m.mu.Unlock()
return nil
}
next := rec
act := domain.Activity{State: s.State, LastActivityAt: timeOr(s.Timestamp, now)}
// A same-state repeat is still a write when it is the FIRST signal for
// this spawn: the receipt itself is a durable fact (it clears the
// no_signal display status). Hook deliveries are best-effort, so the
// first to ARRIVE may match the seeded state — e.g. a turn's "active"
// POST is lost and its Stop hook lands idle on the idle-seeded row.
if sameActivity(cur.Activity, act) && !cur.FirstSignalAt.IsZero() {
return cur, false
if sameActivity(rec.Activity, act) && !rec.FirstSignalAt.IsZero() {
m.mu.Unlock()
return nil
}
next.Activity = act
if next.FirstSignalAt.IsZero() {
@ -100,8 +132,32 @@ func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID,
if s.State == domain.ActivityExited {
next.IsTerminated = true
}
return next, true
})
next.UpdatedAt = now
if err := m.store.UpdateSession(ctx, next); err != nil {
m.mu.Unlock()
return err
}
if rec.Activity.State != domain.ActivityWaitingInput && next.Activity.State == domain.ActivityWaitingInput && !next.IsTerminated {
intent = &ports.NotificationIntent{
Type: domain.NotificationNeedsInput,
SessionID: next.ID,
ProjectID: next.ProjectID,
CreatedAt: next.Activity.LastActivityAt,
SessionDisplayName: next.DisplayName,
}
}
m.mu.Unlock()
m.emitNotification(ctx, intent)
return nil
}
func (m *Manager) emitNotification(ctx context.Context, intent *ports.NotificationIntent) {
if intent == nil || m.notifications == nil {
return
}
if err := m.notifications.Notify(ctx, *intent); err != nil {
slog.Default().Warn("lifecycle: notification failed", "session", intent.SessionID, "type", intent.Type, "err", err)
}
}
// MarkSpawned marks a newly spawned or restored session live and stores runtime/workspace handles.

View File

@ -112,6 +112,14 @@ func TestActivity_InvalidIsIgnored(t *testing.T) {
}
}
func TestActivity_MissingSessionReturnsNotFound(t *testing.T) {
m, _, _ := newManager()
err := m.ApplyActivitySignal(ctx, "missing-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput})
if !errors.Is(err, ports.ErrSessionNotFound) {
t.Fatalf("err = %v, want ErrSessionNotFound", err)
}
}
func TestMarkTerminated(t *testing.T) {
m, st, _ := newManager()
st.sessions["mer-1"] = working("mer-1")
@ -184,6 +192,18 @@ func TestSCMObservationProjectsToExistingPRReactions(t *testing.T) {
}
}
func TestSCMObservation_MissingSessionIsIgnored(t *testing.T) {
st := newFakeStore()
m := New(st, nil)
o := ports.SCMObservation{
Fetched: true,
PR: ports.SCMPRObservation{URL: "pr1", Number: 1},
}
if err := m.ApplySCMObservation(ctx, "missing-1", o); err != nil {
t.Fatalf("ApplySCMObservation missing session: %v", err)
}
}
func TestSCMObservationUsesPRHeadWhenCIHeadMissing(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
@ -546,3 +566,144 @@ func TestMarkSpawnedClearsFirstSignal(t *testing.T) {
t.Fatalf("spawn/restore must clear the receipt, got %+v", got)
}
}
type fakeNotificationSink struct {
intents []ports.NotificationIntent
err error
}
func (f *fakeNotificationSink) Notify(_ context.Context, intent ports.NotificationIntent) error {
f.intents = append(f.intents, intent)
return f.err
}
func TestActivity_WaitingInputTransitionEmitsNotification(t *testing.T) {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC)
m.clock = func() time.Time { return now }
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", DisplayName: "checkout-flow", Activity: domain.Activity{State: domain.ActivityActive, LastActivityAt: now.Add(-time.Minute)}, FirstSignalAt: now.Add(-time.Minute)}
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput}); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 1 {
t.Fatalf("intents = %d, want 1", len(sink.intents))
}
intent := sink.intents[0]
if intent.Type != domain.NotificationNeedsInput || intent.SessionID != "mer-1" || intent.ProjectID != "mer" || intent.SessionDisplayName != "checkout-flow" {
t.Fatalf("intent = %+v", intent)
}
}
func TestActivity_WaitingInputSameStateDoesNotEmitNotification(t *testing.T) {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
now := time.Now()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityWaitingInput, LastActivityAt: now}, FirstSignalAt: now}
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput}); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 0 {
t.Fatalf("same-state waiting_input emitted %+v", sink.intents)
}
}
func TestActivity_TerminatedSessionDoesNotEmitNotification(t *testing.T) {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
if err := m.ApplyActivitySignal(ctx, "mer-1", ports.ActivitySignal{Valid: true, State: domain.ActivityWaitingInput}); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 0 {
t.Fatalf("terminated session emitted %+v", sink.intents)
}
}
func TestSCMObservation_Notifications(t *testing.T) {
for _, tc := range []struct {
name string
obs ports.SCMObservation
want domain.NotificationType
}{
{
name: "ready",
obs: ports.SCMObservation{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1, Title: "checkout"}, CI: ports.SCMCIObservation{Summary: string(domain.CIPassing)}, Review: ports.SCMReviewObservation{Decision: string(domain.ReviewApproved)}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
want: domain.NotificationReadyToMerge,
},
{
name: "merged",
obs: ports.SCMObservation{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/2", Number: 2, Merged: true}},
want: domain.NotificationPRMerged,
},
{
name: "closed",
obs: ports.SCMObservation{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/3", Number: 3, Closed: true}},
want: domain.NotificationPRClosedUnmerged,
},
} {
t.Run(tc.name, func(t *testing.T) {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplySCMObservation(ctx, "mer-1", tc.obs); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 1 {
t.Fatalf("intents = %d, want 1", len(sink.intents))
}
if got := sink.intents[0]; got.Type != tc.want || got.PRURL != tc.obs.PR.URL || got.PRNumber != tc.obs.PR.Number {
t.Fatalf("intent = %+v, want type %s", got, tc.want)
}
})
}
}
func TestSCMObservation_NotReadyWhenCIOrReviewBlocks(t *testing.T) {
for _, obs := range []ports.SCMObservation{
{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1}, CI: ports.SCMCIObservation{Summary: string(domain.CIFailing)}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1}, CI: ports.SCMCIObservation{Summary: string(domain.CIPending)}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1}, CI: ports.SCMCIObservation{Summary: string(domain.CIUnknown)}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
{Fetched: true, PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1}, CI: ports.SCMCIObservation{Summary: string(domain.CIPassing)}, Review: ports.SCMReviewObservation{Decision: string(domain.ReviewChangesRequest)}, Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)}},
} {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
st.sessions["mer-1"] = working("mer-1")
if err := m.ApplySCMObservation(ctx, "mer-1", obs); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 0 {
t.Fatalf("blocked PR emitted %+v", sink.intents)
}
}
}
func TestSCMObservation_ReadyToMergeSuppressedWhileWaitingInput(t *testing.T) {
st := newFakeStore()
sink := &fakeNotificationSink{}
m := New(st, nil, WithNotificationSink(sink))
rec := working("mer-1")
rec.Activity.State = domain.ActivityWaitingInput
st.sessions["mer-1"] = rec
obs := ports.SCMObservation{
Fetched: true,
PR: ports.SCMPRObservation{URL: "https://github.com/o/r/pull/1", Number: 1},
CI: ports.SCMCIObservation{Summary: string(domain.CIPassing)},
Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable)},
}
if err := m.ApplySCMObservation(ctx, "mer-1", obs); err != nil {
t.Fatal(err)
}
if len(sink.intents) != 0 {
t.Fatalf("waiting-input session emitted ready notification: %+v", sink.intents)
}
}

View File

@ -92,7 +92,92 @@ func (m *Manager) ApplySCMObservation(ctx context.Context, id domain.SessionID,
if !o.Fetched {
return nil
}
return m.ApplyPRObservation(ctx, id, scmToPRObservation(o))
if err := m.ApplyPRObservation(ctx, id, scmToPRObservation(o)); err != nil {
return err
}
intent, err := m.notificationIntentForCurrentSCM(ctx, id, o)
if err != nil {
return err
}
m.emitNotification(ctx, intent)
return nil
}
func (m *Manager) notificationIntentForCurrentSCM(ctx context.Context, id domain.SessionID, o ports.SCMObservation) (*ports.NotificationIntent, error) {
// Serialize the session snapshot with activity transitions so ready-to-merge
// notifications do not race against a simultaneous waiting_input update.
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
return m.notificationIntentForSCM(rec, o), nil
}
func (m *Manager) notificationIntentForSCM(rec domain.SessionRecord, o ports.SCMObservation) *ports.NotificationIntent {
prURL := firstSCMNonEmpty(o.PR.URL, o.PR.HTMLURL)
base := ports.NotificationIntent{
SessionID: rec.ID,
ProjectID: rec.ProjectID,
PRURL: prURL,
CreatedAt: timeOr(o.ObservedAt, m.clock()),
SessionDisplayName: rec.DisplayName,
PRNumber: o.PR.Number,
PRTitle: o.PR.Title,
PRSourceBranch: o.PR.SourceBranch,
PRTargetBranch: o.PR.TargetBranch,
Provider: o.Provider,
Repo: o.Repo,
}
if o.PR.Merged {
base.Type = domain.NotificationPRMerged
return &base
}
if o.PR.Closed {
base.Type = domain.NotificationPRClosedUnmerged
return &base
}
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput || !scmObservationIsReadyToMerge(o) {
return nil
}
base.Type = domain.NotificationReadyToMerge
return &base
}
func scmObservationIsReadyToMerge(o ports.SCMObservation) bool {
if o.PR.Merged || o.PR.Closed || o.PR.Draft {
return false
}
ci := domain.CIState(o.CI.Summary)
if ci == "" {
ci = domain.CIUnknown
}
switch ci {
case domain.CIFailing, domain.CIPending, domain.CIUnknown:
return false
}
if domain.ReviewDecision(o.Review.Decision) == domain.ReviewChangesRequest || hasUnresolvedSCMComments(o.Review.Threads) {
return false
}
return domain.Mergeability(o.Mergeability.State) == domain.MergeMergeable
}
func hasUnresolvedSCMComments(threads []ports.SCMReviewThreadObservation) bool {
for _, th := range threads {
if th.Resolved || th.IsBot {
continue
}
for _, c := range th.Comments {
if !c.IsBot {
return true
}
}
}
return false
}
func scmToPRObservation(o ports.SCMObservation) ports.PRObservation {

View File

@ -0,0 +1,90 @@
package notify
import (
"fmt"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func enrich(intent Intent) (domain.NotificationRecord, error) {
rec := domain.NotificationRecord{
SessionID: intent.SessionID,
ProjectID: intent.ProjectID,
PRURL: strings.TrimSpace(intent.PRURL),
Type: intent.Type,
Status: domain.NotificationUnread,
CreatedAt: intent.CreatedAt,
}
if !intent.Type.Valid() {
return domain.NotificationRecord{}, domain.ErrInvalidNotificationType
}
if intent.Type != domain.NotificationNeedsInput && rec.PRURL == "" {
return domain.NotificationRecord{}, domain.ErrInvalidNotificationRecord
}
rec.Title = titleForIntent(intent)
rec.Body = bodyForIntent(intent)
if err := rec.Validate(); err != nil {
return domain.NotificationRecord{}, err
}
return rec, nil
}
func titleForIntent(intent Intent) string {
switch intent.Type {
case domain.NotificationNeedsInput:
return fmt.Sprintf("%s needs input", sessionLabel(intent))
case domain.NotificationReadyToMerge:
return fmt.Sprintf("%s is ready to merge", prLabel(intent))
case domain.NotificationPRMerged:
return fmt.Sprintf("%s was merged", prLabel(intent))
case domain.NotificationPRClosedUnmerged:
return fmt.Sprintf("%s was closed without merging", prLabel(intent))
default:
return "Notification"
}
}
func bodyForIntent(intent Intent) string {
switch intent.Type {
case domain.NotificationNeedsInput:
return "The agent is waiting for your response."
case domain.NotificationReadyToMerge:
if s := sessionLabel(intent); s != "session" {
return fmt.Sprintf("%s has no known blocking CI or review feedback.", s)
}
return "The pull request has no known blocking CI or review feedback."
case domain.NotificationPRMerged:
if title := strings.TrimSpace(intent.PRTitle); title != "" {
return fmt.Sprintf("%s was merged.", title)
}
return "The pull request was merged."
case domain.NotificationPRClosedUnmerged:
if title := strings.TrimSpace(intent.PRTitle); title != "" {
return fmt.Sprintf("%s was closed without merging.", title)
}
return "The pull request was closed without merging."
default:
return ""
}
}
func sessionLabel(intent Intent) string {
if v := strings.TrimSpace(intent.SessionDisplayName); v != "" {
return v
}
if intent.SessionID != "" {
return string(intent.SessionID)
}
return "session"
}
func prLabel(intent Intent) string {
if intent.PRNumber > 0 {
return fmt.Sprintf("PR #%d", intent.PRNumber)
}
if title := strings.TrimSpace(intent.PRTitle); title != "" {
return "PR " + title
}
return "PR"
}

View File

@ -0,0 +1,69 @@
package notify
import (
"context"
"sync"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
const subscriberBuffer = 64
type subscription struct {
projectID domain.ProjectID
ch chan domain.NotificationRecord
}
// Hub is an in-process publisher for notification SSE subscribers.
type Hub struct {
mu sync.RWMutex
nextID int
subs map[int]subscription
}
// NewHub constructs an empty notification Hub.
func NewHub() *Hub {
return &Hub{subs: map[int]subscription{}}
}
// Subscribe registers a live notification subscriber. Empty projectID receives all projects.
func (h *Hub) Subscribe(projectID domain.ProjectID) (<-chan domain.NotificationRecord, func()) {
if h == nil {
ch := make(chan domain.NotificationRecord)
close(ch)
return ch, func() {}
}
ch := make(chan domain.NotificationRecord, subscriberBuffer)
h.mu.Lock()
id := h.nextID
h.nextID++
h.subs[id] = subscription{projectID: projectID, ch: ch}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
if sub, ok := h.subs[id]; ok {
delete(h.subs, id)
close(sub.ch)
}
h.mu.Unlock()
}
}
// Publish pushes a persisted notification to matching subscribers without blocking lifecycle writes.
func (h *Hub) Publish(_ context.Context, rec domain.NotificationRecord) error {
if h == nil {
return nil
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, sub := range h.subs {
if sub.projectID != "" && sub.projectID != rec.ProjectID {
continue
}
select {
case sub.ch <- rec:
default:
}
}
return nil
}

View File

@ -0,0 +1,83 @@
// Package notify owns notification write-side production and live dashboard fan-out.
package notify
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// Store is the write-side notification persistence boundary.
type Store interface {
CreateNotification(ctx context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error)
}
// Publisher pushes newly persisted notifications to live dashboard subscribers.
type Publisher interface {
Publish(ctx context.Context, rec domain.NotificationRecord) error
}
// Intent is the lifecycle-to-notification producer contract.
type Intent = ports.NotificationIntent
// Manager validates lifecycle intents, enriches them into stored rows, persists
// unread notifications, and publishes newly inserted rows to live subscribers.
type Manager struct {
store Store
publisher Publisher
clock func() time.Time
newID func() string
}
// Deps configures a Manager.
type Deps struct {
Store Store
Publisher Publisher
Clock func() time.Time
NewID func() string
}
// New constructs a write-side notification manager.
func New(d Deps) *Manager {
m := &Manager{store: d.Store, publisher: d.Publisher, clock: d.Clock, newID: d.NewID}
if m.clock == nil {
m.clock = time.Now
}
if m.newID == nil {
m.newID = func() string { return "ntf_" + uuid.NewString() }
}
return m
}
// Notify stores one notification intent and publishes it after persistence.
// Duplicate unread rows are treated as a clean no-op.
func (m *Manager) Notify(ctx context.Context, intent Intent) error {
if m == nil || m.store == nil {
return errors.New("notify: store is required")
}
if intent.CreatedAt.IsZero() {
intent.CreatedAt = m.clock().UTC()
}
rec, err := enrich(intent)
if err != nil {
return fmt.Errorf("notify enrich: %w", err)
}
rec.ID = m.newID()
created, inserted, err := m.store.CreateNotification(ctx, rec)
if err != nil {
return fmt.Errorf("notify store: %w", err)
}
if !inserted || m.publisher == nil {
return nil
}
if err := m.publisher.Publish(ctx, created); err != nil {
return fmt.Errorf("notify publish: %w", err)
}
return nil
}

View File

@ -0,0 +1,95 @@
package notify
import (
"context"
"errors"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
type fakeStore struct {
rows []domain.NotificationRecord
duplicate bool
err error
}
func (f *fakeStore) CreateNotification(_ context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error) {
if f.err != nil {
return domain.NotificationRecord{}, false, f.err
}
if f.duplicate {
return domain.NotificationRecord{}, false, nil
}
f.rows = append(f.rows, rec)
return rec, true, nil
}
func TestManagerNotifyPersistsThenPublishes(t *testing.T) {
st := &fakeStore{}
hub := NewHub()
ch, unsub := hub.Subscribe("")
defer unsub()
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC)
mgr := New(Deps{Store: st, Publisher: hub, Clock: func() time.Time { return now }, NewID: func() string { return "ntf_1" }})
if err := mgr.Notify(context.Background(), Intent{Type: domain.NotificationNeedsInput, SessionID: "mer-1", ProjectID: "mer", SessionDisplayName: "checkout-flow"}); err != nil {
t.Fatalf("Notify: %v", err)
}
if len(st.rows) != 1 {
t.Fatalf("stored rows = %d, want 1", len(st.rows))
}
if got := st.rows[0]; got.ID != "ntf_1" || got.CreatedAt != now || got.Status != domain.NotificationUnread || got.Title != "checkout-flow needs input" {
t.Fatalf("stored notification = %+v", got)
}
select {
case got := <-ch:
if got.ID != "ntf_1" {
t.Fatalf("published = %+v", got)
}
default:
t.Fatal("expected published notification")
}
}
func TestManagerNotifyDuplicateDoesNotPublish(t *testing.T) {
st := &fakeStore{duplicate: true}
hub := NewHub()
ch, unsub := hub.Subscribe("")
defer unsub()
mgr := New(Deps{Store: st, Publisher: hub, Clock: func() time.Time { return time.Now() }, NewID: func() string { return "ntf_1" }})
if err := mgr.Notify(context.Background(), Intent{Type: domain.NotificationNeedsInput, SessionID: "mer-1", ProjectID: "mer", CreatedAt: time.Now()}); err != nil {
t.Fatalf("Notify duplicate: %v", err)
}
select {
case got := <-ch:
t.Fatalf("duplicate published %+v", got)
default:
}
}
func TestManagerNotifyRejectsUnknownType(t *testing.T) {
mgr := New(Deps{Store: &fakeStore{}, Clock: func() time.Time { return time.Now() }})
err := mgr.Notify(context.Background(), Intent{Type: "surprise", SessionID: "mer-1", ProjectID: "mer"})
if !errors.Is(err, domain.ErrInvalidNotificationType) {
t.Fatalf("err = %v, want invalid type", err)
}
}
func TestHubProjectFilter(t *testing.T) {
hub := NewHub()
ch, unsub := hub.Subscribe("mer")
defer unsub()
_ = hub.Publish(context.Background(), domain.NotificationRecord{ID: "skip", ProjectID: "ao"})
_ = hub.Publish(context.Background(), domain.NotificationRecord{ID: "keep", ProjectID: "mer"})
select {
case got := <-ch:
if got.ID != "keep" {
t.Fatalf("published = %+v", got)
}
default:
t.Fatal("expected filtered notification")
}
}

View File

@ -0,0 +1,27 @@
package ports
import (
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// NotificationIntent is the lifecycle-to-notification-producer contract. It is
// not an HTTP DTO; lifecycle fills it from facts it already has after the
// underlying session/PR state write succeeds.
type NotificationIntent struct {
Type domain.NotificationType
SessionID domain.SessionID
ProjectID domain.ProjectID
PRURL string
CreatedAt time.Time
// Enrichment hints. These avoid storage reads on the hot path.
SessionDisplayName string
PRNumber int
PRTitle string
PRSourceBranch string
PRTargetBranch string
Provider string
Repo string
}

View File

@ -1,6 +1,13 @@
package ports
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
import (
"errors"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// ErrSessionNotFound reports an observation for an unknown session id.
var ErrSessionNotFound = errors.New("session not found")
// SpawnConfig is the request to start a new session: which project/issue, which
// agent harness, and the branch/prompt the agent launches with.

View File

@ -0,0 +1,68 @@
package notification
import (
"context"
"errors"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
const (
// DefaultListLimit is the unread notification page size used when none is requested.
DefaultListLimit = 50
// MaxListLimit caps unread notification API responses.
MaxListLimit = 100
)
// Manager reads stored notifications for REST controllers.
type Manager struct {
store Store
}
// Deps configures a Manager.
type Deps struct {
Store Store
}
// New constructs a read-only notification Manager.
func New(d Deps) *Manager {
return &Manager{store: d.Store}
}
// ListUnread returns unread notifications newest-first.
func (m *Manager) ListUnread(ctx context.Context, filter ListFilter) ([]Notification, error) {
if m == nil || m.store == nil {
return nil, errors.New("notification: store is required")
}
limit := normalizeLimit(filter.Limit)
rows, err := m.store.ListUnreadNotifications(ctx, limit)
if err != nil {
return nil, err
}
out := make([]Notification, 0, len(rows))
for _, row := range rows {
out = append(out, notificationFromRecord(row))
}
return out, nil
}
func normalizeLimit(limit int) int {
if limit <= 0 {
return DefaultListLimit
}
if limit > MaxListLimit {
return MaxListLimit
}
return limit
}
func notificationFromRecord(rec domain.NotificationRecord) Notification {
return Notification{NotificationRecord: rec, Target: targetForRecord(rec)}
}
func targetForRecord(rec domain.NotificationRecord) Target {
if rec.PRURL != "" {
return Target{Kind: TargetPR, SessionID: rec.SessionID, PRURL: rec.PRURL}
}
return Target{Kind: TargetSession, SessionID: rec.SessionID}
}

View File

@ -0,0 +1,44 @@
package notification
import (
"context"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
type fakeStore struct {
rows []domain.NotificationRecord
err error
}
func (f *fakeStore) CreateNotification(context.Context, domain.NotificationRecord) (domain.NotificationRecord, bool, error) {
return domain.NotificationRecord{}, false, nil
}
func (f *fakeStore) ListUnreadNotifications(_ context.Context, _ int) ([]domain.NotificationRecord, error) {
return f.rows, f.err
}
func TestListUnreadAddsTargets(t *testing.T) {
st := &fakeStore{rows: []domain.NotificationRecord{
{ID: "n1", SessionID: "mer-1", ProjectID: "mer", Type: domain.NotificationNeedsInput, Title: "needs", Status: domain.NotificationUnread, CreatedAt: time.Now()},
{ID: "n2", SessionID: "mer-1", ProjectID: "mer", PRURL: "https://github.com/o/r/pull/1", Type: domain.NotificationReadyToMerge, Title: "ready", Status: domain.NotificationUnread, CreatedAt: time.Now()},
}}
mgr := New(Deps{Store: st})
got, err := mgr.ListUnread(context.Background(), ListFilter{Limit: 10})
if err != nil {
t.Fatalf("ListUnread: %v", err)
}
if got[0].Target.Kind != TargetSession || got[1].Target.Kind != TargetPR || got[1].Target.PRURL == "" {
t.Fatalf("targets = %+v", got)
}
}
func TestListUnreadRequiresStore(t *testing.T) {
_, err := New(Deps{}).ListUnread(context.Background(), ListFilter{})
if err == nil {
t.Fatal("want missing store error")
}
}

View File

@ -0,0 +1,12 @@
package notification
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Store is the notification service's read persistence surface.
type Store interface {
ListUnreadNotifications(ctx context.Context, limit int) ([]domain.NotificationRecord, error)
}

View File

@ -0,0 +1,32 @@
// Package notification exposes read-only notification DTOs for REST controllers.
package notification
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
// TargetKind describes what a dashboard should navigate to for a notification.
type TargetKind string
const (
// TargetSession navigates to a session detail view.
TargetSession TargetKind = "session"
// TargetPR navigates to a pull request view.
TargetPR TargetKind = "pr"
)
// Target is the service-facing navigation metadata for a notification.
type Target struct {
Kind TargetKind
SessionID domain.SessionID
PRURL string
}
// Notification is the dashboard-facing service DTO assembled from a stored row.
type Notification struct {
domain.NotificationRecord
Target Target
}
// ListFilter controls unread notification listing.
type ListFilter struct {
Limit int
}

View File

@ -21,6 +21,18 @@ type ChangeLog struct {
CreatedAt time.Time
}
type Notification struct {
ID string
SessionID domain.SessionID
ProjectID domain.ProjectID
PRURL string
Type domain.NotificationType
Title string
Body string
Status domain.NotificationStatus
CreatedAt time.Time
}
type PR struct {
URL string
SessionID domain.SessionID

View File

@ -0,0 +1,130 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package gen
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
const createNotification = `-- name: CreateNotification :one
INSERT INTO notifications (
id, session_id, project_id, pr_url, type, title, body, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, session_id, project_id, pr_url, type, title, body, status, created_at
`
type CreateNotificationParams struct {
ID string
SessionID domain.SessionID
ProjectID domain.ProjectID
PRURL string
Type domain.NotificationType
Title string
Body string
Status domain.NotificationStatus
CreatedAt time.Time
}
func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) {
row := q.db.QueryRowContext(ctx, createNotification,
arg.ID,
arg.SessionID,
arg.ProjectID,
arg.PRURL,
arg.Type,
arg.Title,
arg.Body,
arg.Status,
arg.CreatedAt,
)
var i Notification
err := row.Scan(
&i.ID,
&i.SessionID,
&i.ProjectID,
&i.PRURL,
&i.Type,
&i.Title,
&i.Body,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const getUnreadNotificationByDedupe = `-- name: GetUnreadNotificationByDedupe :one
SELECT id, session_id, project_id, pr_url, type, title, body, status, created_at
FROM notifications
WHERE session_id = ? AND type = ? AND pr_url = ? AND status = 'unread'
LIMIT 1
`
type GetUnreadNotificationByDedupeParams struct {
SessionID domain.SessionID
Type domain.NotificationType
PRURL string
}
func (q *Queries) GetUnreadNotificationByDedupe(ctx context.Context, arg GetUnreadNotificationByDedupeParams) (Notification, error) {
row := q.db.QueryRowContext(ctx, getUnreadNotificationByDedupe, arg.SessionID, arg.Type, arg.PRURL)
var i Notification
err := row.Scan(
&i.ID,
&i.SessionID,
&i.ProjectID,
&i.PRURL,
&i.Type,
&i.Title,
&i.Body,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const listUnreadNotifications = `-- name: ListUnreadNotifications :many
SELECT id, session_id, project_id, pr_url, type, title, body, status, created_at
FROM notifications
WHERE status = 'unread'
ORDER BY created_at DESC
LIMIT ?
`
func (q *Queries) ListUnreadNotifications(ctx context.Context, limit int64) ([]Notification, error) {
rows, err := q.db.QueryContext(ctx, listUnreadNotifications, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Notification{}
for rows.Next() {
var i Notification
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.ProjectID,
&i.PRURL,
&i.Type,
&i.Title,
&i.Body,
&i.Status,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -0,0 +1,35 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
pr_url TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL CHECK (
type IN (
'needs_input',
'ready_to_merge',
'pr_merged',
'pr_closed_unmerged'
)
),
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'unread' CHECK (status IN ('read', 'unread')),
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_notifications_status
ON notifications(status, created_at DESC);
CREATE UNIQUE INDEX idx_notifications_unread_dedupe
ON notifications(session_id, type, pr_url)
WHERE status = 'unread';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_notifications_unread_dedupe;
DROP INDEX IF EXISTS idx_notifications_status;
DROP TABLE IF EXISTS notifications;
-- +goose StatementEnd

View File

@ -0,0 +1,19 @@
-- name: CreateNotification :one
INSERT INTO notifications (
id, session_id, project_id, pr_url, type, title, body, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: ListUnreadNotifications :many
SELECT *
FROM notifications
WHERE status = 'unread'
ORDER BY created_at DESC
LIMIT ?;
-- name: GetUnreadNotificationByDedupe :one
SELECT *
FROM notifications
WHERE session_id = ? AND type = ? AND pr_url = ? AND status = 'unread'
LIMIT 1;

View File

@ -0,0 +1,105 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
moderncsqlite "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen"
)
var _ notificationsvc.Store = (*Store)(nil)
// CreateNotification inserts one unread notification. It returns created=false
// when the unread dedupe index already has a matching row.
func (s *Store) CreateNotification(ctx context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error) {
if err := rec.Validate(); err != nil {
return domain.NotificationRecord{}, false, err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
if existing, ok, err := s.getUnreadNotificationByDedupe(ctx, rec); err != nil {
return domain.NotificationRecord{}, false, err
} else if ok {
return existing, false, nil
}
row, err := s.qw.CreateNotification(ctx, gen.CreateNotificationParams{
ID: rec.ID,
SessionID: rec.SessionID,
ProjectID: rec.ProjectID,
PRURL: rec.PRURL,
Type: rec.Type,
Title: rec.Title,
Body: rec.Body,
Status: rec.Status,
CreatedAt: rec.CreatedAt,
})
if err != nil {
if isSQLiteUnique(err) {
if existing, ok, lookupErr := s.getUnreadNotificationByDedupe(ctx, rec); lookupErr != nil {
return domain.NotificationRecord{}, false, lookupErr
} else if ok {
return existing, false, nil
}
}
return domain.NotificationRecord{}, false, fmt.Errorf("create notification %s: %w", rec.ID, err)
}
return notificationFromGen(row), true, nil
}
// ListUnreadNotifications returns unread notifications newest-first.
func (s *Store) ListUnreadNotifications(ctx context.Context, limit int) ([]domain.NotificationRecord, error) {
rows, err := s.qr.ListUnreadNotifications(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list unread notifications: %w", err)
}
return notificationsFromGen(rows), nil
}
func (s *Store) getUnreadNotificationByDedupe(ctx context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error) {
row, err := s.qw.GetUnreadNotificationByDedupe(ctx, gen.GetUnreadNotificationByDedupeParams{
SessionID: rec.SessionID,
Type: rec.Type,
PRURL: rec.PRURL,
})
if errors.Is(err, sql.ErrNoRows) {
return domain.NotificationRecord{}, false, nil
}
if err != nil {
return domain.NotificationRecord{}, false, fmt.Errorf("lookup unread notification dedupe: %w", err)
}
return notificationFromGen(row), true, nil
}
func isSQLiteUnique(err error) bool {
var sqliteErr *moderncsqlite.Error
return errors.As(err, &sqliteErr) && sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}
func notificationFromGen(row gen.Notification) domain.NotificationRecord {
return domain.NotificationRecord{
ID: row.ID,
SessionID: row.SessionID,
ProjectID: row.ProjectID,
PRURL: row.PRURL,
Type: row.Type,
Title: row.Title,
Body: row.Body,
Status: row.Status,
CreatedAt: row.CreatedAt,
}
}
func notificationsFromGen(rows []gen.Notification) []domain.NotificationRecord {
out := make([]domain.NotificationRecord, 0, len(rows))
for _, row := range rows {
out = append(out, notificationFromGen(row))
}
return out
}

View File

@ -0,0 +1,90 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func TestNotificationStore_InsertListAndDedupe(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
seedProject(t, s, "mer")
sess, err := s.CreateSession(ctx, sampleRecord("mer"))
if err != nil {
t.Fatalf("create session: %v", err)
}
now := time.Now().UTC().Truncate(time.Second)
rec := domain.NotificationRecord{
ID: "ntf_1",
SessionID: sess.ID,
ProjectID: sess.ProjectID,
Type: domain.NotificationNeedsInput,
Title: "checkout-flow needs input",
Status: domain.NotificationUnread,
CreatedAt: now,
}
created, inserted, err := s.CreateNotification(ctx, rec)
if err != nil || !inserted {
t.Fatalf("CreateNotification inserted=%v err=%v", inserted, err)
}
if created.ID != rec.ID || created.Title != rec.Title {
t.Fatalf("created = %+v", created)
}
dup := rec
dup.ID = "ntf_2"
_, inserted, err = s.CreateNotification(ctx, dup)
if err != nil || inserted {
t.Fatalf("duplicate inserted=%v err=%v, want false nil", inserted, err)
}
rows, err := s.ListUnreadNotifications(ctx, 10)
if err != nil {
t.Fatalf("ListUnreadNotifications: %v", err)
}
if len(rows) != 1 || rows[0].ID != "ntf_1" {
t.Fatalf("rows = %+v", rows)
}
}
func TestNotificationStore_ListUnreadNewestFirstAcrossProjects(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
seedProject(t, s, "mer")
seedProject(t, s, "ao")
mer, _ := s.CreateSession(ctx, sampleRecord("mer"))
ao, _ := s.CreateSession(ctx, sampleRecord("ao"))
base := time.Now().UTC().Truncate(time.Second)
for _, rec := range []domain.NotificationRecord{
{ID: "old", SessionID: mer.ID, ProjectID: mer.ProjectID, Type: domain.NotificationNeedsInput, Title: "old", Status: domain.NotificationUnread, CreatedAt: base},
{ID: "new", SessionID: mer.ID, ProjectID: mer.ProjectID, PRURL: "https://github.com/o/r/pull/1", Type: domain.NotificationReadyToMerge, Title: "new", Status: domain.NotificationUnread, CreatedAt: base.Add(time.Minute)},
{ID: "other", SessionID: ao.ID, ProjectID: ao.ProjectID, Type: domain.NotificationNeedsInput, Title: "other", Status: domain.NotificationUnread, CreatedAt: base.Add(2 * time.Minute)},
} {
if _, inserted, err := s.CreateNotification(ctx, rec); err != nil || !inserted {
t.Fatalf("insert %s inserted=%v err=%v", rec.ID, inserted, err)
}
}
rows, err := s.ListUnreadNotifications(ctx, 2)
if err != nil {
t.Fatalf("ListUnreadNotifications: %v", err)
}
if len(rows) != 2 || rows[0].ID != "other" || rows[1].ID != "new" {
t.Fatalf("rows = %+v", rows)
}
}
func TestNotificationStore_CheckConstraintRejectsInvalidStatus(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
seedProject(t, s, "mer")
sess, _ := s.CreateSession(ctx, sampleRecord("mer"))
_, _, err := s.CreateNotification(ctx, domain.NotificationRecord{
ID: "bad", SessionID: sess.ID, ProjectID: sess.ProjectID, Type: domain.NotificationNeedsInput,
Title: "bad", Status: "archived", CreatedAt: time.Now(),
})
if !errors.Is(err, domain.ErrInvalidNotificationStatus) {
t.Fatalf("err = %v, want invalid status", err)
}
}

View File

@ -56,6 +56,22 @@ sql:
type: "PRCheckStatus"
- column: "pr_comment.resolved"
go_type: "bool"
- column: "notifications.session_id"
go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type: "SessionID"
- column: "notifications.project_id"
go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type: "ProjectID"
- column: "notifications.type"
go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type: "NotificationType"
- column: "notifications.status"
go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type: "NotificationStatus"
- column: "projects.id"
go_type:
import: "github.com/aoagents/agent-orchestrator/backend/internal/domain"

View File

@ -21,6 +21,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/notifications": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List unread notifications */
get: operations["listNotifications"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/notifications/stream": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Stream created notifications */
get: operations["streamNotifications"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/orchestrators": {
parameters: {
query?: never;
@ -431,6 +465,9 @@ export interface components {
ok: boolean;
sessionId: string;
};
ListNotificationsResponse: {
notifications: components["schemas"]["NotificationResponse"][];
};
ListProjectsResponse: {
projects: components["schemas"]["ProjectSummary"][];
};
@ -449,6 +486,27 @@ export interface components {
ok: boolean;
prNumber: number;
};
NotificationResponse: {
body: string;
/** Format: date-time */
createdAt: string;
id: string;
prUrl: string;
projectId: string;
sessionId: string;
/** @enum {string} */
status: "unread" | "read";
target: components["schemas"]["NotificationTarget"];
title: string;
/** @enum {string} */
type: "needs_input" | "ready_to_merge" | "pr_merged" | "pr_closed_unmerged";
};
NotificationTarget: {
/** @enum {string} */
kind: "session" | "pr";
prUrl?: string;
sessionId: string;
};
OrchestratorResponse: {
id: string;
projectId: string;
@ -678,6 +736,99 @@ export interface operations {
};
};
};
listNotifications: {
parameters: {
query?: {
/** @description Notification status filter. V1 supports only unread. */
status?: "unread";
/** @description Maximum notifications to return. Defaults to 50; capped at 100. */
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListNotificationsResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
streamNotifications: {
parameters: {
query?: {
/** @description Optional project id filter for live notifications. */
projectId?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": string;
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
listOrchestrators: {
parameters: {
query?: never;