From fe77b2bf5a984ffdfbbebbf69c4e4ca725ac4ed4 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Thu, 27 Aug 2026 21:59:46 +0800 Subject: [PATCH 01/14] docs(context): sharpen Notification and Notification Center for the dual-stream inbox Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a67a90c8..e24e3718 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -660,7 +660,7 @@ _Avoid_: free workspace, trial period (for the state), Free plan (bare, for this The platform's fixed grace timeline that starts the moment a Workspace Subscription expires: the workspace is suspended immediately, the warning escalates as the countdown runs, and the workspace's resources are permanently deleted when it ends. Both roads into expiry — failed renewal payment and cancelled-then-lapsed — join the same countdown. The Billing Area surfaces it as a destructive warning carrying the stage's next deadline — the suspension date while a cancelled subscription's paid period still runs, the deletion date once expiry has passed; renewing (or resuming, before expiry) exits the countdown. -_Avoid_: grace period (as the user-facing name), debt period, deletion schedule. +_Avoid_: grace period (as the user-facing name), debt period, deletion schedule, paused (for the suspended workspace). ### Pay-As-You-Go (PAYG) @@ -714,13 +714,13 @@ _Avoid_: user currency preference, build-time currency. ### Notification Center -The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). User-scoped and global: it aggregates messages across every Project rather than belonging to one. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work. +The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). Global across every Project in the current workspace rather than belonging to one: every Workspace Actor sees the same messages, and only read state is personal. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work. _Avoid_: task center, activity feed, message center, alerts panel. ### Notification -One message addressed to the current user in the Notification Center: a system event (a deployment outcome, a database event), a billing or quota event, or a product announcement. Persistent and individually read/unread, which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. +One message addressed to the current user in the Notification Center: a system event (a deployment outcome, a database event), a billing or quota event, or a product announcement. Persistent and individually read/unread — read state is per message and per user, never a workspace-shared fact — which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. A Notification originates from the platform or from Brain itself; the two read identically in the Notification Center, though a platform-origin message may be withdrawn or revived by the platform when its underlying condition changes. _Avoid_: alert, toast (for persistent items), event (for the user-facing message). From fb7481dd995c2abd107948bcb49ec38c196c36ec Mon Sep 17 00:00:00 2001 From: aimeritething Date: Thu, 27 Aug 2026 22:24:58 +0800 Subject: [PATCH 02/14] feat(notifications): dual-stream Notification Center pipeline (AIM-332) The Notification Center goes from dev-mock shell to a real inbox with exactly one source of truth per message (ADR-0067): - Go read proxy `/api/notification/v1alpha1`: lists the namespace's upstream Notification CRs with the caller's kubeconfig bearer and merge-patches the desktop-compatible `isRead` label; route-level and fake-cluster service tests. - Brain store `sealai_notification`: `notification_messages` with a partial-unique live `dedupe_key` (release on recovery keeps history) and a 365-day sweep on write; `notification_read_receipts` per user x workspace x source-prefixed id, cascading from swept rows and re-keyed on account merge (ADR-0059). - First producer: quota-exhausted, edge-triggered per resource at 100%, observed at the chat turn and the sidebar's quota warm-up. - Merged feed: `cr::` / `db:` ids, real timestamps, unread = label unread AND no receipt; mark-read always writes a receipt and best-effort patches the CR unless the role is Developer. - Model moves from display-string times to epoch timestamps; the dev mock becomes a feed override. Co-Authored-By: Claude Fable 5 --- apps/api/main.go | 9 +- apps/api/route/notification/routes.go | 98 + apps/api/route/notification/routes_test.go | 79 + apps/api/service/notification/notification.go | 232 ++ .../service/notification/notification_test.go | 204 ++ apps/ui/drizzle.config.ts | 1 + apps/ui/drizzle/0015_notification_center.sql | 27 + apps/ui/drizzle/meta/0015_snapshot.json | 2827 +++++++++++++++++ apps/ui/drizzle/meta/_journal.json | 7 + apps/ui/scripts/reset-local-db.mjs | 2 + apps/ui/src/app/api/chat/route.ts | 9 + .../notifications/quota-observation/route.ts | 6 + .../src/app/api/notifications/read/route.ts | 6 + apps/ui/src/app/api/notifications/route.ts | 6 + .../billing/billing-plan-data.test.ts | 3 + .../src/features/billing/billing-plan-data.ts | 6 + apps/ui/src/features/notifications/client.ts | 84 + .../ui/src/features/notifications/db-types.ts | 23 + apps/ui/src/features/notifications/db.ts | 28 + .../features/notifications/feed-model.test.ts | 187 ++ .../src/features/notifications/feed-model.ts | 163 + .../notifications/http-handlers.test.ts | 217 ++ .../features/notifications/http-handlers.ts | 151 + .../notifications/notification-ids.ts | 6 + .../notifications/notification-time.test.ts | 27 + .../notifications/notification-time.ts | 36 + .../producer-quota-exhausted.test.ts | 139 + .../notifications/producer-quota-exhausted.ts | 88 + .../src/features/notifications/producers.ts | 25 + .../notifications/read-dispatch.test.ts | 59 + .../features/notifications/read-dispatch.ts | 39 + .../features/notifications/route-handlers.ts | 13 + apps/ui/src/features/notifications/schema.ts | 97 + .../src/features/notifications/store.test.ts | 232 ++ apps/ui/src/features/notifications/store.ts | 243 ++ apps/ui/src/features/notifications/types.ts | 90 + .../notifications/use-notification-feed.ts | 145 + .../app-sidebar-account-presentation.test.ts | 1 + .../features/shell/app-sidebar-account.tsx | 18 +- .../app-sidebar-notifications-dev-mock.tsx | 149 +- .../app-sidebar-notifications-model.test.ts | 5 +- .../shell/app-sidebar-notifications-model.ts | 22 +- .../shell/app-sidebar-notifications-store.ts | 18 +- .../shell/app-sidebar-notifications.tsx | 48 +- apps/ui/src/features/shell/app-sidebar.tsx | 21 +- .../use-workspace-subscription-summary.ts | 31 + apps/ui/src/lib/identity-fingerprint-core.ts | 42 + ...hybrid-cr-read-proxy-and-brain-postgres.md | 90 + docs/adr/README.md | 1 + packages/api/src/constants.ts | 6 + packages/api/src/hooks/index.ts | 11 + .../src/hooks/use-notification-crs.test.ts | 47 + .../api/src/hooks/use-notification-crs.ts | 123 + 53 files changed, 6114 insertions(+), 133 deletions(-) create mode 100644 apps/api/route/notification/routes.go create mode 100644 apps/api/route/notification/routes_test.go create mode 100644 apps/api/service/notification/notification.go create mode 100644 apps/api/service/notification/notification_test.go create mode 100644 apps/ui/drizzle/0015_notification_center.sql create mode 100644 apps/ui/drizzle/meta/0015_snapshot.json create mode 100644 apps/ui/src/app/api/notifications/quota-observation/route.ts create mode 100644 apps/ui/src/app/api/notifications/read/route.ts create mode 100644 apps/ui/src/app/api/notifications/route.ts create mode 100644 apps/ui/src/features/notifications/client.ts create mode 100644 apps/ui/src/features/notifications/db-types.ts create mode 100644 apps/ui/src/features/notifications/db.ts create mode 100644 apps/ui/src/features/notifications/feed-model.test.ts create mode 100644 apps/ui/src/features/notifications/feed-model.ts create mode 100644 apps/ui/src/features/notifications/http-handlers.test.ts create mode 100644 apps/ui/src/features/notifications/http-handlers.ts create mode 100644 apps/ui/src/features/notifications/notification-ids.ts create mode 100644 apps/ui/src/features/notifications/notification-time.test.ts create mode 100644 apps/ui/src/features/notifications/notification-time.ts create mode 100644 apps/ui/src/features/notifications/producer-quota-exhausted.test.ts create mode 100644 apps/ui/src/features/notifications/producer-quota-exhausted.ts create mode 100644 apps/ui/src/features/notifications/producers.ts create mode 100644 apps/ui/src/features/notifications/read-dispatch.test.ts create mode 100644 apps/ui/src/features/notifications/read-dispatch.ts create mode 100644 apps/ui/src/features/notifications/route-handlers.ts create mode 100644 apps/ui/src/features/notifications/schema.ts create mode 100644 apps/ui/src/features/notifications/store.test.ts create mode 100644 apps/ui/src/features/notifications/store.ts create mode 100644 apps/ui/src/features/notifications/types.ts create mode 100644 apps/ui/src/features/notifications/use-notification-feed.ts create mode 100644 apps/ui/src/features/shell/use-workspace-subscription-summary.ts create mode 100644 docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md create mode 100644 packages/api/src/hooks/use-notification-crs.test.ts create mode 100644 packages/api/src/hooks/use-notification-crs.ts diff --git a/apps/api/main.go b/apps/api/main.go index 4218e710..0ef1d4d4 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -22,6 +22,7 @@ import ( "sealos/api/route/db" "sealos/api/route/health" "sealos/api/route/k8s" + "sealos/api/route/notification" "sealos/api/route/telemetry" ) @@ -70,6 +71,7 @@ func main() { k8s.RegisterExecWebSocket(router) ap.Register(api) db.Register(api) + notification.Register(api) telemetry.Register(api) fmt.Printf("Server listening on :%s\n", port) @@ -346,9 +348,10 @@ func addLogsQueryExamples(_ *huma.OpenAPI, op *huma.Operation) { // those paths and internally appends the slash so chi can match. func appendSlashForGroupRoots(next http.Handler) http.Handler { roots := map[string]bool{ - "/api/ap/v1alpha1": true, - "/api/db/v1alpha1": true, - "/api/k8s/v1alpha1": true, + "/api/ap/v1alpha1": true, + "/api/db/v1alpha1": true, + "/api/k8s/v1alpha1": true, + notification.BasePath: true, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if roots[r.URL.Path] { diff --git a/apps/api/route/notification/routes.go b/apps/api/route/notification/routes.go new file mode 100644 index 00000000..70b657e7 --- /dev/null +++ b/apps/api/route/notification/routes.go @@ -0,0 +1,98 @@ +// Package notification exposes the Notification Center's platform stream: +// a read proxy over the upstream Notification CRs of the caller's namespace, +// authenticated with the caller's own kubeconfig bearer token (no standing +// credentials on the Brain side), plus the desktop-compatible mark-read patch. +package notification + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "sealos/api/middleware" + notificationsvc "sealos/api/service/notification" +) + +// BasePath is the group root; `main.go` accepts it with or without the slash. +const BasePath = "/api/notification/v1alpha1" + +// Register adds the Notification API routes to the Huma API. +func Register(api huma.API) { + grp := huma.NewGroup(api, BasePath) + registerList(grp) + registerMarkRead(grp) +} + +func registerList(grp huma.API) { + type listInput struct { + middleware.AuthInput + Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"` + } + type listOutput struct { + Body notificationsvc.ListResult + } + + huma.Register(grp, huma.Operation{ + OperationID: "notification-list", + Method: http.MethodGet, + Path: "/", + Summary: "List platform Notifications", + Description: "List the upstream Notification CRs (`notifications.notification.sealos.io/v1`) of the resolved namespace, newest first. The platform writes and withdraws these; Brain only reads them. `isRead` mirrors the CR's `isRead` label — the same state the Sealos desktop message center shows.", + Tags: []string{"Notification"}, + }, func(ctx context.Context, input *listInput) (*listOutput, error) { + _, cfg, err := middleware.RestConfigFromAuth(input.Authorization) + if err != nil { + return nil, huma.Error400BadRequest("invalid kubeconfig", err) + } + result, err := notificationsvc.List(ctx, cfg, input.Namespace) + if err != nil { + return nil, mapK8sError("failed to list notifications", err) + } + return &listOutput{Body: *result}, nil + }) +} + +func registerMarkRead(grp huma.API) { + type markReadInput struct { + middleware.AuthInput + Name string `path:"name" doc:"Notification CR metadata.name"` + Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"` + } + type markReadOutput struct { + Body notificationsvc.ReadResult + } + + huma.Register(grp, huma.Operation{ + OperationID: "notification-mark-read", + Method: http.MethodPatch, + Path: "/{name}/read", + Summary: "Mark a platform Notification read", + Description: "Merge-patch `metadata.labels.isRead: \"true\"` on one Notification CR — the write the Sealos desktop performs, so the desktop bell follows. Callers whose kubeconfig lacks patch permission (workspace Developers) receive 403; the Notification Center treats that as a best-effort skip because its own per-user receipt already records the read.", + Tags: []string{"Notification"}, + }, func(ctx context.Context, input *markReadInput) (*markReadOutput, error) { + _, cfg, err := middleware.RestConfigFromAuth(input.Authorization) + if err != nil { + return nil, huma.Error400BadRequest("invalid kubeconfig", err) + } + result, err := notificationsvc.MarkRead(ctx, cfg, input.Namespace, input.Name) + if err != nil { + return nil, mapK8sError("failed to mark notification read", err) + } + return &markReadOutput{Body: *result}, nil + }) +} + +func mapK8sError(message string, err error) error { + switch { + case apierrors.IsNotFound(err): + return huma.Error404NotFound("notification not found", err) + case apierrors.IsForbidden(err): + return huma.Error403Forbidden("notification access forbidden", err) + case apierrors.IsUnauthorized(err): + return huma.Error401Unauthorized("invalid kubeconfig", err) + default: + return huma.Error500InternalServerError(message, err) + } +} diff --git a/apps/api/route/notification/routes_test.go b/apps/api/route/notification/routes_test.go new file mode 100644 index 00000000..51078179 --- /dev/null +++ b/apps/api/route/notification/routes_test.go @@ -0,0 +1,79 @@ +package notification + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" +) + +func newTestAPI() (chi.Router, huma.API) { + router := chi.NewRouter() + api := humachi.New(router, huma.DefaultConfig("test", "0.0.0")) + Register(api) + return router, api +} + +func TestRegisterExposesListAndMarkRead(t *testing.T) { + _, api := newTestAPI() + + list := api.OpenAPI().Paths["/api/notification/v1alpha1/"] + if list == nil || list.Get == nil { + t.Fatalf("expected GET /api/notification/v1alpha1/ to be registered") + } + if list.Get.OperationID != "notification-list" { + t.Fatalf("unexpected list operation ID: %q", list.Get.OperationID) + } + + read := api.OpenAPI().Paths["/api/notification/v1alpha1/{name}/read"] + if read == nil || read.Patch == nil { + t.Fatalf("expected PATCH /api/notification/v1alpha1/{name}/read to be registered") + } + if read.Patch.OperationID != "notification-mark-read" { + t.Fatalf("unexpected mark-read operation ID: %q", read.Patch.OperationID) + } +} + +func TestListRequiresAuthorizationAtHTTPBoundary(t *testing.T) { + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodGet, "/api/notification/v1alpha1/?namespace=ns-a", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected a missing Authorization header to be rejected, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListRejectsInvalidKubeconfigBeforeTouchingTheCluster(t *testing.T) { + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodGet, "/api/notification/v1alpha1/?namespace=ns-a", nil) + req.Header.Set("Authorization", "Bearer not-a-kubeconfig") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected an invalid kubeconfig to answer 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMarkReadRejectsInvalidKubeconfigBeforeTouchingTheCluster(t *testing.T) { + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodPatch, "/api/notification/v1alpha1/debt-choice-debtperiod/read?namespace=ns-a", nil) + req.Header.Set("Authorization", "Bearer not-a-kubeconfig") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected an invalid kubeconfig to answer 400, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/apps/api/service/notification/notification.go b/apps/api/service/notification/notification.go new file mode 100644 index 00000000..32983e67 --- /dev/null +++ b/apps/api/service/notification/notification.go @@ -0,0 +1,232 @@ +// Package notification reads the platform's Notification CRs +// (`notifications.notification.sealos.io/v1`) for the Notification Center. +// +// The platform is the single source of truth for these messages: the account +// and workspace-subscription controllers write fixed-name CRs (for example +// `debt-choice-debtperiod`, `workspace-debt-debt`) into every user namespace, +// overwrite them in place when a state escalates (flipping `isRead` back to +// "false"), and mark them read again on recovery. Brain never copies them — +// it lists them with the caller's own kubeconfig and patches the same +// `isRead` label the Sealos desktop uses, so both surfaces agree. +package notification + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "sealos/api/middleware" +) + +// GVR is the upstream Notification CRD resource. +var GVR = schema.GroupVersionResource{ + Group: "notification.sealos.io", + Version: "v1", + Resource: "notifications", +} + +// ReadLabel is the CR label the platform and the desktop use for read state. +const ReadLabel = "isRead" + +// Item is one platform Notification, flattened for the UI. Copy is the CR's +// default language as written by upstream; `i18ns` is deliberately dropped. +type Item struct { + Name string `json:"name" doc:"CR metadata.name; fixed per upstream scenario and overwritten in place"` + Namespace string `json:"namespace"` + UID string `json:"uid,omitempty"` + IsRead bool `json:"isRead" doc:"metadata.labels.isRead == \"true\""` + Title string `json:"title"` + Message string `json:"message"` + From string `json:"from,omitempty"` + Importance string `json:"importance,omitempty" doc:"High, Medium, or Low"` + DesktopPopup bool `json:"desktopPopup"` + Timestamp int64 `json:"timestamp" doc:"spec.timestamp (Unix seconds); creationTimestamp when upstream omitted it"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} + +// ListResult is the list response body. +type ListResult struct { + Namespace string `json:"namespace"` + Items []Item `json:"items"` +} + +// ReadResult is the mark-read response body. +type ReadResult struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + IsRead bool `json:"isRead"` +} + +type dynamicClientFunc func(*clientcmdapi.Config, string) (dynamic.Interface, string, error) + +var dynamicClientFactory dynamicClientFunc = defaultDynamicClientFactory + +func defaultDynamicClientFactory(cfg *clientcmdapi.Config, ns string) (dynamic.Interface, string, error) { + resolved, err := middleware.ResolveContext(cfg, middleware.ResolveOptions{Namespace: ns}) + if err != nil { + return nil, "", err + } + if strings.TrimSpace(resolved.Namespace) == "" { + return nil, "", fmt.Errorf("namespace is required") + } + client, err := dynamic.NewForConfig(resolved.RestConfig) + if err != nil { + return nil, "", err + } + return client, resolved.Namespace, nil +} + +// List returns the namespace's live Notification CRs using the caller's kubeconfig. +func List(ctx context.Context, cfg *clientcmdapi.Config, namespace string) (*ListResult, error) { + client, ns, err := dynamicClientFactory(cfg, namespace) + if err != nil { + return nil, err + } + return ListWithClient(ctx, client, ns, time.Now()) +} + +// MarkRead flips the CR's `isRead` label to "true" using the caller's kubeconfig. +func MarkRead(ctx context.Context, cfg *clientcmdapi.Config, namespace string, name string) (*ReadResult, error) { + client, ns, err := dynamicClientFactory(cfg, namespace) + if err != nil { + return nil, err + } + return MarkReadWithClient(ctx, client, ns, name) +} + +// ListWithClient lists the namespace's Notification CRs, drops entries outside +// their optional `startTime`/`endTime` window (the desktop's rule), and orders +// them newest first. +func ListWithClient(ctx context.Context, client dynamic.Interface, namespace string, now time.Time) (*ListResult, error) { + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + list, err := client.Resource(GVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + items := make([]Item, 0, len(list.Items)) + for i := range list.Items { + item, visible := itemFromUnstructured(&list.Items[i], now) + if visible { + items = append(items, item) + } + } + sort.SliceStable(items, func(a, b int) bool { + if items[a].Timestamp != items[b].Timestamp { + return items[a].Timestamp > items[b].Timestamp + } + return items[a].Name < items[b].Name + }) + return &ListResult{Namespace: namespace, Items: items}, nil +} + +// MarkReadWithClient merge-patches `metadata.labels.isRead: "true"` on one CR — +// the same write the Sealos desktop performs, so the desktop bell follows. +func MarkReadWithClient(ctx context.Context, client dynamic.Interface, namespace string, name string) (*ReadResult, error) { + namespace = strings.TrimSpace(namespace) + name = strings.TrimSpace(name) + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + patch, err := json.Marshal(map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{ReadLabel: "true"}, + }, + }) + if err != nil { + return nil, err + } + obj, err := client.Resource(GVR).Namespace(namespace).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + return nil, err + } + return &ReadResult{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + IsRead: obj.GetLabels()[ReadLabel] == "true", + }, nil +} + +func itemFromUnstructured(obj *unstructured.Unstructured, now time.Time) (Item, bool) { + spec, _, _ := unstructured.NestedMap(obj.Object, "spec") + if startedAt, ok := parseMetaTime(spec["startTime"]); ok && now.Before(startedAt) { + return Item{}, false + } + if endedAt, ok := parseMetaTime(spec["endTime"]); ok && !now.Before(endedAt) { + return Item{}, false + } + item := Item{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + UID: string(obj.GetUID()), + IsRead: obj.GetLabels()[ReadLabel] == "true", + Title: stringField(spec, "title"), + Message: stringField(spec, "message"), + From: stringField(spec, "from"), + Importance: stringField(spec, "importance"), + DesktopPopup: boolField(spec, "desktopPopup"), + Timestamp: int64Field(spec, "timestamp"), + } + created := obj.GetCreationTimestamp() + if !created.IsZero() { + item.CreationTimestamp = created.UTC().Format(time.RFC3339) + if item.Timestamp == 0 { + item.Timestamp = created.Unix() + } + } + return item, true +} + +func parseMetaTime(value interface{}) (time.Time, bool) { + raw, ok := value.(string) + if !ok || strings.TrimSpace(raw) == "" { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return parsed, true +} + +func stringField(m map[string]interface{}, key string) string { + value, _ := m[key].(string) + return value +} + +func boolField(m map[string]interface{}, key string) bool { + value, _ := m[key].(bool) + return value +} + +func int64Field(m map[string]interface{}, key string) int64 { + switch value := m[key].(type) { + case int64: + return value + case int: + return int64(value) + case float64: + return int64(value) + case json.Number: + parsed, err := value.Int64() + if err == nil { + return parsed + } + } + return 0 +} diff --git a/apps/api/service/notification/notification_test.go b/apps/api/service/notification/notification_test.go new file mode 100644 index 00000000..07a80d65 --- /dev/null +++ b/apps/api/service/notification/notification_test.go @@ -0,0 +1,204 @@ +package notification + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" +) + +func testClient(objects ...runtime.Object) dynamic.Interface { + return fake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{GVR: "NotificationList"}, + objects..., + ) +} + +func testNotification(name, namespace string, isRead string, spec map[string]interface{}) *unstructured.Unstructured { + metadata := map[string]interface{}{ + "name": name, + "namespace": namespace, + "uid": "uid-" + name, + "creationTimestamp": "2026-08-20T10:00:00Z", + } + if isRead != "" { + metadata["labels"] = map[string]interface{}{"isRead": isRead} + } + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "notification.sealos.io/v1", + "kind": "Notification", + "metadata": metadata, + "spec": spec, + }} +} + +func testNow() time.Time { + return time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) +} + +func TestListWithClientFlattensAndOrdersNewestFirst(t *testing.T) { + client := testClient( + testNotification("debt-choice-debtperiod", "ns-a", "false", map[string]interface{}{ + "title": "Balance exhausted", + "message": "Your services will be suspended.", + "from": "Debt-System", + "importance": "High", + "desktopPopup": true, + "timestamp": int64(1756200000), + "i18ns": map[string]interface{}{"zh": map[string]interface{}{"title": "余额耗尽"}}, + }), + testNotification("workspace-debt-debt", "ns-a", "true", map[string]interface{}{ + "title": "Workspace suspended", + "message": "Renew to resume.", + "from": "Workspace-Subscription-System", + "timestamp": int64(1756300000), + }), + testNotification("other-namespace", "ns-b", "false", map[string]interface{}{ + "title": "Not mine", + "message": "", + "timestamp": int64(1756400000), + }), + ) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if got.Namespace != "ns-a" { + t.Fatalf("expected namespace ns-a, got %q", got.Namespace) + } + if len(got.Items) != 2 { + t.Fatalf("expected 2 items in ns-a, got %d: %+v", len(got.Items), got.Items) + } + first, second := got.Items[0], got.Items[1] + if first.Name != "workspace-debt-debt" || second.Name != "debt-choice-debtperiod" { + t.Fatalf("expected newest first, got %q then %q", first.Name, second.Name) + } + if !first.IsRead || second.IsRead { + t.Fatalf("expected isRead to follow the label: first=%v second=%v", first.IsRead, second.IsRead) + } + if second.Title != "Balance exhausted" || second.Message != "Your services will be suspended." { + t.Fatalf("unexpected copy: %+v", second) + } + if second.From != "Debt-System" || second.Importance != "High" || !second.DesktopPopup { + t.Fatalf("unexpected metadata: %+v", second) + } + if second.Timestamp != 1756200000 { + t.Fatalf("expected spec.timestamp to pass through, got %d", second.Timestamp) + } + if second.CreationTimestamp != "2026-08-20T10:00:00Z" { + t.Fatalf("expected creationTimestamp to pass through, got %q", second.CreationTimestamp) + } +} + +func TestListWithClientFallsBackToCreationTimestampAndTreatsMissingLabelAsUnread(t *testing.T) { + client := testClient( + testNotification("announcement", "ns-a", "", map[string]interface{}{ + "title": "Welcome", + "message": "Hello", + }), + ) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(got.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(got.Items)) + } + item := got.Items[0] + if item.IsRead { + t.Fatalf("expected a CR without the isRead label to be unread") + } + wantCreated := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC).Unix() + if item.Timestamp != wantCreated { + t.Fatalf("expected timestamp to fall back to creationTimestamp %d, got %d", wantCreated, item.Timestamp) + } +} + +func TestListWithClientHonoursStartAndEndWindows(t *testing.T) { + client := testClient( + testNotification("not-yet", "ns-a", "false", map[string]interface{}{ + "title": "Scheduled", + "message": "", + "timestamp": int64(1), + "startTime": "2026-09-01T00:00:00Z", + }), + testNotification("expired", "ns-a", "false", map[string]interface{}{ + "title": "Old", + "message": "", + "timestamp": int64(2), + "endTime": "2026-08-01T00:00:00Z", + }), + testNotification("live", "ns-a", "false", map[string]interface{}{ + "title": "Live", + "message": "", + "timestamp": int64(3), + "startTime": "2026-08-01T00:00:00Z", + "endTime": "2026-09-01T00:00:00Z", + }), + ) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(got.Items) != 1 || got.Items[0].Name != "live" { + t.Fatalf("expected only the live notification, got %+v", got.Items) + } +} + +func TestListWithClientRequiresNamespace(t *testing.T) { + if _, err := ListWithClient(context.Background(), testClient(), " ", testNow()); err == nil { + t.Fatalf("expected an error for a blank namespace") + } +} + +func TestMarkReadWithClientPatchesTheReadLabel(t *testing.T) { + client := testClient( + testNotification("debt-choice-debtperiod", "ns-a", "false", map[string]interface{}{ + "title": "Balance exhausted", + "message": "", + "timestamp": int64(1756200000), + }), + ) + + got, err := MarkReadWithClient(context.Background(), client, "ns-a", "debt-choice-debtperiod") + if err != nil { + t.Fatalf("MarkReadWithClient returned error: %v", err) + } + if !got.IsRead || got.Name != "debt-choice-debtperiod" || got.Namespace != "ns-a" { + t.Fatalf("unexpected read result: %+v", got) + } + + listed, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(listed.Items) != 1 || !listed.Items[0].IsRead { + t.Fatalf("expected the CR to read back as read, got %+v", listed.Items) + } + if listed.Items[0].Title != "Balance exhausted" { + t.Fatalf("expected the merge patch to leave spec untouched, got %+v", listed.Items[0]) + } +} + +func TestMarkReadWithClientSurfacesNotFound(t *testing.T) { + _, err := MarkReadWithClient(context.Background(), testClient(), "ns-a", "missing") + if err == nil || !apierrors.IsNotFound(err) { + t.Fatalf("expected a NotFound error, got %v", err) + } +} + +func TestMarkReadWithClientRequiresName(t *testing.T) { + if _, err := MarkReadWithClient(context.Background(), testClient(), "ns-a", " "); err == nil { + t.Fatalf("expected an error for a blank name") + } +} diff --git a/apps/ui/drizzle.config.ts b/apps/ui/drizzle.config.ts index 0f95e632..84d93fba 100644 --- a/apps/ui/drizzle.config.ts +++ b/apps/ui/drizzle.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "./src/features/deploy/task/schema.ts", "./src/features/onboarding/schema.ts", "./src/features/marketing/schema.ts", + "./src/features/notifications/schema.ts", "./src/lib/project-persistence/schema.ts", ], dbCredentials: { url: process.env.DATABASE_URL ?? "" }, diff --git a/apps/ui/drizzle/0015_notification_center.sql b/apps/ui/drizzle/0015_notification_center.sql new file mode 100644 index 00000000..4e5c809d --- /dev/null +++ b/apps/ui/drizzle/0015_notification_center.sql @@ -0,0 +1,27 @@ +CREATE SCHEMA "sealai_notification"; +--> statement-breakpoint +CREATE TABLE "sealai_notification"."notification_messages" ( + "id" text PRIMARY KEY NOT NULL, + "namespace" text NOT NULL, + "kind" text NOT NULL, + "project_uid" text, + "payload" jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sealai_notification"."notification_read_receipts" ( + "user_uid" text NOT NULL, + "namespace" text NOT NULL, + "message_key" text NOT NULL, + "message_id" text, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_read_receipts_user_uid_namespace_message_key_pk" PRIMARY KEY("user_uid","namespace","message_key") +); +--> statement-breakpoint +ALTER TABLE "sealai_notification"."notification_read_receipts" ADD CONSTRAINT "notification_read_receipts_message_id_notification_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "sealai_notification"."notification_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "notification_messages_live_dedupe_key_idx" ON "sealai_notification"."notification_messages" USING btree ("dedupe_key") WHERE "sealai_notification"."notification_messages"."released_at" IS NULL;--> statement-breakpoint +CREATE INDEX "notification_messages_namespace_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("namespace","created_at");--> statement-breakpoint +CREATE INDEX "notification_messages_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "notification_read_receipts_message_id_idx" ON "sealai_notification"."notification_read_receipts" USING btree ("message_id"); \ No newline at end of file diff --git a/apps/ui/drizzle/meta/0015_snapshot.json b/apps/ui/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..ff531759 --- /dev/null +++ b/apps/ui/drizzle/meta/0015_snapshot.json @@ -0,0 +1,2827 @@ +{ + "id": "18095ed1-1f31-475f-b343-789f3ea17726", + "prevId": "67332fdf-15e9-467a-aecf-25706210e908", + "version": "7", + "dialect": "postgresql", + "tables": { + "sealai_assistant.assistant_chat_messages": { + "name": "assistant_chat_messages", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chat_messages_chat_id_idx": { + "name": "assistant_chat_messages_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chat_messages_chat_id_created_idx": { + "name": "assistant_chat_messages_chat_id_created_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assistant_chat_messages_chat_id_assistant_chats_id_fk": { + "name": "assistant_chat_messages_chat_id_assistant_chats_id_fk", + "tableFrom": "assistant_chat_messages", + "tableTo": "assistant_chats", + "schemaTo": "sealai_assistant", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_chats": { + "name": "assistant_chats", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Chat'" + }, + "title_ai_generated": { + "name": "title_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chats_updated_at_idx": { + "name": "assistant_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chats_namespace_actor_updated_at_idx": { + "name": "assistant_chats_namespace_actor_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_devbox_runtimes": { + "name": "assistant_devbox_runtimes", + "schema": "sealai_assistant", + "columns": { + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pause_due_at": { + "name": "pause_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_due_at": { + "name": "delete_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_owner": { + "name": "cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_expires_at": { + "name": "cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_devbox_runtimes_pause_due_idx": { + "name": "assistant_devbox_runtimes_pause_due_idx", + "columns": [ + { + "expression": "pause_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"paused_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_devbox_runtimes_delete_due_idx": { + "name": "assistant_devbox_runtimes_delete_due_idx", + "columns": [ + { + "expression": "delete_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"delete_due_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_entitlements": { + "name": "assistant_entitlements", + "schema": "sealai_assistant", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "free_turns_used": { + "name": "free_turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_entitlements_updated_at_idx": { + "name": "assistant_entitlements_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_app_install_sessions": { + "name": "github_app_install_sessions", + "schema": "sealai_assistant", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_app_install_sessions_expires_at_idx": { + "name": "github_app_install_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_app_install_sessions_namespace_idx": { + "name": "github_app_install_sessions_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_connections": { + "name": "github_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github_app'" + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'User'" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_connections_updated_at_idx": { + "name": "github_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_updated_at_idx": { + "name": "github_connections_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_unique_idx": { + "name": "github_connections_namespace_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_installation_idx": { + "name": "github_connections_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_oauth_connections": { + "name": "github_oauth_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bearer'" + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_oauth_connections_updated_at_idx": { + "name": "github_oauth_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_github_login_idx": { + "name": "github_oauth_connections_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_current_owner_unique_idx": { + "name": "github_oauth_connections_current_owner_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_assistant\".\"github_oauth_connections\".\"owner_identity_version\" = 2", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_fingerprints": { + "name": "identity_fingerprints", + "schema": "sealai_assistant", + "columns": { + "cr_name": { + "name": "cr_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "minted_at": { + "name": "minted_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_uid_canonicalizations": { + "name": "identity_uid_canonicalizations", + "schema": "sealai_assistant", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canonical_user_uid": { + "name": "canonical_user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_uid_canonicalizations_canonical_idx": { + "name": "identity_uid_canonicalizations_canonical_idx", + "columns": [ + { + "expression": "canonical_user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_agent_calls": { + "name": "deploy_task_agent_calls", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deploy_task_agent_calls_pending_idx": { + "name": "deploy_task_agent_calls_pending_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_agent_calls_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_agent_calls_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_agent_calls", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_agent_calls_pk": { + "name": "deploy_task_agent_calls_pk", + "columns": ["task_id", "call_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_task_agent_calls_lease_epoch_nonnegative": { + "name": "deploy_task_agent_calls_lease_epoch_nonnegative", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"lease_epoch\" >= 0" + }, + "deploy_task_agent_calls_tool_name_valid": { + "name": "deploy_task_agent_calls_tool_name_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"tool_name\" IN ('template_ready', 'deployment_completed')" + }, + "deploy_task_agent_calls_state_valid": { + "name": "deploy_task_agent_calls_state_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" IN ('pending', 'running', 'succeeded', 'failed')" + }, + "deploy_task_agent_calls_request_hash_valid": { + "name": "deploy_task_agent_calls_request_hash_valid", + "value": "char_length(\"sealai_deployment\".\"deploy_task_agent_calls\".\"request_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_events": { + "name": "deploy_task_events", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "nextval('sealai_deployment.deploy_task_events_seq'::regclass)" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_events_task_created_at_idx": { + "name": "deploy_task_events_task_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_events_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_events_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_events", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_events_pk": { + "name": "deploy_task_events_pk", + "columns": ["task_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_messages": { + "name": "deploy_task_messages", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_messages_task_id_idx": { + "name": "deploy_task_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_task_messages_task_created_idx": { + "name": "deploy_task_messages_task_created_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_messages_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_messages_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_messages", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_tasks": { + "name": "deploy_tasks", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creating_actor": { + "name": "creating_actor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "marketing_attribution": { + "name": "marketing_attribution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_binding": { + "name": "credential_binding", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_connection_id": { + "name": "github_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "runner": { + "name": "runner", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_state": { + "name": "runtime_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_paused_at": { + "name": "runtime_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_owner": { + "name": "runtime_cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_expires_at": { + "name": "runtime_cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gateway_url": { + "name": "gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_session_id": { + "name": "gateway_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_thread_id": { + "name": "gateway_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_turn_id": { + "name": "gateway_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_state_snapshot": { + "name": "gateway_state_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "agent_turn_count": { + "name": "agent_turn_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_protocol": { + "name": "agent_protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mcp-v1'" + }, + "agent_control_token_hash": { + "name": "agent_control_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_control_token_revoked_at": { + "name": "agent_control_token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_template_digest": { + "name": "agent_template_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_schema_digest": { + "name": "agent_input_schema_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_checkpoint_id": { + "name": "agent_checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_revision": { + "name": "agent_input_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_completion_receipt": { + "name": "agent_completion_receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_details": { + "name": "failure_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifact_summary": { + "name": "artifact_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "canvas_projection": { + "name": "canvas_projection", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "timeline_snapshot": { + "name": "timeline_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocking_inputs": { + "name": "blocking_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_url": { + "name": "result_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_claimed_at": { + "name": "lease_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retried_from_task_id": { + "name": "retried_from_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_tasks_agent_control_token_hash_idx": { + "name": "deploy_tasks_agent_control_token_hash_idx", + "columns": [ + { + "expression": "agent_control_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_namespace_updated_at_idx": { + "name": "deploy_tasks_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_project_uid_updated_at_idx": { + "name": "deploy_tasks_project_uid_updated_at_idx", + "columns": [ + { + "expression": "project_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_status_updated_at_idx": { + "name": "deploy_tasks_status_updated_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_leased_expiry_idx": { + "name": "deploy_tasks_leased_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('running', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_queued_created_idx": { + "name": "deploy_tasks_queued_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_terminal_completed_idx": { + "name": "deploy_tasks_terminal_completed_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('completed', 'failed', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_runtime_paused_idx": { + "name": "deploy_tasks_runtime_paused_idx", + "columns": [ + { + "expression": "runtime_paused_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"runtime_provider\" = 'devbox' AND lower(coalesce(\"sealai_deployment\".\"deploy_tasks\".\"runtime_state\", '')) IN ('paused', 'archived')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_one_active_clone_idx": { + "name": "deploy_tasks_one_active_clone_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retried_from_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"retried_from_task_id\" IS NOT NULL AND \"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('queued', 'running', 'blocked', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_tasks_agent_turn_count_nonnegative": { + "name": "deploy_tasks_agent_turn_count_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_turn_count\" >= 0" + }, + "deploy_tasks_agent_input_revision_nonnegative": { + "name": "deploy_tasks_agent_input_revision_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_input_revision\" >= 0" + }, + "deploy_tasks_agent_protocol_valid": { + "name": "deploy_tasks_agent_protocol_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_protocol\" IN ('mcp-v1')" + }, + "deploy_tasks_agent_control_token_hash_valid": { + "name": "deploy_tasks_agent_control_token_hash_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NULL OR char_length(\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_onboarding.onboarding_profiles": { + "name": "onboarding_profiles", + "schema": "sealai_onboarding", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at_step": { + "name": "dismissed_at_step", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_type": { + "name": "role_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_other_text": { + "name": "role_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_context": { + "name": "usage_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_other_text": { + "name": "usage_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority_tags": { + "name": "priority_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_display_order": { + "name": "priority_display_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_other_text": { + "name": "priority_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_goal_text": { + "name": "open_goal_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.attribution_subjects": { + "name": "attribution_subjects", + "schema": "sealai_marketing", + "columns": { + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "attribution_subjects_pk": { + "name": "attribution_subjects_pk", + "columns": ["subject_type", "subject_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.lifecycle_events": { + "name": "lifecycle_events", + "schema": "sealai_marketing", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hashed_user_data": { + "name": "hashed_user_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lifecycle_events_action_transaction_idx": { + "name": "lifecycle_events_action_transaction_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_marketing\".\"lifecycle_events\".\"transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_messages": { + "name": "notification_messages", + "schema": "sealai_notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_messages_live_dedupe_key_idx": { + "name": "notification_messages_live_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_notification\".\"notification_messages\".\"released_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_namespace_created_at_idx": { + "name": "notification_messages_namespace_created_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_created_at_idx": { + "name": "notification_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_read_receipts": { + "name": "notification_read_receipts", + "schema": "sealai_notification", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_key": { + "name": "message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_read_receipts_message_id_idx": { + "name": "notification_read_receipts_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_read_receipts_message_id_notification_messages_id_fk": { + "name": "notification_read_receipts_message_id_notification_messages_id_fk", + "tableFrom": "notification_read_receipts", + "tableTo": "notification_messages", + "schemaTo": "sealai_notification", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_read_receipts_user_uid_namespace_message_key_pk": { + "name": "notification_read_receipts_user_uid_namespace_message_key_pk", + "columns": ["user_uid", "namespace", "message_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_canvas_layouts": { + "name": "project_canvas_layouts", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name_snapshot": { + "name": "project_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nodes": { + "name": "nodes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_canvas_layouts_updated_at_idx": { + "name": "project_canvas_layouts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_canvas_layouts_pk": { + "name": "project_canvas_layouts_pk", + "columns": ["namespace", "project_uid"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_operations": { + "name": "project_delete_operations", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_id": { + "name": "preview_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_operations_expires_at_idx": { + "name": "project_delete_operations_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_operations_pk": { + "name": "project_delete_operations_pk", + "columns": ["namespace", "project_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_previews": { + "name": "project_delete_previews", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_previews_expires_at_idx": { + "name": "project_delete_previews_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_delete_previews_target_idx": { + "name": "project_delete_previews_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_previews_pk": { + "name": "project_delete_previews_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_management_audit_events": { + "name": "project_management_audit_events", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_management_audit_events_target_idx": { + "name": "project_management_audit_events_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_management_audit_events_pk": { + "name": "project_management_audit_events_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_navigation_preferences": { + "name": "project_navigation_preferences", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_project_ids": { + "name": "pinned_project_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_navigation_preferences_updated_at_idx": { + "name": "project_navigation_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_navigation_preferences_pk": { + "name": "project_navigation_preferences_pk", + "columns": ["namespace"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.projects": { + "name": "projects", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_namespace_lower_display_name_idx": { + "name": "projects_namespace_lower_display_name_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"display_name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_updated_at_idx": { + "name": "projects_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "projects_pk": { + "name": "projects_pk", + "columns": ["namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "sealai_assistant": "sealai_assistant", + "sealai_deployment": "sealai_deployment", + "sealai_onboarding": "sealai_onboarding", + "sealai_marketing": "sealai_marketing", + "sealai_notification": "sealai_notification", + "sealai_project": "sealai_project" + }, + "sequences": { + "sealai_deployment.deploy_task_events_seq": { + "name": "deploy_task_events_seq", + "schema": "sealai_deployment", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/ui/drizzle/meta/_journal.json b/apps/ui/drizzle/meta/_journal.json index 1b7042ff..3a8bbdb8 100644 --- a/apps/ui/drizzle/meta/_journal.json +++ b/apps/ui/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1786602557421, "tag": "0014_marketing_lifecycle_attribution", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787839794520, + "tag": "0015_notification_center", + "breakpoints": true } ] } diff --git a/apps/ui/scripts/reset-local-db.mjs b/apps/ui/scripts/reset-local-db.mjs index 9acd7148..d80fff37 100644 --- a/apps/ui/scripts/reset-local-db.mjs +++ b/apps/ui/scripts/reset-local-db.mjs @@ -28,6 +28,8 @@ try { DROP SCHEMA IF EXISTS sealai_assistant, sealai_deployment, + sealai_marketing, + sealai_notification, sealai_onboarding, sealai_project, drizzle diff --git a/apps/ui/src/app/api/chat/route.ts b/apps/ui/src/app/api/chat/route.ts index c813f0a5..144fb820 100644 --- a/apps/ui/src/app/api/chat/route.ts +++ b/apps/ui/src/app/api/chat/route.ts @@ -69,6 +69,7 @@ import { import { withSelectedResourceContext } from "@/features/chat/runtime/selected-resource-context"; import { buildChatToolset } from "@/features/chat/runtime/tools"; import { withWorkspaceResourceContext } from "@/features/chat/runtime/workspace-resource-context"; +import { observeWorkspaceQuotaQuietly } from "@/features/notifications/producers"; import { appTokenFromRequest } from "@/lib/app-token"; import { IdentityBindingSupersededError } from "@/lib/identity-fingerprint-core"; import { decodeKubeconfig } from "@/lib/kubeconfig"; @@ -929,6 +930,14 @@ export async function POST(req: Request) { } const actor: VerifiedAssistantConversationActor = verifiedPersonalResourceActor(authorization); + // The chat turn is a natural observation point for the quota-exhausted + // producer: the snapshot already crossed the server boundary here. + if (workspaceResourceQuota.success) { + observeWorkspaceQuotaQuietly({ + namespace: authorization.namespace, + snapshot: workspaceResourceQuota.data, + }); + } const kubeconfig = decodeKubeconfig(parsed.data.encodedKubeconfig); if (kubeconfig == null) { return jsonError( diff --git a/apps/ui/src/app/api/notifications/quota-observation/route.ts b/apps/ui/src/app/api/notifications/quota-observation/route.ts new file mode 100644 index 00000000..6b134d7f --- /dev/null +++ b/apps/ui/src/app/api/notifications/quota-observation/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.observeQuota; diff --git a/apps/ui/src/app/api/notifications/read/route.ts b/apps/ui/src/app/api/notifications/read/route.ts new file mode 100644 index 00000000..15d9ba34 --- /dev/null +++ b/apps/ui/src/app/api/notifications/read/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.markRead; diff --git a/apps/ui/src/app/api/notifications/route.ts b/apps/ui/src/app/api/notifications/route.ts new file mode 100644 index 00000000..c92f2f7d --- /dev/null +++ b/apps/ui/src/app/api/notifications/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const GET = notificationRouteHandlers.feed; diff --git a/apps/ui/src/features/billing/billing-plan-data.test.ts b/apps/ui/src/features/billing/billing-plan-data.test.ts index a5fcfdb3..aef0ac12 100644 --- a/apps/ui/src/features/billing/billing-plan-data.test.ts +++ b/apps/ui/src/features/billing/billing-plan-data.test.ts @@ -1172,6 +1172,7 @@ test("loads the sidebar subscription summary with only region-addressed reads", isPayg: false, lifecycle: "active", planName: "Pro", + role: "OWNER", }); assert.deepEqual( requests.map((request) => request.url), @@ -1195,6 +1196,7 @@ test("the sidebar summary reports an Active Free Trial and its period end", asyn isPayg: false, lifecycle: "active", planName: "Free", + role: "OWNER", }); }); @@ -1218,5 +1220,6 @@ test("the sidebar summary presents a deleted subscription as PAYG", async () => isPayg: true, lifecycle: "active", planName: "PAYG", + role: "OWNER", }); }); diff --git a/apps/ui/src/features/billing/billing-plan-data.ts b/apps/ui/src/features/billing/billing-plan-data.ts index 10dca8c0..c1bcc2f2 100644 --- a/apps/ui/src/features/billing/billing-plan-data.ts +++ b/apps/ui/src/features/billing/billing-plan-data.ts @@ -717,12 +717,17 @@ export async function loadBillingPlanSnapshot( * a two-request read (region, then the region-addressed subscription route) * instead of the Plan view's full snapshot. */ +/** The caller's membership role in the workspace as the subscription record names it. */ +export type WorkspaceSubscriptionRole = "DEVELOPER" | "MANAGER" | "OWNER"; + export interface WorkspaceSubscriptionSummary { currentPeriodEndAt: string; isActiveFreeTrial: boolean; isPayg: boolean; lifecycle: SubscriptionLifecycle; planName: string; + /** Null when the record names no role (PAYG workspaces). */ + role: WorkspaceSubscriptionRole | null; } export async function loadWorkspaceSubscriptionSummary( @@ -768,6 +773,7 @@ export async function loadWorkspaceSubscriptionSummary( status: subscription.Status, }), planName: subscription.PlanName, + role: subscription.role ?? null, }; } diff --git a/apps/ui/src/features/notifications/client.ts b/apps/ui/src/features/notifications/client.ts new file mode 100644 index 00000000..3097aa12 --- /dev/null +++ b/apps/ui/src/features/notifications/client.ts @@ -0,0 +1,84 @@ +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; +import { personalResourceAuthHeaders } from "@/lib/personal-resource-headers"; + +import { + type NotificationFeedResponse, + notificationFeedResponseSchema, +} from "./types"; + +/** Credentials every Brain-side notification request carries (ADR-0059). */ +export interface NotificationClientCredentials { + appToken: string; + kubeconfig: string; + namespace: string; +} + +function notificationsUrl(pathname: string, namespace: string): string { + const search = new URLSearchParams({ namespace }); + return `/api/notifications${pathname}?${search.toString()}`; +} + +/** The `db:` stream and the user's receipts for the current workspace. */ +export async function fetchNotificationFeed( + credentials: NotificationClientCredentials, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + const response = await fetchImpl( + notificationsUrl("", credentials.namespace), + { headers: personalResourceAuthHeaders(credentials) } + ); + if (!response.ok) { + throw new Error(`Notification feed request failed (${response.status})`); + } + return notificationFeedResponseSchema.parse(await response.json()); +} + +/** Records per-user receipts for the given source-prefixed ids. */ +export async function postNotificationReadReceipts( + credentials: NotificationClientCredentials, + ids: readonly string[], + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + if (ids.length === 0) { + return; + } + const response = await fetchImpl( + notificationsUrl("/read", credentials.namespace), + { + body: JSON.stringify({ ids }), + headers: { + ...personalResourceAuthHeaders(credentials), + "content-type": "application/json", + }, + method: "POST", + } + ); + if (!response.ok) { + throw new Error(`Mark-read request failed (${response.status})`); + } +} + +/** + * Reports a workspace quota snapshot so the quota-exhausted producer can + * observe it. Best-effort: the sidebar's quota read must never fail on it. + */ +export async function reportWorkspaceQuotaObservation( + credentials: NotificationClientCredentials, + snapshot: WorkspaceResourceQuotaSnapshot, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + const response = await fetchImpl( + notificationsUrl("/quota-observation", credentials.namespace), + { + body: JSON.stringify({ quota: snapshot }), + headers: { + ...personalResourceAuthHeaders(credentials), + "content-type": "application/json", + }, + method: "POST", + } + ); + if (!response.ok) { + throw new Error(`Quota observation request failed (${response.status})`); + } +} diff --git a/apps/ui/src/features/notifications/db-types.ts b/apps/ui/src/features/notifications/db-types.ts new file mode 100644 index 00000000..03f12dc6 --- /dev/null +++ b/apps/ui/src/features/notifications/db-types.ts @@ -0,0 +1,23 @@ +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; + +import type { identityFingerprints } from "@/features/chat/persistence/schema"; + +import type { notificationMessages, notificationReadReceipts } from "./schema"; + +// biome-ignore lint/style/useConsistentTypeDefinitions: interfaces lack the implicit index signature PgDatabase's schema generic requires +export type NotificationDbSchema = { + identityFingerprints: typeof identityFingerprints; + notificationMessages: typeof notificationMessages; + notificationReadReceipts: typeof notificationReadReceipts; +}; + +/** + * Driver-agnostic database type shared by production (node-postgres) and the + * PGlite tests; the store constrains itself to the query-builder surface + * every drizzle pg driver provides. `identityFingerprints` rides along + * because receipt writes re-check the binding in-transaction (ADR-0059). + */ +export type NotificationPgDatabase = PgDatabase< + PgQueryResultHKT, + NotificationDbSchema +>; diff --git a/apps/ui/src/features/notifications/db.ts b/apps/ui/src/features/notifications/db.ts new file mode 100644 index 00000000..bde1376b --- /dev/null +++ b/apps/ui/src/features/notifications/db.ts @@ -0,0 +1,28 @@ +import "server-only"; + +import { drizzle } from "drizzle-orm/node-postgres"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; +import { getAppPostgresPool } from "@/lib/app-postgres/db"; + +import type { NotificationDbSchema, NotificationPgDatabase } from "./db-types"; +import { notificationMessages, notificationReadReceipts } from "./schema"; + +const notificationSchema: NotificationDbSchema = { + identityFingerprints, + notificationMessages, + notificationReadReceipts, +}; + +let notificationDbInstance: NotificationPgDatabase | undefined; + +/** + * Lazily creates the Drizzle client on first use so `next build` does not need + * `DATABASE_URL` (static analysis / route collection must not open the pool). + */ +export function getNotificationDb(): NotificationPgDatabase { + notificationDbInstance ??= drizzle(getAppPostgresPool(), { + schema: notificationSchema, + }) as NotificationPgDatabase; + return notificationDbInstance; +} diff --git a/apps/ui/src/features/notifications/feed-model.test.ts b/apps/ui/src/features/notifications/feed-model.test.ts new file mode 100644 index 00000000..11bdedab --- /dev/null +++ b/apps/ui/src/features/notifications/feed-model.test.ts @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { + crNotificationId, + mergeNotificationFeed, + notificationKindForCR, + notificationSource, + renderNotificationMessage, +} from "./feed-model"; +import type { NotificationMessage } from "./types"; + +const T0 = 1_756_200_000; // Unix seconds +const QUOTA_BODY_RE = /New deployments can't start/; + +function cr(overrides: Partial): NotificationCRItem { + return { + desktopPopup: true, + from: "Debt-System", + importance: "High", + isRead: false, + message: "Your account balance is exhausted; services are suspended.", + name: "debt-choice-debtperiod", + namespace: "ns-a", + timestamp: T0, + title: "Balance exhausted", + ...overrides, + }; +} + +function dbMessage( + overrides: Partial +): NotificationMessage { + return { + createdAt: (T0 + 600) * 1000, + id: "m1", + kind: "quota-exhausted", + payload: { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + }, + projectUid: null, + ...overrides, + }; +} + +test("both streams merge into one list, newest first, with source-prefixed ids", () => { + const feed = mergeNotificationFeed({ + crItems: [ + cr({}), + cr({ + name: "workspace-debt-debt", + from: "Workspace-Subscription-System", + timestamp: T0 + 1200, + }), + ], + dbMessages: [dbMessage({})], + receipts: [], + }); + + assert.deepEqual( + feed.map((item) => item.id), + [ + `cr:workspace-debt-debt:${T0 + 1200}`, + "db:m1", + `cr:debt-choice-debtperiod:${T0}`, + ] + ); + assert.deepEqual( + feed.map((item) => item.timestamp), + [(T0 + 1200) * 1000, (T0 + 600) * 1000, T0 * 1000] + ); + assert.deepEqual( + feed.map((item) => item.source), + ["cr", "db", "cr"] + ); +}); + +test("platform copy is shown as-is and kinds follow the upstream sender", () => { + const [item] = mergeNotificationFeed({ + crItems: [cr({})], + dbMessages: [], + receipts: [], + }); + assert.equal(item?.title, "Balance exhausted"); + assert.equal( + item?.body, + "Your account balance is exhausted; services are suspended." + ); + assert.equal(item?.kind, "billing"); + assert.equal(item?.crName, "debt-choice-debtperiod"); + + assert.equal( + notificationKindForCR({ from: "Debt-System", name: "x" }), + "billing" + ); + assert.equal( + notificationKindForCR({ name: "workspace-debt-debt-pre-deletion" }), + "billing" + ); + assert.equal( + notificationKindForCR({ from: "Admin", name: "release-notes" }), + "announcement" + ); +}); + +test("Brain entries render from their payload", () => { + const rendered = renderNotificationMessage(dbMessage({})); + assert.equal(rendered.kind, "quota"); + assert.equal(rendered.title, "Storage quota is full"); + assert.match(rendered.body, QUOTA_BODY_RE); + assert.equal( + renderNotificationMessage( + dbMessage({ + payload: { + kind: "quota-exhausted", + limit: 4, + resource: "cpu", + used: 4, + }, + }) + ).title, + "CPU quota is full" + ); +}); + +test("unread for platform messages is label unread AND no receipt", () => { + const unreadNoReceipt = cr({ isRead: false }); + const readByLabel = cr({ isRead: true, name: "workspace-debt-debt" }); + const unreadWithReceipt = cr({ + isRead: false, + name: "announce", + from: "Admin", + }); + + const feed = mergeNotificationFeed({ + crItems: [unreadNoReceipt, readByLabel, unreadWithReceipt], + dbMessages: [], + receipts: [crNotificationId("announce", T0)], + }); + const byName = new Map(feed.map((item) => [item.crName, item.unread])); + + assert.equal(byName.get("debt-choice-debtperiod"), true); + assert.equal( + byName.get("workspace-debt-debt"), + false, + "upstream auto-read wins" + ); + assert.equal(byName.get("announce"), false, "a Brain receipt wins"); +}); + +test("a revived fixed-name CR is a new id no old receipt covers", () => { + const oldId = crNotificationId("debt-choice-debtperiod", T0); + const [revived] = mergeNotificationFeed({ + crItems: [cr({ timestamp: T0 + 86_400 })], + dbMessages: [], + receipts: [oldId], + }); + + assert.notEqual(revived?.id, oldId); + assert.equal(revived?.unread, true); +}); + +test("Brain entries are unread until a receipt exists", () => { + const unread = mergeNotificationFeed({ + crItems: [], + dbMessages: [dbMessage({})], + receipts: [], + }); + const read = mergeNotificationFeed({ + crItems: [], + dbMessages: [dbMessage({})], + receipts: ["db:m1"], + }); + assert.equal(unread[0]?.unread, true); + assert.equal(read[0]?.unread, false); +}); + +test("ids carry their source", () => { + assert.equal(notificationSource("cr:x:1"), "cr"); + assert.equal(notificationSource("db:m1"), "db"); + assert.equal(notificationSource("n1"), null); +}); diff --git a/apps/ui/src/features/notifications/feed-model.ts b/apps/ui/src/features/notifications/feed-model.ts new file mode 100644 index 00000000..d8c33178 --- /dev/null +++ b/apps/ui/src/features/notifications/feed-model.ts @@ -0,0 +1,163 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import type { + AppNotification, + AppNotificationKind, + AppNotificationSource, +} from "@/features/shell/app-sidebar-notifications-model"; + +import { dbNotificationId } from "./notification-ids"; +import type { + NotificationMessage, + NotificationPayload, + QuotaExhaustedResource, +} from "./types"; + +/** + * The merged-feed seam: platform CRs (`cr:`) and Brain-produced entries + * (`db:`) become one Notification list sorted by real time, with unread + * computed from each source's own state and the user's receipts. Pure, so + * fixture CRs and rows pin contents, order, and unread here. + */ + +/** + * A platform notification's id is versioned by the CR's own timestamp: the + * platform overwrites fixed-name CRs in place on escalation, and the newer + * timestamp makes the revived message a new id that no old receipt covers. + */ +export function crNotificationId(name: string, timestamp: number): string { + return `cr:${name}:${timestamp}`; +} + +export function notificationSource(id: string): AppNotificationSource | null { + if (id.startsWith("cr:")) { + return "cr"; + } + if (id.startsWith("db:")) { + return "db"; + } + return null; +} + +/** Upstream `spec.from` values written by the platform's billing controllers. */ +const BILLING_SENDERS: ReadonlySet = new Set([ + "Debt-System", + "Workspace-Subscription-System", +]); + +export function notificationKindForCR( + item: Pick +): AppNotificationKind { + if ( + (item.from != null && BILLING_SENDERS.has(item.from)) || + item.name.startsWith("debt-choice-") || + item.name.startsWith("workspace-debt-") + ) { + return "billing"; + } + return "announcement"; +} + +const QUOTA_RESOURCE_LABELS: Record = { + cpu: "CPU", + memory: "Memory", + nodeport: "Port", + pod: "Pod", + storage: "Storage", +}; + +interface RenderedNotification { + body: string; + kind: AppNotificationKind; + title: string; +} + +/** + * Brain-produced entries render from their payload here, client-side; the + * store holds parameters, never strings. Keyed by kind so the compiler + * demands a renderer for every producer that lands. Copy direction from the + * catalog (A1): name the exhausted resource and what it blocks. + */ +const RENDERERS: { + [K in NotificationPayload["kind"]]: ( + payload: Extract + ) => RenderedNotification; +} = { + "quota-exhausted": (payload) => { + const label = QUOTA_RESOURCE_LABELS[payload.resource]; + return { + body: `New deployments can't start until you free up ${label.toLowerCase()} or raise the limit.`, + kind: "quota", + title: `${label} quota is full`, + }; + }, +}; + +export function renderNotificationMessage( + message: NotificationMessage +): RenderedNotification { + return RENDERERS[message.payload.kind](message.payload); +} + +export interface MergeNotificationFeedInput { + crItems: readonly NotificationCRItem[]; + dbMessages: readonly NotificationMessage[]; + /** Source-prefixed ids the user has read (server receipts). */ + receipts: Iterable; +} + +export function platformNotification( + item: NotificationCRItem, + receipts: ReadonlySet +): AppNotification { + const id = crNotificationId(item.name, item.timestamp); + return { + body: item.message, + crName: item.name, + id, + kind: notificationKindForCR(item), + source: "cr", + timestamp: item.timestamp * 1000, + title: item.title, + // A platform message is unread iff the label says unread AND the user + // holds no receipt; upstream auto-read on recovery stacks on top. + unread: !(item.isRead || receipts.has(id)), + }; +} + +export function brainNotification( + message: NotificationMessage, + receipts: ReadonlySet +): AppNotification { + const id = dbNotificationId(message.id); + const rendered = renderNotificationMessage(message); + return { + body: rendered.body, + id, + kind: rendered.kind, + source: "db", + timestamp: message.createdAt, + title: rendered.title, + unread: !receipts.has(id), + }; +} + +function compareIds(a: string, b: string): number { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; +} + +/** One list, both streams, newest first; ids break ties so order is stable. */ +export function mergeNotificationFeed( + input: MergeNotificationFeedInput +): AppNotification[] { + const receipts = new Set(input.receipts); + const items = [ + ...input.crItems.map((item) => platformNotification(item, receipts)), + ...input.dbMessages.map((message) => brainNotification(message, receipts)), + ]; + items.sort((a, b) => b.timestamp - a.timestamp || compareIds(a.id, b.id)); + return items; +} diff --git a/apps/ui/src/features/notifications/http-handlers.test.ts b/apps/ui/src/features/notifications/http-handlers.test.ts new file mode 100644 index 00000000..79c29662 --- /dev/null +++ b/apps/ui/src/features/notifications/http-handlers.test.ts @@ -0,0 +1,217 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { SignJWT } from "jose"; + +import { + assistantChatMessages, + assistantChats, + assistantDevboxRuntimes, + assistantEntitlements, + githubAppInstallSessions, + githubConnections, + githubOauthConnections, + identityFingerprints, +} from "@/features/chat/persistence/schema"; +import { createIdentityFingerprintStore } from "@/lib/identity-fingerprint-core"; + +import { createNotificationHandlers } from "./http-handlers"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; +import { notificationFeedResponseSchema } from "./types"; + +const pglite = new PGlite(); +// The fingerprint store wants the assistant schema; the notification store +// only its own three tables — one PGlite handle with both keeps them in the +// same database, as production does. +const db = drizzle(pglite, { + schema: { + assistantChatMessages, + assistantChats, + assistantDevboxRuntimes, + assistantEntitlements, + githubAppInstallSessions, + githubConnections, + githubOauthConnections, + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); + +const store = createNotificationStore(() => db); +const observeFingerprint = createIdentityFingerprintStore(() => db); + +afterAll(() => pglite.close()); + +const APP_TOKEN_SECRET = "cluster-shared-jwt-internal"; +const APP_TOKEN_CONFIG = { secret: APP_TOKEN_SECRET }; + +function mintAppToken(crName: string) { + return new SignJWT({ userCrName: crName, userUid: `${crName}-uid` }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(1_753_600_000) + .sign(new TextEncoder().encode(APP_TOKEN_SECRET)); +} + +function jwt(subject: string): string { + const header = Buffer.from( + JSON.stringify({ alg: "RS256", typ: "JWT" }) + ).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ sub: subject })).toString( + "base64url" + ); + return `${header}.${payload}.test-signature`; +} + +function encodedKubeconfig(namespace: string, workspaceActor: string): string { + return encodeURIComponent(` +apiVersion: v1 +clusters: + - name: cluster + cluster: + server: https://example.test +contexts: + - name: current + context: + cluster: cluster + namespace: ${namespace} + user: active-user +current-context: current +users: + - name: active-user + user: + token: ${jwt(`system:serviceaccount:user-system:${workspaceActor}`)} +`); +} + +async function request( + pathname: string, + options: { + actor: string; + appToken?: string | null; + body?: unknown; + namespace?: string; + } +): Promise { + const namespace = options.namespace ?? "ns-a"; + return new Request( + `https://brain.test/api/notifications${pathname}?namespace=${namespace}`, + { + body: options.body == null ? undefined : JSON.stringify(options.body), + headers: { + Authorization: `Bearer ${encodedKubeconfig(namespace, options.actor)}`, + "content-type": "application/json", + ...(options.appToken === null + ? {} + : { + "X-Sealos-App-Token": + options.appToken ?? (await mintAppToken(options.actor)), + }), + }, + method: options.body == null ? "GET" : "POST", + } + ); +} + +const handlers = createNotificationHandlers({ + appTokenConfig: APP_TOKEN_CONFIG, + observeFingerprint, + store, + verify: () => Promise.resolve({ ok: true }), +}); + +test("a quota observation crossing 100% writes one entry, and the feed lists it unread", async () => { + const full = { + quota: { + items: [ + { limit: 20_480, type: "storage", used: 20_480 }, + { limit: 4000, type: "cpu", used: 1000 }, + ], + }, + }; + + const first = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: full }) + ); + const retry = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: full }) + ); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { produced: ["storage"], released: [] }); + assert.deepEqual(await retry.json(), { produced: [], released: [] }); + + const feed = await handlers.feed(await request("", { actor: "alice" })); + assert.equal(feed.status, 200); + const parsed = notificationFeedResponseSchema.parse(await feed.json()); + assert.equal(parsed.messages.length, 1); + assert.equal(parsed.messages[0]?.payload.resource, "storage"); + assert.deepEqual(parsed.receipts, []); +}); + +test("marking read writes a receipt the feed returns; other users keep theirs empty", async () => { + const feed = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "alice" }))).json() + ); + const dbId = `db:${feed.messages[0]?.id}`; + + const marked = await handlers.markRead( + await request("/read", { + actor: "alice", + body: { ids: [dbId, "cr:debt-choice-debtperiod:1756200000"] }, + }) + ); + assert.equal(marked.status, 200); + + const alice = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "alice" }))).json() + ); + assert.deepEqual( + [...alice.receipts].sort(), + [dbId, "cr:debt-choice-debtperiod:1756200000"].sort() + ); + + const bob = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "bob" }))).json() + ); + assert.equal(bob.messages.length, 1, "the workspace stream is shared"); + assert.deepEqual(bob.receipts, [], "read state is personal"); +}); + +test("the feed is scoped to the verified workspace", async () => { + const other = notificationFeedResponseSchema.parse( + await ( + await handlers.feed( + await request("", { actor: "alice", namespace: "ns-b" }) + ) + ).json() + ); + assert.deepEqual(other.messages, []); +}); + +test("requests without an App Token are refused; malformed bodies answer 400", async () => { + const noToken = await handlers.feed( + await request("", { actor: "alice", appToken: null }) + ); + assert.equal(noToken.status, 401); + + const badRead = await handlers.markRead( + await request("/read", { actor: "alice", body: { ids: ["n1"] } }) + ); + assert.equal(badRead.status, 400); + + const badQuota = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: { quota: {} } }) + ); + assert.equal(badQuota.status, 400); +}); diff --git a/apps/ui/src/features/notifications/http-handlers.ts b/apps/ui/src/features/notifications/http-handlers.ts new file mode 100644 index 00000000..2b058e0e --- /dev/null +++ b/apps/ui/src/features/notifications/http-handlers.ts @@ -0,0 +1,151 @@ +import type { AppTokenVerificationConfig } from "@/lib/app-token"; +import type { ObserveIdentityFingerprint } from "@/lib/identity-fingerprint-core"; +import { + authorizePersonalResourceRequest, + jsonError, + supersededBindingResponse, +} from "@/lib/personal-resource-http"; +import type { VerifyKubeconfigNamespace } from "@/lib/request-kubeconfig-auth"; +import { + type VerifiedPersonalResourceActor, + verifiedPersonalResourceActor, +} from "@/lib/verified-personal-actor"; + +import { observeWorkspaceQuotaForNotifications } from "./producer-quota-exhausted"; +import type { NotificationReader, NotificationStore } from "./store"; +import { + markNotificationReadRequestSchema, + type NotificationFeedResponse, + quotaObservationRequestSchema, +} from "./types"; + +export interface NotificationHandlerDependencies { + /** Test seam; defaults to `JWT_INTERNAL` from the env. */ + appTokenConfig?: AppTokenVerificationConfig | null; + /** Test seam; defaults to the region-local Identity Fingerprint store. */ + observeFingerprint?: ObserveIdentityFingerprint; + store: NotificationStore; + verify?: VerifyKubeconfigNamespace; +} + +function readerOf(actor: VerifiedPersonalResourceActor): NotificationReader { + return { + legacyWorkspaceActor: actor.legacyWorkspaceActor, + namespace: actor.owner.namespace, + userUid: actor.owner.userUid, + }; +} + +function unavailable(route: string, message: string): Response { + console.error(`[api/notifications/${route}] persistence unavailable`); + return jsonError({ + code: "notifications_unavailable", + message, + status: 503, + }); +} + +/** + * The Notification Center's Brain-side HTTP surface (ADR-0067): the `db:` + * stream plus the user's receipts, the per-user mark-read write, and the + * quota observation point. Every handler travels the verified-personal-actor + * choke point — receipts are uid-keyed rows (ADR-0059) and the stream is + * namespace-scoped, so both the workspace and the user must be verified. + */ +export function createNotificationHandlers( + dependencies: NotificationHandlerDependencies +) { + const authorize = async ( + request: Request + ): Promise< + | { ok: true; actor: VerifiedPersonalResourceActor } + | { ok: false; response: Response } + > => { + const result = await authorizePersonalResourceRequest(request, { + appTokenConfig: dependencies.appTokenConfig, + observeFingerprint: dependencies.observeFingerprint, + verify: dependencies.verify, + }); + return result.ok + ? { actor: verifiedPersonalResourceActor(result.authorization), ok: true } + : result; + }; + + return { + feed: async (request: Request): Promise => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization.response; + } + const reader = readerOf(authorization.actor); + try { + const [messages, receipts] = await Promise.all([ + dependencies.store.listMessages(reader.namespace), + dependencies.store.listReceipts(reader), + ]); + const body: NotificationFeedResponse = { messages, receipts }; + return Response.json(body); + } catch { + return unavailable("feed", "Notifications are unavailable."); + } + }, + markRead: async (request: Request): Promise => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization.response; + } + const body = await request.json().catch(() => null); + const parsed = markNotificationReadRequestSchema.safeParse(body); + if (!parsed.success) { + return jsonError({ + code: "invalid_request", + message: "Invalid mark-read request.", + status: 400, + }); + } + try { + await dependencies.store.markRead( + readerOf(authorization.actor), + parsed.data.ids + ); + return Response.json({ read: parsed.data.ids }); + } catch (error) { + const superseded = supersededBindingResponse(error); + if (superseded != null) { + return superseded; + } + return unavailable("read", "Could not record the read receipt."); + } + }, + observeQuota: async (request: Request): Promise => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization.response; + } + const body = await request.json().catch(() => null); + const parsed = quotaObservationRequestSchema.safeParse(body); + if (!parsed.success) { + return jsonError({ + code: "invalid_request", + message: "Invalid quota observation.", + status: 400, + }); + } + try { + const result = await observeWorkspaceQuotaForNotifications( + dependencies.store, + { + namespace: authorization.actor.owner.namespace, + snapshot: parsed.data.quota, + } + ); + return Response.json(result); + } catch { + return unavailable( + "quota-observation", + "Could not record the quota observation." + ); + } + }, + }; +} diff --git a/apps/ui/src/features/notifications/notification-ids.ts b/apps/ui/src/features/notifications/notification-ids.ts new file mode 100644 index 00000000..5ebb0c36 --- /dev/null +++ b/apps/ui/src/features/notifications/notification-ids.ts @@ -0,0 +1,6 @@ +/** The `db:` prefix every Brain-produced notification id carries. */ +export const DB_NOTIFICATION_ID_PREFIX = "db:"; + +export function dbNotificationId(messageId: string): string { + return `${DB_NOTIFICATION_ID_PREFIX}${messageId}`; +} diff --git a/apps/ui/src/features/notifications/notification-time.test.ts b/apps/ui/src/features/notifications/notification-time.test.ts new file mode 100644 index 00000000..a873649e --- /dev/null +++ b/apps/ui/src/features/notifications/notification-time.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { formatNotificationTime } from "./notification-time"; + +const NOW = Date.UTC(2026, 7, 27, 12, 0, 0); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const SHORT_DATE_RE = /^[A-Z][a-z]{2} \d{1,2}$/; + +test("relative time steps from just now through days", () => { + assert.equal(formatNotificationTime(NOW - 10_000, NOW), "Just now"); + assert.equal(formatNotificationTime(NOW - 2 * MINUTE, NOW), "2m ago"); + assert.equal(formatNotificationTime(NOW - 26 * MINUTE, NOW), "26m ago"); + assert.equal(formatNotificationTime(NOW - HOUR, NOW), "1h ago"); + assert.equal(formatNotificationTime(NOW - 23 * HOUR, NOW), "23h ago"); + assert.equal(formatNotificationTime(NOW - 30 * HOUR, NOW), "Yesterday"); + assert.equal(formatNotificationTime(NOW - 2 * DAY, NOW), "2d ago"); + assert.equal(formatNotificationTime(NOW - 6 * DAY, NOW), "6d ago"); +}); + +test("older than a week falls back to a short date; the future reads as now", () => { + assert.match(formatNotificationTime(NOW - 30 * DAY, NOW), SHORT_DATE_RE); + assert.equal(formatNotificationTime(NOW + HOUR, NOW), "Just now"); + assert.equal(formatNotificationTime(Number.NaN, NOW), ""); +}); diff --git a/apps/ui/src/features/notifications/notification-time.ts b/apps/ui/src/features/notifications/notification-time.ts new file mode 100644 index 00000000..dbc1354d --- /dev/null +++ b/apps/ui/src/features/notifications/notification-time.ts @@ -0,0 +1,36 @@ +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** + * Relative time for a Notification row, from a real timestamp (epoch ms): + * "Just now", "26m ago", "3h ago", "Yesterday", "5d ago", then a short date. + */ +export function formatNotificationTime( + timestamp: number, + now: number = Date.now() +): string { + if (!Number.isFinite(timestamp)) { + return ""; + } + const elapsed = Math.max(0, now - timestamp); + if (elapsed < MINUTE_MS) { + return "Just now"; + } + if (elapsed < HOUR_MS) { + return `${Math.floor(elapsed / MINUTE_MS)}m ago`; + } + if (elapsed < DAY_MS) { + return `${Math.floor(elapsed / HOUR_MS)}h ago`; + } + if (elapsed < 2 * DAY_MS) { + return "Yesterday"; + } + if (elapsed < 7 * DAY_MS) { + return `${Math.floor(elapsed / DAY_MS)}d ago`; + } + return new Date(timestamp).toLocaleDateString("en-US", { + day: "numeric", + month: "short", + }); +} diff --git a/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts new file mode 100644 index 00000000..2518803e --- /dev/null +++ b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts @@ -0,0 +1,139 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import type { WorkspaceQuotaItem } from "@/features/billing/workspace-resource-quota"; +import { identityFingerprints } from "@/features/chat/persistence/schema"; + +import { + isQuotaExhausted, + observeWorkspaceQuotaForNotifications, + quotaExhaustedDedupeKey, +} from "./producer-quota-exhausted"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; + +const pglite = new PGlite(); +const db = drizzle(pglite, { + schema: { + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); + +function snapshot(...items: WorkspaceQuotaItem[]) { + return { items }; +} + +test("isQuotaExhausted fires at 100% and never on an unknown limit", () => { + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 20 }), + true + ); + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 25 }), + true + ); + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 19.9 }), + false + ); + assert.equal(isQuotaExhausted({ limit: 0, type: "pod", used: 0 }), false); +}); + +test("the dedupe key names workspace and resource", () => { + assert.equal( + quotaExhaustedDedupeKey("ns-a", "storage"), + "quota-exhausted:ns-a:storage" + ); +}); + +test("crossing 100% writes one entry per resource; a retry writes nothing", async () => { + const full = snapshot( + { limit: 4000, type: "cpu", used: 4000 }, + { limit: 20_480, type: "storage", used: 20_480 }, + { limit: 4096, type: "memory", used: 3072 } + ); + + const first = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: NOW, + snapshot: full, + }); + const retry = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 60_000), + snapshot: full, + }); + + assert.deepEqual(first, { produced: ["cpu", "storage"], released: [] }); + assert.deepEqual(retry, { produced: [], released: [] }); + const messages = await store.listMessages("ns-a"); + assert.equal(messages.length, 2, "exactly one entry per exhausted resource"); + assert.deepEqual(messages.map((message) => message.payload.resource).sort(), [ + "cpu", + "storage", + ]); + assert.deepEqual( + messages.find((message) => message.payload.resource === "storage")?.payload, + { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + } + ); +}); + +test("falling below 100% resets; staying full re-sends nothing; re-crossing writes again", async () => { + const recovered = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 120_000), + snapshot: snapshot( + { limit: 4000, type: "cpu", used: 4000 }, + { limit: 20_480, type: "storage", used: 10_240 } + ), + }); + assert.deepEqual(recovered, { produced: [], released: ["storage"] }); + + const stillFull = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 180_000), + snapshot: snapshot({ limit: 4000, type: "cpu", used: 4000 }), + }); + assert.deepEqual(stillFull, { produced: [], released: [] }); + + const reCrossed = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 240_000), + snapshot: snapshot({ limit: 20_480, type: "storage", used: 20_480 }), + }); + assert.deepEqual(reCrossed, { produced: ["storage"], released: [] }); + + const messages = await store.listMessages("ns-a"); + assert.equal(messages.length, 3, "cpu once, storage twice across a recovery"); +}); + +test("a blank namespace observes nothing", async () => { + const result = await observeWorkspaceQuotaForNotifications(store, { + namespace: " ", + snapshot: snapshot({ limit: 1, type: "pod", used: 1 }), + }); + assert.deepEqual(result, { produced: [], released: [] }); +}); diff --git a/apps/ui/src/features/notifications/producer-quota-exhausted.ts b/apps/ui/src/features/notifications/producer-quota-exhausted.ts new file mode 100644 index 00000000..1885d962 --- /dev/null +++ b/apps/ui/src/features/notifications/producer-quota-exhausted.ts @@ -0,0 +1,88 @@ +import type { + WorkspaceQuotaItem, + WorkspaceResourceQuotaSnapshot, +} from "@/features/billing/workspace-resource-quota"; + +import type { NotificationStore } from "./store"; +import type { QuotaExhaustedResource } from "./types"; + +/** + * The first Brain-side producer (catalog row A1): when any workspace resource + * quota reaches 100% during a user's request, exactly one "quota is full" + * entry appears per resource. Edge-triggered by naming — the dedupe key is + * live while the resource stays full, so retries and repeated observations + * write nothing; falling below 100% releases the key so the next crossing + * writes a fresh entry. No scheduler, no periodic re-sends. + */ + +export const QUOTA_EXHAUSTED_DEDUPE_PREFIX = "quota-exhausted"; + +export function quotaExhaustedDedupeKey( + namespace: string, + resource: QuotaExhaustedResource +): string { + return `${QUOTA_EXHAUSTED_DEDUPE_PREFIX}:${namespace}:${resource}`; +} + +/** 100% of the limit; a zero or unknown limit never counts as exhausted. */ +export function isQuotaExhausted(item: WorkspaceQuotaItem): boolean { + return ( + Number.isFinite(item.limit) && + Number.isFinite(item.used) && + item.limit > 0 && + item.used >= item.limit + ); +} + +export interface QuotaObservationResult { + produced: QuotaExhaustedResource[]; + released: QuotaExhaustedResource[]; +} + +export type QuotaObservationStore = Pick< + NotificationStore, + "produce" | "release" +>; + +/** + * Observes one quota snapshot for a workspace: exhausted resources produce + * (deduped), recovered resources release. Resources absent from the snapshot + * are unknown and left alone. + */ +export async function observeWorkspaceQuotaForNotifications( + store: QuotaObservationStore, + input: { + namespace: string; + now?: Date; + snapshot: WorkspaceResourceQuotaSnapshot; + } +): Promise { + const result: QuotaObservationResult = { produced: [], released: [] }; + const namespace = input.namespace.trim(); + if (namespace === "") { + return result; + } + for (const item of input.snapshot.items) { + const dedupeKey = quotaExhaustedDedupeKey(namespace, item.type); + if (isQuotaExhausted(item)) { + const produced = await store.produce({ + dedupeKey, + kind: "quota-exhausted", + namespace, + now: input.now, + payload: { + kind: "quota-exhausted", + limit: item.limit, + resource: item.type, + used: item.used, + }, + }); + if (produced) { + result.produced.push(item.type); + } + } else if (await store.release({ dedupeKey, now: input.now })) { + result.released.push(item.type); + } + } + return result; +} diff --git a/apps/ui/src/features/notifications/producers.ts b/apps/ui/src/features/notifications/producers.ts new file mode 100644 index 00000000..ca0ba500 --- /dev/null +++ b/apps/ui/src/features/notifications/producers.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import { observeWorkspaceQuotaForNotifications } from "./producer-quota-exhausted"; +import { notificationStore } from "./route-handlers"; + +/** + * Fire-and-forget quota observation for request paths that already carry a + * snapshot (the chat turn). The producer must never fail the user's request: + * a persistence hiccup is logged and the next observation retries by naming. + */ +export function observeWorkspaceQuotaQuietly(input: { + namespace: string; + snapshot: WorkspaceResourceQuotaSnapshot; +}): void { + observeWorkspaceQuotaForNotifications(notificationStore, input).catch( + (error: unknown) => { + console.error( + "[notifications] quota observation failed:", + error instanceof Error ? error.message : String(error) + ); + } + ); +} diff --git a/apps/ui/src/features/notifications/read-dispatch.test.ts b/apps/ui/src/features/notifications/read-dispatch.test.ts new file mode 100644 index 00000000..d2335c22 --- /dev/null +++ b/apps/ui/src/features/notifications/read-dispatch.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; + +import { planReadDispatch, shouldSyncCRReadLabel } from "./read-dispatch"; + +function item(overrides: Partial): AppNotification { + return { + id: "db:m1", + kind: "quota", + source: "db", + timestamp: 0, + title: "Storage quota is full", + unread: true, + ...overrides, + }; +} + +const MIXED = [ + item({}), + item({ + crName: "debt-choice-debtperiod", + id: "cr:debt-choice-debtperiod:1", + source: "cr", + }), + item({ + crName: "debt-choice-debtperiod", + id: "cr:debt-choice-debtperiod:2", + source: "cr", + }), +]; + +test("every id gets a receipt; Owners and Managers also patch the CRs once each", () => { + for (const role of ["OWNER", "MANAGER"] as const) { + const plan = planReadDispatch(MIXED, role); + assert.deepEqual(plan.receiptIds, [ + "db:m1", + "cr:debt-choice-debtperiod:1", + "cr:debt-choice-debtperiod:2", + ]); + assert.deepEqual(plan.crNames, ["debt-choice-debtperiod"]); + } +}); + +test("Developers skip the CR patch but still get the receipt", () => { + const plan = planReadDispatch(MIXED, "DEVELOPER"); + assert.equal(plan.receiptIds.length, 3); + assert.deepEqual(plan.crNames, []); + assert.equal(shouldSyncCRReadLabel("DEVELOPER"), false); +}); + +test("an unknown role tries the patch (the cluster decides)", () => { + assert.equal(shouldSyncCRReadLabel(null), true); + assert.equal(shouldSyncCRReadLabel(undefined), true); + assert.deepEqual(planReadDispatch(MIXED, null).crNames, [ + "debt-choice-debtperiod", + ]); +}); diff --git a/apps/ui/src/features/notifications/read-dispatch.ts b/apps/ui/src/features/notifications/read-dispatch.ts new file mode 100644 index 00000000..940fba39 --- /dev/null +++ b/apps/ui/src/features/notifications/read-dispatch.ts @@ -0,0 +1,39 @@ +import type { WorkspaceSubscriptionRole } from "@/features/billing/billing-plan-data"; +import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; + +/** + * Per-source mark-read dispatch. Any role always writes a Brain receipt for + * every id; platform items additionally patch the CR's `isRead` label so the + * desktop bell follows — but only for roles the cluster lets patch. Owners + * and Managers hold that permission, Developers do not, and an unknown role + * (PAYG workspaces carry none) tries and lets a 403 fall through silently. + */ +export interface ReadDispatch { + /** CR names to patch best-effort. */ + crNames: string[]; + /** Source-prefixed ids to record receipts for (always every id). */ + receiptIds: string[]; +} + +export function shouldSyncCRReadLabel( + role: WorkspaceSubscriptionRole | null | undefined +): boolean { + return role !== "DEVELOPER"; +} + +export function planReadDispatch( + items: readonly AppNotification[], + role: WorkspaceSubscriptionRole | null | undefined +): ReadDispatch { + const receiptIds = [...new Set(items.map((item) => item.id))]; + const crNames = shouldSyncCRReadLabel(role) + ? [ + ...new Set( + items.flatMap((item) => + item.source === "cr" && item.crName != null ? [item.crName] : [] + ) + ), + ] + : []; + return { crNames, receiptIds }; +} diff --git a/apps/ui/src/features/notifications/route-handlers.ts b/apps/ui/src/features/notifications/route-handlers.ts new file mode 100644 index 00000000..19528d62 --- /dev/null +++ b/apps/ui/src/features/notifications/route-handlers.ts @@ -0,0 +1,13 @@ +import "server-only"; + +import { getNotificationDb } from "./db"; +import { createNotificationHandlers } from "./http-handlers"; +import { createNotificationStore } from "./store"; + +/** The production store; the only place the database is injected. */ +export const notificationStore = createNotificationStore(getNotificationDb); + +/** Production wiring for the Notification Center routes (ADR-0067). */ +export const notificationRouteHandlers = createNotificationHandlers({ + store: notificationStore, +}); diff --git a/apps/ui/src/features/notifications/schema.ts b/apps/ui/src/features/notifications/schema.ts new file mode 100644 index 00000000..a00139cf --- /dev/null +++ b/apps/ui/src/features/notifications/schema.ts @@ -0,0 +1,97 @@ +import { sql } from "drizzle-orm"; +import { + index, + jsonb, + pgSchema, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +import type { NotificationMessageKind, NotificationPayload } from "./types"; + +/** + * Postgres schema owning the Notification Center's Brain-produced stream and + * the per-user read receipts (ADR-0067). Platform messages never land here: + * they stay in the cluster as Notification CRs and are read live. Isolated + * from `public` like every other app-owned schema. + */ +export const NOTIFICATION_DB_SCHEMA = "sealai_notification"; + +export const ns = pgSchema(NOTIFICATION_DB_SCHEMA); + +/** Brain-produced entries are kept this long; swept opportunistically on write. */ +export const NOTIFICATION_RETENTION_DAYS = 365; + +/** + * One Brain-produced Notification (`db:` stream). Rendering stays client-side: + * `payload` carries the structured parameters the display layer turns into + * copy, never final strings. `dedupeKey` is the producer's idempotency key — + * naming is dedupe, so a retried request re-inserts nothing. A producer + * "releases" the key (`releasedAt`) when the underlying state recovers so the + * next threshold crossing writes a fresh entry while history keeps the old + * one; the unique index therefore only spans live keys. + */ +export const notificationMessages = ns.table( + "notification_messages", + { + id: text("id").primaryKey(), + /** Workspace namespace; the inbox's aggregation boundary. */ + namespace: text("namespace").notNull(), + kind: text("kind").notNull().$type(), + /** Source Project when the message has one (deploy outcomes, later). */ + projectUid: text("project_uid"), + payload: jsonb("payload").notNull().$type(), + dedupeKey: text("dedupe_key").notNull(), + releasedAt: timestamp("released_at", { mode: "date", withTimezone: true }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("notification_messages_live_dedupe_key_idx") + .on(table.dedupeKey) + .where(sql`${table.releasedAt} IS NULL`), + index("notification_messages_namespace_created_at_idx").on( + table.namespace, + table.createdAt + ), + index("notification_messages_created_at_idx").on(table.createdAt), + ] +); + +/** + * One read receipt per user × workspace × message key. The key is the + * source-prefixed notification id: `db:` for Brain-produced + * entries (with `messageId` set so the 365-day sweep cascades) and + * `cr::` for platform CRs — versioned by the CR's own + * timestamp so an upstream revive (same fixed name, newer timestamp) reads as + * unread again. Any role writes receipts; the CR label is a separate, + * best-effort write. + */ +export const notificationReadReceipts = ns.table( + "notification_read_receipts", + { + /** Bare global user UID (ADR-0059). */ + userUid: text("user_uid").notNull(), + namespace: text("namespace").notNull(), + messageKey: text("message_key").notNull(), + messageId: text("message_id").references(() => notificationMessages.id, { + onDelete: "cascade", + }), + readAt: timestamp("read_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.userUid, table.namespace, table.messageKey], + }), + index("notification_read_receipts_message_id_idx").on(table.messageId), + ] +); + +export type NotificationMessageRow = typeof notificationMessages.$inferSelect; +export type NotificationReadReceiptRow = + typeof notificationReadReceipts.$inferSelect; diff --git a/apps/ui/src/features/notifications/store.test.ts b/apps/ui/src/features/notifications/store.test.ts new file mode 100644 index 00000000..5874436a --- /dev/null +++ b/apps/ui/src/features/notifications/store.test.ts @@ -0,0 +1,232 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; +import { dbNotificationId } from "./notification-ids"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore, type NotificationReader } from "./store"; + +const testSchema = { + identityFingerprints, + notificationMessages, + notificationReadReceipts, +}; + +const pglite = new PGlite(); +const db = drizzle(pglite, { schema: testSchema }); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); + +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); +const DAY_MS = 24 * 60 * 60 * 1000; +const SUPERSEDED_RE = /superseded/; + +async function bindIdentity(crName: string, userUid: string): Promise { + await db + .insert(identityFingerprints) + .values({ crName, mintedAt: 1, userUid }) + .onConflictDoNothing(); +} + +function reader( + overrides: Partial = {} +): NotificationReader { + return { + legacyWorkspaceActor: "alice", + namespace: "ns-a", + userUid: "alice-uid", + ...overrides, + }; +} + +function quotaPayload(resource: "cpu" | "memory" | "storage") { + return { kind: "quota-exhausted" as const, limit: 10, resource, used: 10 }; +} + +test("produce writes one entry per dedupe key and retries dedupe", async () => { + const first = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: NOW, + payload: quotaPayload("storage"), + }); + const retry = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: new Date(NOW.getTime() + 1000), + payload: quotaPayload("storage"), + }); + + assert.equal(first, true); + assert.equal(retry, false); + const messages = await store.listMessages("ns-a"); + assert.equal(messages.length, 1); + assert.equal(messages[0]?.kind, "quota-exhausted"); + assert.deepEqual(messages[0]?.payload, quotaPayload("storage")); + assert.equal(messages[0]?.createdAt, NOW.getTime()); + assert.equal(await store.isLive("quota-exhausted:ns-a:storage"), true); +}); + +test("release frees the dedupe key, keeps history, and lets the next crossing write again", async () => { + const released = await store.release({ + dedupeKey: "quota-exhausted:ns-a:storage", + now: new Date(NOW.getTime() + 2000), + }); + const releasedAgain = await store.release({ + dedupeKey: "quota-exhausted:ns-a:storage", + }); + const reproduced = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: new Date(NOW.getTime() + 3000), + payload: quotaPayload("storage"), + }); + + assert.equal(released, true); + assert.equal(releasedAgain, false, "a released key releases nothing twice"); + assert.equal(reproduced, true, "recovery resets the edge trigger"); + const messages = await store.listMessages("ns-a"); + assert.equal(messages.length, 2, "the released entry stays as history"); + assert.equal(messages[0]?.createdAt, NOW.getTime() + 3000, "newest first"); +}); + +test("messages are isolated per namespace", async () => { + await store.produce({ + dedupeKey: "quota-exhausted:ns-b:cpu", + kind: "quota-exhausted", + namespace: "ns-b", + now: NOW, + payload: quotaPayload("cpu"), + }); + + const a = await store.listMessages("ns-a"); + const b = await store.listMessages("ns-b"); + assert.equal(a.length, 2); + assert.equal(b.length, 1); + assert.equal(b[0]?.payload.resource, "cpu"); +}); + +test("receipts are per user and per namespace, and db receipts attach their row", async () => { + await bindIdentity("alice", "alice-uid"); + await bindIdentity("bob", "bob-uid"); + const [message] = await store.listMessages("ns-b"); + assert.ok(message); + const dbId = dbNotificationId(message.id); + const crId = "cr:debt-choice-debtperiod:1756200000"; + + await store.markRead(reader({ namespace: "ns-b" }), [dbId, crId, crId]); + await store.markRead(reader({ namespace: "ns-b" }), [dbId]); + + assert.deepEqual( + (await store.listReceipts(reader({ namespace: "ns-b" }))).sort(), + [crId, dbId].sort() + ); + assert.deepEqual( + await store.listReceipts( + reader({ + legacyWorkspaceActor: "bob", + namespace: "ns-b", + userUid: "bob-uid", + }) + ), + [], + "another user's inbox is untouched" + ); + assert.deepEqual( + await store.listReceipts(reader({ namespace: "ns-a" })), + [], + "the same user's other workspace is untouched" + ); + const [receipt] = await db + .select({ messageId: notificationReadReceipts.messageId }) + .from(notificationReadReceipts) + .where( + and( + eq(notificationReadReceipts.userUid, "alice-uid"), + eq(notificationReadReceipts.messageKey, dbId) + ) + ); + assert.equal(receipt?.messageId, message.id); +}); + +test("a db receipt for a foreign or unknown message is ignored, not refused", async () => { + const [foreign] = await store.listMessages("ns-a"); + assert.ok(foreign); + + await store.markRead(reader({ namespace: "ns-b" }), [ + dbNotificationId(foreign.id), + dbNotificationId("does-not-exist"), + ]); + + const receipts = await store.listReceipts(reader({ namespace: "ns-b" })); + assert.equal(receipts.includes(dbNotificationId(foreign.id)), false); + assert.equal(receipts.includes(dbNotificationId("does-not-exist")), false); +}); + +test("marking read refuses a superseded identity binding", async () => { + await assert.rejects( + store.markRead( + reader({ legacyWorkspaceActor: "alice", userUid: "someone-else-uid" }), + ["cr:x:1"] + ), + SUPERSEDED_RE + ); +}); + +test("writes sweep entries older than 365 days and their receipts cascade", async () => { + const old = new Date(NOW.getTime() - 400 * DAY_MS); + await store.produce({ + dedupeKey: "quota-exhausted:ns-c:memory", + kind: "quota-exhausted", + namespace: "ns-c", + now: old, + payload: quotaPayload("memory"), + }); + const [oldMessage] = await store.listMessages("ns-c"); + assert.ok(oldMessage); + await bindIdentity("carol", "carol-uid"); + const carol = reader({ + legacyWorkspaceActor: "carol", + namespace: "ns-c", + userUid: "carol-uid", + }); + await store.markRead(carol, [dbNotificationId(oldMessage.id), "cr:keep:1"]); + + // Any later write sweeps: here a different namespace's entry. + await store.produce({ + dedupeKey: "quota-exhausted:ns-d:cpu", + kind: "quota-exhausted", + namespace: "ns-d", + now: NOW, + payload: quotaPayload("cpu"), + }); + + assert.deepEqual(await store.listMessages("ns-c"), []); + assert.deepEqual( + await store.listReceipts(carol), + ["cr:keep:1"], + "the swept row's receipt cascades; the CR receipt stays" + ); + assert.equal( + (await store.listMessages("ns-a")).length, + 2, + "entries inside the window survive" + ); +}); diff --git a/apps/ui/src/features/notifications/store.ts b/apps/ui/src/features/notifications/store.ts new file mode 100644 index 00000000..79f9ab87 --- /dev/null +++ b/apps/ui/src/features/notifications/store.ts @@ -0,0 +1,243 @@ +import { randomUUID } from "node:crypto"; + +import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; + +import { requireCurrentIdentityBinding } from "@/lib/identity-fingerprint-core"; + +import type { NotificationPgDatabase } from "./db-types"; +import { DB_NOTIFICATION_ID_PREFIX } from "./notification-ids"; +import { + NOTIFICATION_RETENTION_DAYS, + type NotificationMessageRow, + notificationMessages, + notificationReadReceipts, +} from "./schema"; +import type { + NotificationMessage, + NotificationMessageKind, + NotificationPayload, +} from "./types"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +type ReceiptInsert = typeof notificationReadReceipts.$inferInsert; + +/** The reader marking notifications read: bare uid key + crName for the re-check. */ +export interface NotificationReader { + legacyWorkspaceActor: string; + namespace: string; + userUid: string; +} + +export interface ProduceNotificationInput { + dedupeKey: string; + kind: NotificationMessageKind; + namespace: string; + /** Injection seam for the sweep's clock; defaults to now. */ + now?: Date; + payload: NotificationPayload; + projectUid?: string | null; +} + +export interface NotificationStore { + /** Whether a live (unreleased) row holds the dedupe key. */ + isLive(dedupeKey: string): Promise; + /** The namespace's entries, newest first. */ + listMessages(namespace: string): Promise; + /** The user's receipts for the namespace, as source-prefixed ids. */ + listReceipts(reader: NotificationReader): Promise; + /** + * Records receipts for the given source-prefixed ids. `db:` ids attach the + * message row so the sweep cascades; unknown or foreign `db:` ids are + * ignored rather than refused. Idempotent. + */ + markRead(reader: NotificationReader, ids: readonly string[]): Promise; + /** + * Edge-triggered write: inserts one entry unless a live row already holds + * the dedupe key, in which case nothing is written (retries are idempotent + * by naming). Every write also sweeps entries older than the retention + * window — no scheduler. Returns whether a row was inserted. + */ + produce(input: ProduceNotificationInput): Promise; + /** + * Recovery: frees the dedupe key so the next threshold crossing writes a + * fresh entry. The released row stays as history. Returns whether a live + * row was released. + */ + release(input: { dedupeKey: string; now?: Date }): Promise; +} + +/** + * `db:` receipts attach their message row so the sweep cascades; a `db:` id + * this namespace does not hold is skipped (it would outlive nothing). Every + * other id is a platform key and stands alone. + */ +function receiptRows( + keys: readonly string[], + ownedMessageIds: ReadonlySet, + reader: NotificationReader +): ReceiptInsert[] { + const rows: ReceiptInsert[] = []; + for (const key of keys) { + const isDbKey = key.startsWith(DB_NOTIFICATION_ID_PREFIX); + const messageId = isDbKey + ? key.slice(DB_NOTIFICATION_ID_PREFIX.length) + : null; + if (isDbKey && !(messageId != null && ownedMessageIds.has(messageId))) { + continue; + } + rows.push({ + messageId, + messageKey: key, + namespace: reader.namespace, + userUid: reader.userUid, + }); + } + return rows; +} + +function toMessage(row: NotificationMessageRow): NotificationMessage { + return { + createdAt: row.createdAt.getTime(), + id: row.id, + kind: row.kind, + payload: row.payload, + projectUid: row.projectUid, + }; +} + +/** + * Persistence for the Notification Center's Brain-produced stream and read + * receipts (ADR-0067). The handle's schema carries `identity_fingerprints` + * too: receipt writes must span the binding re-check (ADR-0059). + */ +export function createNotificationStore( + getDb: () => NotificationPgDatabase +): NotificationStore { + return { + isLive: async (dedupeKey) => { + const [row] = await getDb() + .select({ id: notificationMessages.id }) + .from(notificationMessages) + .where( + and( + eq(notificationMessages.dedupeKey, dedupeKey), + isNull(notificationMessages.releasedAt) + ) + ) + .limit(1); + return row != null; + }, + listMessages: async (namespace) => { + const rows = await getDb() + .select() + .from(notificationMessages) + .where(eq(notificationMessages.namespace, namespace)) + .orderBy( + desc(notificationMessages.createdAt), + desc(notificationMessages.id) + ); + return rows.map(toMessage); + }, + listReceipts: async (reader) => { + const rows = await getDb() + .select({ messageKey: notificationReadReceipts.messageKey }) + .from(notificationReadReceipts) + .where( + and( + eq(notificationReadReceipts.userUid, reader.userUid), + eq(notificationReadReceipts.namespace, reader.namespace) + ) + ); + return rows.map((row) => row.messageKey); + }, + markRead: (reader, ids) => + getDb().transaction(async (tx) => { + const keys = [...new Set(ids)]; + if (keys.length === 0) { + return; + } + // Receipts are uid-keyed rows: re-check the fingerprint in the same + // transaction so a concurrent merge either sweeps them or refuses + // the stale binding (ADR-0059). + await requireCurrentIdentityBinding(tx, { + crName: reader.legacyWorkspaceActor, + userUid: reader.userUid, + }); + const dbIds = keys + .filter((key) => key.startsWith(DB_NOTIFICATION_ID_PREFIX)) + .map((key) => key.slice(DB_NOTIFICATION_ID_PREFIX.length)); + const owned = + dbIds.length === 0 + ? [] + : await tx + .select({ id: notificationMessages.id }) + .from(notificationMessages) + .where( + and( + eq(notificationMessages.namespace, reader.namespace), + inArray(notificationMessages.id, dbIds) + ) + ); + const ownedIds = new Set(owned.map((row) => row.id)); + const values = receiptRows(keys, ownedIds, reader); + if (values.length === 0) { + return; + } + await tx + .insert(notificationReadReceipts) + .values(values) + .onConflictDoNothing({ + target: [ + notificationReadReceipts.userUid, + notificationReadReceipts.namespace, + notificationReadReceipts.messageKey, + ], + }); + }), + produce: (input) => + getDb().transaction(async (tx) => { + const now = input.now ?? new Date(); + const inserted = await tx + .insert(notificationMessages) + .values({ + createdAt: now, + dedupeKey: input.dedupeKey, + id: randomUUID(), + kind: input.kind, + namespace: input.namespace, + payload: input.payload, + projectUid: input.projectUid ?? null, + }) + .onConflictDoNothing({ + target: notificationMessages.dedupeKey, + where: sql`${notificationMessages.releasedAt} IS NULL`, + }) + .returning({ id: notificationMessages.id }); + // Retention is enforced here, on the write path, so the table never + // needs a scheduler; receipts on swept rows cascade. + await tx + .delete(notificationMessages) + .where( + lt( + notificationMessages.createdAt, + new Date(now.getTime() - NOTIFICATION_RETENTION_DAYS * DAY_MS) + ) + ); + return inserted.length > 0; + }), + release: async (input) => { + const released = await getDb() + .update(notificationMessages) + .set({ releasedAt: input.now ?? new Date() }) + .where( + and( + eq(notificationMessages.dedupeKey, input.dedupeKey), + isNull(notificationMessages.releasedAt) + ) + ) + .returning({ id: notificationMessages.id }); + return released.length > 0; + }, + }; +} diff --git a/apps/ui/src/features/notifications/types.ts b/apps/ui/src/features/notifications/types.ts new file mode 100644 index 00000000..c594dd4d --- /dev/null +++ b/apps/ui/src/features/notifications/types.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { workspaceResourceQuotaSnapshotSchema } from "@/features/billing/workspace-resource-quota"; + +/** + * The Brain-produced Notification kinds (`db:` stream). Each kind's payload + * carries the structured parameters the display layer renders; strings never + * cross the wire. The first producer is `quota-exhausted`; the gift hint and + * subscription-change receipt follow in later tickets. + */ +export const NOTIFICATION_MESSAGE_KINDS = ["quota-exhausted"] as const; + +export type NotificationMessageKind = + (typeof NOTIFICATION_MESSAGE_KINDS)[number]; + +/** The five workspace quota resources a `quota-exhausted` entry can name. */ +export const QUOTA_EXHAUSTED_RESOURCES = [ + "cpu", + "memory", + "storage", + "pod", + "nodeport", +] as const; + +export type QuotaExhaustedResource = (typeof QUOTA_EXHAUSTED_RESOURCES)[number]; + +export const quotaExhaustedPayloadSchema = z + .object({ + kind: z.literal("quota-exhausted"), + resource: z.enum(QUOTA_EXHAUSTED_RESOURCES), + /** Snapshot at the crossing, in the quota item's native unit. */ + limit: z.number().finite().nonnegative(), + used: z.number().finite().nonnegative(), + }) + .strict(); + +export const notificationPayloadSchema = z.discriminatedUnion("kind", [ + quotaExhaustedPayloadSchema, +]); + +export type NotificationPayload = z.infer; + +/** The `db:` stream's wire shape (`GET /api/notifications`). */ +export const notificationMessageSchema = z + .object({ + id: z.string().min(1), + kind: z.enum(NOTIFICATION_MESSAGE_KINDS), + /** Unix epoch milliseconds. */ + createdAt: z.number().int().nonnegative(), + payload: notificationPayloadSchema, + projectUid: z.string().nullable(), + }) + .strict(); + +export type NotificationMessage = z.infer; + +export const notificationFeedResponseSchema = z + .object({ + messages: z.array(notificationMessageSchema), + /** Source-prefixed notification ids the current user has read. */ + receipts: z.array(z.string().min(1)), + }) + .strict(); + +export type NotificationFeedResponse = z.infer< + typeof notificationFeedResponseSchema +>; + +/** A notification id is source-prefixed: `cr::` or `db:`. */ +export const NOTIFICATION_ID_PATTERN = /^(cr|db):.+$/; + +export const markNotificationReadRequestSchema = z + .object({ + ids: z.array(z.string().regex(NOTIFICATION_ID_PATTERN)).min(1).max(200), + }) + .strict(); + +export type MarkNotificationReadRequest = z.infer< + typeof markNotificationReadRequestSchema +>; + +export const quotaObservationRequestSchema = z + .object({ + quota: workspaceResourceQuotaSnapshotSchema, + }) + .strict(); + +export type QuotaObservationRequest = z.infer< + typeof quotaObservationRequestSchema +>; diff --git a/apps/ui/src/features/notifications/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts new file mode 100644 index 00000000..8cb709f7 --- /dev/null +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -0,0 +1,145 @@ +"use client"; + +import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; +import { + markNotificationCRRead, + NOTIFICATION_CR_REFRESH_INTERVAL_MS, + useNotificationCRList, +} from "@workspace/api/hooks"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import useSWR from "swr"; + +import { + type AppNotification, + countUnreadNotifications, + isNotificationUnread, +} from "@/features/shell/app-sidebar-notifications-model"; +import { + notificationReadIdsAtom, + notificationsDevMockItemsAtom, +} from "@/features/shell/app-sidebar-notifications-store"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; +import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +import { fetchNotificationFeed, postNotificationReadReceipts } from "./client"; +import { mergeNotificationFeed } from "./feed-model"; +import { planReadDispatch } from "./read-dispatch"; + +const EMPTY_ITEMS: readonly AppNotification[] = []; + +export interface NotificationFeed { + items: readonly AppNotification[]; + markAllRead: () => void; + markRead: (item: AppNotification) => void; + readIds: ReadonlySet; + unreadCount: number; +} + +/** + * The merged Notification Center feed: platform CRs polled from the cluster + * (≤5 minutes) and Brain-produced entries plus the user's receipts from + * Brain's store, merged client-side into one list. Mark-read is optimistic + * and dispatched per source; a Dev Mock override replaces the items outright. + */ +export function useNotificationFeed(): NotificationFeed { + const appToken = useAtomValue(appTokenAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const namespace = useAtomValue(namespaceAtom).trim(); + const mockItems = useAtomValue(notificationsDevMockItemsAtom); + const [readIds, setReadIds] = useAtom(notificationReadIdsAtom); + const { data: subscription } = useWorkspaceSubscriptionSummary(); + + const credentialsReady = + appToken !== "" && kubeconfig !== "" && namespace !== ""; + const credentialKey = kubeconfigCredentialKey(kubeconfig); + + const crList = useNotificationCRList({ + enabled: mockItems == null, + kubeconfig, + namespace, + }); + const brainFeed = useSWR( + credentialsReady && mockItems == null + ? (["notifications-feed", namespace, credentialKey, appToken] as const) + : null, + () => fetchNotificationFeed({ appToken, kubeconfig, namespace }), + { + refreshInterval: NOTIFICATION_CR_REFRESH_INTERVAL_MS, + shouldRetryOnError: false, + } + ); + + const items = useMemo(() => { + if (mockItems != null) { + return mockItems; + } + if (crList.data == null && brainFeed.data == null) { + return EMPTY_ITEMS; + } + return mergeNotificationFeed({ + crItems: crList.data?.items ?? [], + dbMessages: brainFeed.data?.messages ?? [], + receipts: brainFeed.data?.receipts ?? [], + }); + }, [brainFeed.data, crList.data, mockItems]); + + const dispatchRead = useCallback( + (targets: readonly AppNotification[]) => { + if (targets.length === 0) { + return; + } + setReadIds((previous) => { + const next = new Set(previous); + for (const target of targets) { + next.add(target.id); + } + return next; + }); + if (mockItems != null || !credentialsReady) { + return; + } + const plan = planReadDispatch(targets, subscription?.role); + const credentials = { appToken, kubeconfig, namespace }; + postNotificationReadReceipts(credentials, plan.receiptIds) + .then(() => brainFeed.mutate()) + .catch(() => undefined); + // Desktop parity is best-effort: a 403 (Developer RBAC) or any other + // failure is swallowed — the receipt already made the message read. + for (const name of plan.crNames) { + markNotificationCRRead({ kubeconfig, name, namespace }) + .then(() => crList.mutate()) + .catch(() => undefined); + } + }, + [ + appToken, + brainFeed.mutate, + credentialsReady, + crList.mutate, + kubeconfig, + mockItems, + namespace, + setReadIds, + subscription?.role, + ] + ); + + const markRead = useCallback( + (item: AppNotification) => dispatchRead([item]), + [dispatchRead] + ); + const markAllRead = useCallback( + () => + dispatchRead(items.filter((item) => isNotificationUnread(item, readIds))), + [dispatchRead, items, readIds] + ); + + return { + items, + markAllRead, + markRead, + readIds, + unreadCount: countUnreadNotifications(items, readIds), + }; +} diff --git a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts index bf3f668c..dd33eff2 100644 --- a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts @@ -15,6 +15,7 @@ function summary( isPayg: false, lifecycle: "active", planName: "PRO", + role: null, ...overrides, }; } diff --git a/apps/ui/src/features/shell/app-sidebar-account.tsx b/apps/ui/src/features/shell/app-sidebar-account.tsx index 87257eca..8d745a72 100644 --- a/apps/ui/src/features/shell/app-sidebar-account.tsx +++ b/apps/ui/src/features/shell/app-sidebar-account.tsx @@ -30,12 +30,8 @@ import { useRef, useState, } from "react"; -import useSWR from "swr"; import { loadAiCredits } from "@/features/billing/billing-ai-credits"; -import { - loadWorkspaceSubscriptionSummary, - type WorkspaceSubscriptionSummary, -} from "@/features/billing/billing-plan-data"; +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; import { fetchFreeChatTurnsUsage } from "@/features/chat/persistence/client"; @@ -56,6 +52,7 @@ import { quotaUsageTone, } from "@/features/shell/app-sidebar-quota"; import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-toggle"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; import { appTokenAtom, desktopUserAvatarAtom, @@ -758,15 +755,8 @@ export function AppSidebarAccount() { const credentialsReady = appToken !== "" && kubeconfig !== "" && workspace !== ""; - // Live billing data, not the login-time session snapshot: the badge and - // hint follow the same subscription route as the Billing Area's hooks. - const { data: subscriptionSummary, isLoading: subscriptionPending } = useSWR( - credentialsReady - ? (["app-sidebar-subscription", workspace, kubeconfig, appToken] as const) - : null, - () => loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), - { revalidateOnFocus: false, shouldRetryOnError: false } - ); + const { data: subscriptionSummary, isLoading: subscriptionPending } = + useWorkspaceSubscriptionSummary(); const { badge, hint } = useMemo( () => deriveAppSidebarAccountPresentation( diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx b/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx index 4d699af5..d0888bfc 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx @@ -9,117 +9,138 @@ import { useSetAtom } from "jotai"; import { useEffect } from "react"; import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; import { - appNotificationsAtom, notificationReadIdsAtom, + notificationsDevMockItemsAtom, } from "@/features/shell/app-sidebar-notifications-store"; /** - * The Notification Center's Dev Mock: fills the notifications store with - * fixture items so the panel can be designed and demoed before a real feed - * exists. Registered while the App Sidebar is mounted; disabled (the - * default) leaves the store empty — the shipped empty state. + * The Notification Center's Dev Mock: overrides the merged feed with fixture + * items so the panel can be designed and demoed without a cluster or store. + * Registered while the App Sidebar is mounted; disabled (the default) hands + * the panel back to the real feed. Scenario fixtures feeding the real + * pipeline (fixture CRs and rows) are the display-layer ticket's follow-up. */ export const NOTIFICATIONS_DEV_MOCK_KEY = "notifications-mock"; const NOTIFICATIONS_DEV_SCENARIOS = ["mixed", "flood", "all-read"] as const; +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const MOCK_NOW = Date.now(); + +function mockItem( + overrides: Partial & { + ago: number; + id: string; + kind: AppNotification["kind"]; + title: string; + } +): AppNotification { + const { ago, ...rest } = overrides; + return { + source: "db", + timestamp: MOCK_NOW - ago, + unread: false, + ...rest, + id: `db:mock-${rest.id}`, + }; +} + const MIXED_ITEMS: AppNotification[] = [ - { + mockItem({ + ago: 2 * MINUTE_MS, id: "n1", kind: "deploy-failure", project: "api-server", - time: "2m ago", title: "Deployment failed", unread: true, - }, - { + }), + mockItem({ + ago: 26 * MINUTE_MS, id: "n2", kind: "deploy-success", project: "web-app", - time: "26m ago", title: "Deployment complete", unread: true, - }, - { + }), + mockItem({ + ago: HOUR_MS, id: "n3", kind: "quota", - time: "1h ago", - title: "Memory quota at 82%", + title: "Storage quota is full", unread: true, - }, - { + }), + mockItem({ + ago: 30 * HOUR_MS, id: "n4", kind: "billing", - time: "Yesterday", title: "Free trial ends in 3 days", - unread: false, - }, - { + }), + mockItem({ + ago: 31 * HOUR_MS, id: "n5", kind: "deploy-success", project: "pg-main", - time: "Yesterday", title: "Database backup finished", - unread: false, - }, - { + }), + mockItem({ + ago: 2 * DAY_MS, id: "n6", kind: "announcement", - time: "2d ago", title: "New: database terminal", - unread: false, - }, + }), ]; const FLOOD_ITEMS: AppNotification[] = [ ...MIXED_ITEMS.slice(0, 3), - { + mockItem({ + ago: 2 * HOUR_MS, id: "n7", kind: "deploy-success", project: "landing", - time: "3h ago", title: "Deployment complete", unread: true, - }, - { + }), + mockItem({ + ago: 3 * HOUR_MS, id: "n8", - kind: "quota", - time: "6h ago", - title: "Storage quota at 91%", + kind: "deploy-failure", + project: "worker", + title: "Deployment failed", unread: true, - }, - ...MIXED_ITEMS.slice(3), - { + }), + mockItem({ + ago: 4 * HOUR_MS, id: "n9", - kind: "deploy-failure", - project: "redis-cache", - time: "2d ago", - title: "Workload restarted", - unread: false, - }, - { + kind: "quota", + title: "CPU quota is full", + unread: true, + }), + mockItem({ + ago: 5 * HOUR_MS, id: "n10", kind: "billing", - time: "3d ago", - title: "Invoice ready", - unread: false, - }, - { + title: "Balance exhausted", + unread: true, + }), + mockItem({ + ago: 6 * HOUR_MS, id: "n11", - kind: "announcement", - time: "5d ago", - title: "New: GitHub deployments", - unread: false, - }, - { - id: "n12", kind: "deploy-success", - project: "docs-site", - time: "6d ago", + project: "docs", title: "Deployment complete", - unread: false, - }, + unread: true, + }), + mockItem({ + ago: 7 * HOUR_MS, + id: "n12", + kind: "announcement", + title: "Scheduled maintenance", + unread: true, + }), + ...MIXED_ITEMS.slice(3), ]; const SCENARIO_ITEMS: Record = { @@ -142,19 +163,19 @@ const notificationsDevMockSource: DevTweaksMockSource = { /** Registers the mock while the App Sidebar is mounted; renders nothing. */ export function AppSidebarNotificationsDevMock() { const mock = useDevTweaksMock(NOTIFICATIONS_DEV_MOCK_KEY, { - note: "Fills the Notification Center with fixture items", + note: "Replaces the Notification Center feed with fixture items", scenarios: NOTIFICATIONS_DEV_SCENARIOS, source: notificationsDevMockSource, title: "Notifications mock", }); - const setItems = useSetAtom(appNotificationsAtom); + const setItems = useSetAtom(notificationsDevMockItemsAtom); const setReadIds = useSetAtom(notificationReadIdsAtom); useEffect(() => { setItems( - mock.enabled ? (SCENARIO_ITEMS[mock.scenario] ?? MIXED_ITEMS) : [] + mock.enabled ? (SCENARIO_ITEMS[mock.scenario] ?? MIXED_ITEMS) : null ); - // A scenario switch is a fresh inbox: session read receipts reset with it. + // A scenario switch is a fresh inbox: optimistic read receipts reset. setReadIds(new Set()); }, [mock.enabled, mock.scenario, setItems, setReadIds]); diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts index 501d9f6b..b63d7b1b 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts @@ -11,9 +11,10 @@ import { function item(overrides: Partial): AppNotification { return { - id: "n1", + id: "db:n1", kind: "deploy-success", - time: "2m ago", + source: "db", + timestamp: Date.UTC(2026, 7, 27, 12, 0, 0), title: "Deployment complete", unread: false, ...overrides, diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts index 3493293c..df3fe10a 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts @@ -1,8 +1,10 @@ /** * The Notification Center's model: what one Notification is, plus the pure * derivations the panel renders from (unread accounting, tab filtering, the - * entry badge). No data source is wired yet — items arrive via the - * notifications store, which only the dev mock writes today. + * entry badge). Items arrive from the merged feed + * (`@/features/notifications/feed-model`): platform CRs read live from the + * cluster and Brain-produced entries from Brain's own store, one list sorted + * by real time. */ export type AppNotificationKind = @@ -12,17 +14,25 @@ export type AppNotificationKind = | "deploy-success" | "quota"; +/** Which stream a Notification came from; also its id prefix. */ +export type AppNotificationSource = "cr" | "db"; + /** * One Notification: a message addressed to the current user, aggregated - * across Projects. `time` is a preformatted display string until a real - * data source lands; `unread` is the item's own state before session read - * receipts are applied on top. + * across Projects. `id` is source-prefixed (`cr::` or + * `db:`) and doubles as the read-receipt key; `timestamp` is epoch + * milliseconds; `unread` is the item's own state (the CR label, or "never + * read" for Brain entries) before receipts are applied on top. */ export interface AppNotification { + body?: string; + /** The CR to patch when a `cr:` item is marked read (best-effort). */ + crName?: string; id: string; kind: AppNotificationKind; project?: string; - time: string; + source: AppNotificationSource; + timestamp: number; title: string; unread: boolean; } diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-store.ts b/apps/ui/src/features/shell/app-sidebar-notifications-store.ts index bb0cc505..798d1e63 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-store.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-store.ts @@ -2,16 +2,20 @@ import { atom } from "jotai"; import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; /** - * The Notification Center's items. Empty until a data source writes it — in - * the shell phase only the dev mock does, so production users see the empty - * state. A future real feed replaces the writer, not the readers. + * The notifications Dev Mock's override: `null` while the mock is off (the + * merged feed is the writer), a fixture list while it is on. The feed hook + * reads this so the panel and badge never need to know which is live. */ -export const appNotificationsAtom = atom([]); +export const notificationsDevMockItemsAtom = atom< + readonly AppNotification[] | null +>(null); /** - * Session-local read receipts layered over the items' own `unread` flags: - * clicking a row or "mark all as read" lands here. Deliberately not - * persisted while notifications have no backend identity to key on. + * Optimistic read receipts layered over the items' own `unread` flags: + * clicking a row or "mark all as read" lands here first, and the server + * receipt (and, best-effort, the CR label) follows. Keyed by the + * source-prefixed notification id, so a revived platform message (new + * timestamp, new id) is never covered by a stale receipt. */ export const notificationReadIdsAtom = atom>( new Set() diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.tsx index 8fd93ee2..dee0dd0d 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications.tsx @@ -7,7 +7,6 @@ import { } from "@workspace/ui/components/popover"; import { useSidebar } from "@workspace/ui/components/sidebar"; import { cn } from "@workspace/ui/lib/utils"; -import { useAtom, useAtomValue } from "jotai"; import { Bell, CheckCheck, @@ -18,21 +17,21 @@ import { type LucideIcon, Megaphone, } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useRef, useState } from "react"; +import { formatNotificationTime } from "@/features/notifications/notification-time"; +import { + type NotificationFeed, + useNotificationFeed, +} from "@/features/notifications/use-notification-feed"; import { AppSidebarNotificationsDevMockGate } from "@/features/shell/app-sidebar-notifications-dev-mock-gate"; import { type AppNotification, type AppNotificationKind, - countUnreadNotifications, isNotificationUnread, type NotificationTab, notificationBadgeLabel, visibleNotifications, } from "@/features/shell/app-sidebar-notifications-model"; -import { - appNotificationsAtom, - notificationReadIdsAtom, -} from "@/features/shell/app-sidebar-notifications-store"; import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-toggle"; const KIND_META: Record< @@ -69,7 +68,9 @@ function NotificationRow({ onRead: () => void; unread: boolean; }) { - const meta = [item.project, item.time].filter(Boolean).join(" · "); + const meta = [item.project, formatNotificationTime(item.timestamp)] + .filter(Boolean) + .join(" · "); return ( + {cta == null ? null : ( + { + if (cta.href.startsWith("/billing")) { + recordBillingReturnRoute(); + } + onRead(); + }} + > + {cta.label} + + )} - + ); } @@ -226,7 +251,9 @@ export function AppSidebarNotifications() { return ( <> - + {/* The billing Dev Mock's scenarios drive the inbox fixtures too, so + its panel entry is reachable from anywhere the inbox is. */} + Date: Thu, 27 Aug 2026 23:29:25 +0800 Subject: [PATCH 05/14] feat(status-hint): single-slot billing state banner (AIM-333) Productionize the status hint surface: one tinted, in-flow banner at the top of the shell's content column that explains a holding billing state and offers the fix, evaluated client-side from already-proxied subscription, account, and quota reads. - Pure model: payment-due > account-debt > quota-full > trial-expiry, single slot, dismiss/revive edge semantics, settled strings from the design doc; payment-due stages change copy and dates only. - Subscription summary carries the Deletion Countdown stage and derived deadline (ADR-0063) plus the recovery voice; an expired Free trial asks for an upgrade, never a renewal. - Currency-free balance read and a per-workspace quota read split out of the existing loaders; the Usage quota rows learn Pods. - Dev fixtures gain `free-expiring` so every banner state is simulable. - CONTEXT.md names the Status Hint. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 6 + apps/ui/src/app/billing/layout.tsx | 2 + apps/ui/src/app/project/layout.tsx | 2 + .../src/features/billing/account-balance.ts | 32 +- .../billing/billing-plan-data.test.ts | 23 ++ .../src/features/billing/billing-plan-data.ts | 37 +- .../features/billing/billing-usage-data.ts | 37 +- .../ui/src/features/billing/billing-usage.tsx | 2 + .../src/features/billing/dev-mock-cookie.ts | 1 + .../server/dev-fixtures/dev-fixtures.test.ts | 1 + .../billing/server/dev-fixtures/index.ts | 11 +- .../server/dev-fixtures/notifications.ts | 5 + .../app-sidebar-account-presentation.test.ts | 3 + .../use-workspace-subscription-summary.ts | 15 +- .../status-hint-banner.interaction.test.tsx | 105 +++++ .../status-hint/status-hint-banner.tsx | 99 +++++ .../status-hint/status-hint-model.test.ts | 360 ++++++++++++++++++ .../features/status-hint/status-hint-model.ts | 291 ++++++++++++++ .../features/status-hint/status-hint-store.ts | 14 + .../status-hint-through-fixtures.test.ts | 129 +++++++ .../features/status-hint/use-status-hint.ts | 128 +++++++ 21 files changed, 1272 insertions(+), 31 deletions(-) create mode 100644 apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx create mode 100644 apps/ui/src/features/status-hint/status-hint-banner.tsx create mode 100644 apps/ui/src/features/status-hint/status-hint-model.test.ts create mode 100644 apps/ui/src/features/status-hint/status-hint-model.ts create mode 100644 apps/ui/src/features/status-hint/status-hint-store.ts create mode 100644 apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts create mode 100644 apps/ui/src/features/status-hint/use-status-hint.ts diff --git a/CONTEXT.md b/CONTEXT.md index e24e3718..c7e9c33e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -724,6 +724,12 @@ One message addressed to the current user in the Notification Center: a system e _Avoid_: alert, toast (for persistent items), event (for the user-facing message). +### Status Hint + +The one banner at the top of the content area that explains a billing state while it holds — payment-due (under the Deletion Countdown), Account Debt, a full workspace quota, or an Active Free Trial about to end — and offers the way out. It is a state, not a message: it appears and vanishes with the condition, writes nothing to the Notification Center, and only the most severe holding state shows. The destructive states cannot be dismissed; a dismissed quota or trial hint stays hidden until its state ends and re-enters. + +_Avoid_: alert bar, global notification, sticky toast, warning strip (as the concept's name). + ## Design System ### Component Registry diff --git a/apps/ui/src/app/billing/layout.tsx b/apps/ui/src/app/billing/layout.tsx index 58505af8..6ba362ff 100644 --- a/apps/ui/src/app/billing/layout.tsx +++ b/apps/ui/src/app/billing/layout.tsx @@ -9,6 +9,7 @@ import { AppSidebarCookieBridge } from "@/features/shell/app-sidebar-cookie-brid import AuthBootstrap, { SealosSdkBootstrap, } from "@/features/shell/auth-bootstrap"; +import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; /** Desktop iframe auth is resolved on the client through the Sealos SDK. */ export const dynamic = "force-dynamic"; @@ -26,6 +27,7 @@ export default function BillingLayout({ + {children} diff --git a/apps/ui/src/app/project/layout.tsx b/apps/ui/src/app/project/layout.tsx index 9089fb86..4be2bcc8 100644 --- a/apps/ui/src/app/project/layout.tsx +++ b/apps/ui/src/app/project/layout.tsx @@ -11,6 +11,7 @@ import AuthBootstrap, { SealosSdkBootstrap, } from "@/features/shell/auth-bootstrap"; import ProjectWorkspaceLayout from "@/features/shell/project-workspace-layout"; +import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; /** Desktop iframe auth is resolved on the client through the Sealos SDK. */ export const dynamic = "force-dynamic"; @@ -30,6 +31,7 @@ export default function ProjectLayout({ + {children} diff --git a/apps/ui/src/features/billing/account-balance.ts b/apps/ui/src/features/billing/account-balance.ts index e052f87f..35248a2a 100644 --- a/apps/ui/src/features/billing/account-balance.ts +++ b/apps/ui/src/features/billing/account-balance.ts @@ -18,14 +18,15 @@ export interface AccountBalance { microUnits: number; } -export async function loadAccountBalance( - credentials: { - appToken: string; - currency: BillingCurrency; - kubeconfig: string; - }, +/** + * The cash term of the available Account Balance: Balance − DeductionBalance, + * before credits. Currency-free, for surfaces that judge the amount rather + * than display it (the status hint's Account Debt evaluation). + */ +export async function loadAccountBalanceMicroUnits( + credentials: { appToken: string; kubeconfig: string }, fetch: BillingFetch = globalThis.fetch -): Promise { +): Promise { const requestBillingJson = createBillingJsonRequester({ credentials, fallbackErrorMessage: "Could not load Account Balance.", @@ -36,9 +37,22 @@ export async function loadAccountBalance( if (!parsed.success) { throw new Error("Account Balance response is invalid."); } + return parsed.data.account.Balance - parsed.data.account.DeductionBalance; +} + +export async function loadAccountBalance( + credentials: { + appToken: string; + currency: BillingCurrency; + kubeconfig: string; + }, + fetch: BillingFetch = globalThis.fetch +): Promise { return { currency: credentials.currency, - microUnits: - parsed.data.account.Balance - parsed.data.account.DeductionBalance, + microUnits: await loadAccountBalanceMicroUnits( + { appToken: credentials.appToken, kubeconfig: credentials.kubeconfig }, + fetch + ), }; } diff --git a/apps/ui/src/features/billing/billing-plan-data.test.ts b/apps/ui/src/features/billing/billing-plan-data.test.ts index 6124a0d0..764d034c 100644 --- a/apps/ui/src/features/billing/billing-plan-data.test.ts +++ b/apps/ui/src/features/billing/billing-plan-data.test.ts @@ -1174,7 +1174,10 @@ test("loads the sidebar subscription summary with only region-addressed reads", isPayg: false, lifecycle: "active", planName: "Pro", + recoveryVoice: "renew", role: "OWNER", + warningDeadlineAt: null, + warningStage: null, }); assert.deepEqual( requests.map((request) => request.url), @@ -1198,7 +1201,10 @@ test("the sidebar summary reports an Active Free Trial and its period end", asyn isPayg: false, lifecycle: "active", planName: "Free", + recoveryVoice: "resubscribe", role: "OWNER", + warningDeadlineAt: null, + warningStage: null, }); }); @@ -1222,6 +1228,23 @@ test("the sidebar summary presents a deleted subscription as PAYG", async () => isPayg: true, lifecycle: "active", planName: "PAYG", + recoveryVoice: "renew", role: "OWNER", + warningDeadlineAt: null, + warningStage: null, }); }); + +test("the sidebar summary carries the Deletion Countdown's stage and derived deadline", async () => { + // ADR-0063: the status hint states the deletion date exactly as the Plan + // view does — expiry plus the fixed grace, derived client-side. + const { summary } = loadSummaryWithSubscription({ + CurrentPeriodEndAt: "2026-08-20T00:00:00Z", + Status: "DEBT", + }); + const result = await summary; + assert.equal(result.lifecycle, "payment-due"); + assert.equal(result.warningStage, "expired"); + assert.equal(result.warningDeadlineAt, "2026-09-03T00:00:00.000Z"); + assert.equal(result.recoveryVoice, "renew"); +}); diff --git a/apps/ui/src/features/billing/billing-plan-data.ts b/apps/ui/src/features/billing/billing-plan-data.ts index 0e2fec83..c2b08145 100644 --- a/apps/ui/src/features/billing/billing-plan-data.ts +++ b/apps/ui/src/features/billing/billing-plan-data.ts @@ -726,8 +726,15 @@ export interface WorkspaceSubscriptionSummary { isPayg: boolean; lifecycle: SubscriptionLifecycle; planName: string; + recoveryVoice: RecoveryVoice; /** Null when the record names no role (PAYG workspaces). */ role: WorkspaceSubscriptionRole | null; + /** + * The Deletion Countdown's next deadline, derived client-side exactly as + * the Plan view derives it (ADR-0063). Set only while `warningStage` is. + */ + warningDeadlineAt: string | null; + warningStage: SubscriptionWarningStage | null; } export async function loadWorkspaceSubscriptionSummary( @@ -755,6 +762,19 @@ export async function loadWorkspaceSubscriptionSummary( ).subscription ); + // Pending upgrades are invisible to this read (no transaction request); + // they surface as the quiet "active" state, which is what the account + // row's second line wants anyway. + const lifecycle = subscriptionLifecycle({ + cancelAtPeriodEnd: subscription.CancelAtPeriodEnd, + isPayg: subscription.type === "PAYG", + planName: subscription.PlanName, + status: subscription.Status, + }); + const warningStage = subscriptionWarningStage({ + lifecycle, + status: subscription.Status, + }); return { currentPeriodEndAt: subscription.CurrentPeriodEndAt, isActiveFreeTrial: isActiveFreeTrialSubscription({ @@ -763,17 +783,16 @@ export async function loadWorkspaceSubscriptionSummary( type: subscription.type ?? "", }), isPayg: subscription.type === "PAYG", - // Pending upgrades are invisible to this read (no transaction request); - // they surface as the quiet "active" state, which is what the account - // row's second line wants anyway. - lifecycle: subscriptionLifecycle({ - cancelAtPeriodEnd: subscription.CancelAtPeriodEnd, - isPayg: subscription.type === "PAYG", - planName: subscription.PlanName, - status: subscription.Status, - }), + lifecycle, planName: subscription.PlanName, + recoveryVoice: recoveryVoice(subscription.PlanName), role: subscription.role ?? null, + warningDeadlineAt: subscriptionWarningDeadline({ + currentPeriodEndAt: subscription.CurrentPeriodEndAt, + expireAt: subscription.ExpireAt ?? null, + stage: warningStage, + }), + warningStage, }; } diff --git a/apps/ui/src/features/billing/billing-usage-data.ts b/apps/ui/src/features/billing/billing-usage-data.ts index 34b44748..66d1f7f1 100644 --- a/apps/ui/src/features/billing/billing-usage-data.ts +++ b/apps/ui/src/features/billing/billing-usage-data.ts @@ -12,6 +12,7 @@ export type BillingQuotaType = | "gpu" | "memory" | "nodeport" + | "pod" | "storage" | "traffic"; @@ -56,6 +57,7 @@ const QUOTA_RESOURCES: ReadonlyArray<{ { keys: ["limits.cpu"], label: "CPU", type: "cpu" }, { keys: ["limits.memory"], label: "Memory", type: "memory" }, { keys: ["requests.storage"], label: "Storage", type: "storage" }, + { keys: ["pods", "count/pods"], label: "Pods", type: "pod" }, { keys: ["services.nodeports"], label: "Ports", type: "nodeport" }, { keys: ["traffic"], label: "Traffic", type: "traffic" }, { @@ -122,6 +124,30 @@ function quotaRows( }); } +/** + * One workspace's quota rows from the proxied resource-quota read — the + * Usage view's table without its workspace picker, for surfaces that only + * judge fullness (the status hint's quota-full evaluation). + */ +export async function loadWorkspaceQuotaUsage( + credentials: BillingCredentials & { workspace: string }, + fetch: BillingFetch = globalThis.fetch +): Promise { + const requestBillingJson = createBillingJsonRequester({ + credentials: { + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + }, + fallbackErrorMessage: "Could not load workspace usage.", + fetch, + }); + const quotaPayload = await requestBillingJson( + "/api/billing/workspace-quota", + { workspace: credentials.workspace } + ); + return quotaRows(quotaResponseSchema.parse(quotaPayload)); +} + export async function loadBillingUsage( input: BillingCredentials & { workspace: string }, dependencies: BillingUsageDependencies = {} @@ -163,14 +189,11 @@ export async function loadBillingUsage( return { rows: [], selectedWorkspace, workspaces }; } - const quotaPayload = await requestBillingJson( - "/api/billing/workspace-quota", - { - workspace: selectedWorkspace, - } - ); return { - rows: quotaRows(quotaResponseSchema.parse(quotaPayload)), + rows: await loadWorkspaceQuotaUsage( + { ...credentials, workspace: selectedWorkspace }, + fetch + ), selectedWorkspace, workspaces, }; diff --git a/apps/ui/src/features/billing/billing-usage.tsx b/apps/ui/src/features/billing/billing-usage.tsx index f3311c02..8c3d9904 100644 --- a/apps/ui/src/features/billing/billing-usage.tsx +++ b/apps/ui/src/features/billing/billing-usage.tsx @@ -12,6 +12,7 @@ import { import { cn } from "@workspace/ui/lib/utils"; import { useAtomValue } from "jotai"; import { + Boxes, CircuitBoard, Cpu, HardDrive, @@ -45,6 +46,7 @@ const QUOTA_ICONS = { gpu: CircuitBoard, memory: MemoryStick, nodeport: HdmiPort, + pod: Boxes, storage: HardDrive, traffic: Network, } satisfies Record; diff --git a/apps/ui/src/features/billing/dev-mock-cookie.ts b/apps/ui/src/features/billing/dev-mock-cookie.ts index a9a241aa..69f12cf6 100644 --- a/apps/ui/src/features/billing/dev-mock-cookie.ts +++ b/apps/ui/src/features/billing/dev-mock-cookie.ts @@ -17,6 +17,7 @@ export const BILLING_DEV_SCENARIOS = [ "payg-debt-deletion", "payg-debt-final", "free", + "free-expiring", "free-expired", "paused", "active", diff --git a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts index 6f9efce0..b41898b3 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts @@ -74,6 +74,7 @@ const DATE_RANGE = { const CREDITLESS_SCENARIOS = new Set([ "free", + "free-expiring", "free-expired", "paused", "payg", diff --git a/apps/ui/src/features/billing/server/dev-fixtures/index.ts b/apps/ui/src/features/billing/server/dev-fixtures/index.ts index 95633c99..455a0db1 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -73,6 +73,7 @@ export const PAYG_SCENARIOS = new Set([ const CARDLESS_SCENARIOS = new Set([ "active-balance", "free", + "free-expiring", "free-expired", "paused", ...PAYG_SCENARIOS, @@ -81,6 +82,7 @@ const CARDLESS_SCENARIOS = new Set([ /** Scenarios with no subscription transaction history. */ const TRANSACTIONLESS_SCENARIOS = new Set([ "free", + "free-expiring", "free-expired", "paused", ...PAYG_SCENARIOS, @@ -156,8 +158,11 @@ function subscriptionPayload( return { ...base, CancelAtPeriodEnd: true }; // The platform creates Free subscriptions with CancelAtPeriodEnd already // true; a trial runs a period, a paused (no-trial) Free has none. - case "free": { - const trialEnd = daysFromNow(10); + case "free": + case "free-expiring": { + // free-expiring sits inside the status hint's three-day notice window + // (catalog C2); free is a mid-trial newcomer. + const trialEnd = daysFromNow(scenario === "free-expiring" ? 3 : 10); return { ...base, CancelAtPeriodEnd: true, @@ -737,6 +742,7 @@ const FIXTURES: Record unknown> = { const isPaygMode = PAYG_SCENARIOS.has(context.scenario); const isFreePlan = context.scenario === "free" || + context.scenario === "free-expiring" || context.scenario === "free-expired" || context.scenario === "paused"; return { @@ -884,6 +890,7 @@ const PAY_TRANSITIONS: Record< active: "active", "active-balance": "active", free: "active", + "free-expiring": "active", "mixed-workspaces": "active", paused: "active", "quota-full": "active", diff --git a/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts index eef1e246..831aeead 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts @@ -219,6 +219,11 @@ function scenarioFixture(scenario: BillingDevScenario): ScenarioFixture { unread: "debt-choice-criticalbalanceperiod", }), }; + // The trial's advance reminders (C1) are upstream's to write; until they + // ship, an expiring trial's inbox is quiet and the status hint carries + // the notice alone. + case "free-expiring": + return { brain: [], platform: [] }; case "free-expired": return { brain: [{ ...GIFT_HINT, ago: 20 * DAY_MS }], diff --git a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts index dd33eff2..af785549 100644 --- a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts @@ -15,7 +15,10 @@ function summary( isPayg: false, lifecycle: "active", planName: "PRO", + recoveryVoice: "renew", role: null, + warningDeadlineAt: null, + warningStage: null, ...overrides, }; } diff --git a/apps/ui/src/features/shell/use-workspace-subscription-summary.ts b/apps/ui/src/features/shell/use-workspace-subscription-summary.ts index aa45feec..98aebc7b 100644 --- a/apps/ui/src/features/shell/use-workspace-subscription-summary.ts +++ b/apps/ui/src/features/shell/use-workspace-subscription-summary.ts @@ -9,10 +9,13 @@ import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; /** * The App Sidebar's shared read of the Workspace Subscription summary — the * account row's badge and hint, and the Notification Center's role check for - * the best-effort CR read patch. One SWR key, so both consumers share the - * request. + * the best-effort CR read patch, and the status hint's state evaluation. + * One SWR key, so every consumer shares the request; a consumer that needs + * the state to clear on its own passes a `refreshInterval`. */ -export function useWorkspaceSubscriptionSummary() { +export function useWorkspaceSubscriptionSummary( + options: { refreshInterval?: number } = {} +) { const appToken = useAtomValue(appTokenAtom).trim(); const kubeconfig = useAtomValue(kubeconfigAtom).trim(); const workspace = useAtomValue(namespaceAtom).trim(); @@ -26,6 +29,10 @@ export function useWorkspaceSubscriptionSummary() { ? (["app-sidebar-subscription", workspace, kubeconfig, appToken] as const) : null, () => loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), - { revalidateOnFocus: false, shouldRetryOnError: false } + { + refreshInterval: options.refreshInterval, + revalidateOnFocus: false, + shouldRetryOnError: false, + } ); } diff --git a/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx new file mode 100644 index 00000000..bcd1c45a --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { render } from "@testing-library/react/pure"; + +import { withTestDom } from "@/features/project-canvas/react-test-harness"; + +import type { StatusHint } from "./status-hint-model"; + +const PAYMENT_DUE: StatusHint = { + cta: { href: "/billing", label: "Renew plan" }, + description: + "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.", + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", +}; + +const QUOTA_FULL: StatusHint = { + cta: { href: "/billing/usage", label: "View usage" }, + description: + "New deployments can't start until storage is freed or the plan is upgraded.", + dismissible: true, + id: "quota-full", + title: "Storage quota is full", + tone: "warning", +}; + +async function renderHint( + hint: StatusHint, + run: ( + rendered: ReturnType, + dismissals: () => number, + act: (run: () => void) => Promise + ) => void | Promise +) { + await withTestDom(async (act) => { + const { StatusHintBannerView } = await import("./status-hint-banner"); + let rendered: ReturnType | undefined; + let dismissed = 0; + try { + await act(() => { + rendered = render( + { + dismissed += 1; + }} + /> + ); + }); + if (rendered != null) { + await run(rendered, () => dismissed, act); + } + } finally { + await act(() => rendered?.unmount()); + } + }); +} + +test("a non-dismissible hint renders title, description, and CTA with no close button", async () => { + await renderHint(PAYMENT_DUE, (rendered) => { + // A destructive, non-dismissible state announces itself as an alert. + const banner = rendered.getByRole("alert"); + assert.equal(banner.dataset.state, "payment-due"); + assert.equal(banner.dataset.tone, "destructive"); + const text = banner.textContent ?? ""; + assert.ok(text.includes("Workspace suspended — payment due")); + assert.ok(text.includes("Resources will be deleted on Sep 6")); + const cta = rendered.getByRole("button", { name: "Renew plan" }); + assert.equal(cta.getAttribute("href"), "/billing"); + assert.equal(rendered.queryByRole("button", { name: "Dismiss" }), null); + }); +}); + +test("a dismissible hint pins a close button that reports the dismissal", async () => { + await renderHint(QUOTA_FULL, async (rendered, dismissals, act) => { + assert.equal(rendered.getByRole("status").dataset.state, "quota-full"); + const close = rendered.getByRole("button", { name: "Dismiss" }); + await act(() => close.click()); + assert.equal(dismissals(), 1); + assert.equal( + rendered.getByRole("button", { name: "View usage" }).getAttribute("href"), + "/billing/usage" + ); + }); +}); + +test("the CTA records the billing return route before navigating", async () => { + await renderHint(QUOTA_FULL, async (rendered, _dismissals, act) => { + window.history.replaceState({}, "", "/project/demo?tab=canvas"); + window.sessionStorage.removeItem("billing-return-route"); + const cta = rendered.getByRole("button", { name: "View usage" }); + await act(() => { + cta.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + }); + assert.equal( + window.sessionStorage.getItem("billing-return-route"), + "/project/demo?tab=canvas" + ); + }); +}); diff --git a/apps/ui/src/features/status-hint/status-hint-banner.tsx b/apps/ui/src/features/status-hint/status-hint-banner.tsx new file mode 100644 index 00000000..07dad25b --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-banner.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { AppButton } from "@workspace/ui/components/app-button"; +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { cn } from "@workspace/ui/lib/utils"; +import { Info, TriangleAlert, X } from "lucide-react"; +import Link from "next/link"; + +import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; + +import type { StatusHint, StatusHintTone } from "./status-hint-model"; +import { useStatusHint } from "./use-status-hint"; + +/** + * The status hint surface (design spec §7): a full-width tinted strip at the + * very top of the content area, in document flow, that explains a holding + * billing state and offers the fix. One tonal recipe across tones — a + * payment-due stage only ever changes its words, never its visuals. + */ + +// Same tint recipe as the Billing Area's subscription warning banner. +const SURFACE_TONES: Record = { + destructive: "bg-red-500/10 text-destructive", + info: "bg-blue-400/10 text-blue-600 dark:text-blue-400", + warning: "bg-amber-400/10 text-amber-600 dark:text-amber-400", +}; + +export function StatusHintBannerView({ + hint, + onDismiss, +}: { + hint: StatusHint; + onDismiss: () => void; +}) { + const Icon = hint.tone === "info" ? Info : TriangleAlert; + return ( +
+ + + + {hint.title} + + + + {hint.description} + + + {hint.cta.label} + + } + size="sm" + variant="secondary" + /> + {hint.dismissible ? ( + + + + ) : null} +
+ ); +} + +/** + * The connected banner. Mounted at the top of every app shell's content + * column (project and Billing Area alike); renders nothing while no state + * holds. The Plan view's own subscription warning stays — it owns "what + * next on this page", the banner owns the global state. + */ +export function StatusHintBanner() { + const { dismiss, hint } = useStatusHint(); + if (hint == null) { + return null; + } + return ( + dismiss(hint.id)} /> + ); +} diff --git a/apps/ui/src/features/status-hint/status-hint-model.test.ts b/apps/ui/src/features/status-hint/status-hint-model.test.ts new file mode 100644 index 00000000..972785ba --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-model.test.ts @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; + +import { + evaluateStatusHints, + reconcileDismissed, + type StatusHintInputs, + selectStatusHint, +} from "./status-hint-model"; + +const NOW = new Date("2026-08-25T12:00:00Z"); + +function subscription( + overrides: Partial +): WorkspaceSubscriptionSummary { + return { + currentPeriodEndAt: "2026-09-12T12:00:00Z", + isActiveFreeTrial: false, + isPayg: false, + lifecycle: "active", + planName: "Hobby", + recoveryVoice: "renew", + role: "OWNER", + warningDeadlineAt: null, + warningStage: null, + ...overrides, + }; +} + +const QUIET: StatusHintInputs = { + availableBalanceMicroUnits: 50_000_000, + now: NOW, + quota: [ + { label: "CPU", percentUsed: 37.5, type: "cpu" }, + { label: "Memory", percentUsed: 75, type: "memory" }, + { label: "Storage", percentUsed: 60, type: "storage" }, + { label: "Ports", percentUsed: 25, type: "nodeport" }, + { label: "Traffic", percentUsed: 100, type: "traffic" }, + ], + subscription: subscription({}), +}; + +const PAYMENT_DUE: Partial = { + currentPeriodEndAt: "2026-08-23T12:00:00Z", + lifecycle: "payment-due", + warningDeadlineAt: "2026-09-06T12:00:00Z", + warningStage: "expired", +}; + +function ids(inputs: StatusHintInputs) { + return evaluateStatusHints(inputs).hints.map((hint) => hint.id); +} + +test("a quiet workspace holds no state and settles every input", () => { + const evaluation = evaluateStatusHints(QUIET); + assert.deepEqual(evaluation.hints, []); + assert.deepEqual(evaluation.settled, [ + "payment-due", + "account-debt", + "quota-full", + "trial-expiry", + ]); +}); + +test("payment-due while suspended ships the settled strings with the derived deletion date", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription(PAYMENT_DUE), + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing", label: "Renew plan" }, + description: + "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.", + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", + }); +}); + +test("payment-due stage progression changes copy and dates only — never the visuals", () => { + const suspended = evaluateStatusHints({ + ...QUIET, + subscription: subscription(PAYMENT_DUE), + }).hints[0]; + const imminent = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + ...PAYMENT_DUE, + currentPeriodEndAt: "2026-08-15T12:00:00Z", + warningDeadlineAt: "2026-08-29T12:00:00Z", + warningStage: "deletion-imminent", + }), + }).hints[0]; + assert.ok(suspended && imminent); + assert.equal(imminent.title, "Workspace suspended — deletion imminent"); + assert.equal( + imminent.description, + "Resources will be permanently deleted on Aug 29. This cannot be undone." + ); + assert.equal(imminent.tone, suspended.tone); + assert.equal(imminent.dismissible, suspended.dismissible); + assert.deepEqual(imminent.cta, suspended.cta); +}); + +test("an expired Free trial never asks for a renewal", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + ...PAYMENT_DUE, + planName: "Free", + recoveryVoice: "resubscribe", + }), + }).hints; + assert.ok(hint); + assert.equal(hint.id, "payment-due"); + assert.deepEqual(hint.cta, { + href: "/billing?mode=upgrade", + label: "Upgrade plan", + }); + assert.equal( + hint.description, + "This workspace is suspended. Resources will be deleted on Sep 6 unless you upgrade to a paid plan." + ); +}); + +test("a missing deadline speaks of deletion without inventing a date", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ ...PAYMENT_DUE, warningDeadlineAt: null }), + }).hints; + assert.ok(hint); + assert.equal( + hint.description, + "This workspace is suspended. Resources will be deleted soon unless the subscription is renewed." + ); +}); + +test("Account Debt lights up from the available balance and never mentions a subscription", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + availableBalanceMicroUnits: 0, + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing", label: "Top up balance" }, + description: + "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.", + dismissible: false, + id: "account-debt", + title: "Account balance in debt", + tone: "destructive", + }); +}); + +test("a PAYG workspace reported in debt is Account Debt, not payment-due", () => { + // The platform reports it as a DEBT status with no subscription and no + // timestamps — CONTEXT.md: never voice it as a subscription expiring. + assert.deepEqual( + ids({ + ...QUIET, + availableBalanceMicroUnits: null, + subscription: subscription({ + isPayg: true, + lifecycle: "payment-due", + planName: "PAYG", + warningStage: "expired", + }), + }), + ["account-debt"] + ); +}); + +test("quota-full names the first full resource and is a warning, not red", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + quota: [ + { label: "CPU", percentUsed: 80, type: "cpu" }, + { label: "Memory", percentUsed: 100, type: "memory" }, + { label: "Storage", percentUsed: 100, type: "storage" }, + ], + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing/usage", label: "View usage" }, + description: + "New deployments can't start until memory is freed or the plan is upgraded.", + dismissible: true, + id: "quota-full", + title: "Memory quota is full", + tone: "warning", + }); +}); + +test("quota-full covers pods", () => { + const [pods] = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Pods", percentUsed: 100, type: "pod" }], + }).hints; + assert.equal(pods?.title, "Pods quota is full"); + assert.equal( + pods?.description, + "New deployments can't start until pods is freed or the plan is upgraded." + ); +}); + +test("quota-full keeps CPU capitalised and ignores traffic", () => { + const [cpu] = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "CPU", percentUsed: 100, type: "cpu" }], + }).hints; + assert.equal(cpu?.title, "CPU quota is full"); + assert.equal( + cpu?.description, + "New deployments can't start until CPU is freed or the plan is upgraded." + ); + assert.deepEqual( + ids({ + ...QUIET, + quota: [{ label: "Traffic", percentUsed: 100, type: "traffic" }], + }), + [] + ); +}); + +test("trial-expiry opens three days before the Free trial ends and counts down", () => { + const trial = (endsAt: string) => + evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + currentPeriodEndAt: endsAt, + isActiveFreeTrial: true, + planName: "Free", + }), + }).hints; + assert.deepEqual(trial("2026-09-04T12:00:00Z"), []); + assert.deepEqual(trial("2026-08-28T12:00:00Z"), [ + { + cta: { href: "/billing?mode=upgrade", label: "View plans" }, + description: + "Your workspace will be suspended when the trial ends on Aug 28. Upgrade to keep it running.", + dismissible: true, + id: "trial-expiry", + title: "Free trial ends in 3 days", + tone: "info", + }, + ]); + assert.equal( + trial("2026-08-26T12:00:00Z")[0]?.title, + "Free trial ends tomorrow" + ); + assert.equal( + trial("2026-08-25T12:30:00Z")[0]?.title, + "Free trial ends today" + ); + // Once expired, section B owns the flow. + assert.deepEqual(trial("2026-08-24T12:00:00Z"), []); +}); + +test("severity orders payment-due > account-debt > quota-full > trial-expiry", () => { + assert.deepEqual( + ids({ + availableBalanceMicroUnits: -1, + now: NOW, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription(PAYMENT_DUE), + }), + ["payment-due", "account-debt", "quota-full"] + ); + assert.deepEqual( + ids({ + availableBalanceMicroUnits: -1, + now: NOW, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription({ + currentPeriodEndAt: "2026-08-27T12:00:00Z", + isActiveFreeTrial: true, + planName: "Free", + }), + }), + ["account-debt", "quota-full", "trial-expiry"] + ); +}); + +test("unknown inputs neither light a state nor settle it", () => { + const evaluation = evaluateStatusHints({ + availableBalanceMicroUnits: null, + now: NOW, + quota: null, + subscription: null, + }); + assert.deepEqual(evaluation.hints, []); + assert.deepEqual(evaluation.settled, []); + // The subscription alone settles the subscription-driven states. + assert.deepEqual( + evaluateStatusHints({ + availableBalanceMicroUnits: null, + now: NOW, + quota: null, + subscription: subscription({}), + }).settled, + ["payment-due", "trial-expiry"] + ); +}); + +test("the single slot shows the most severe undismissed hint", () => { + const { hints } = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription({ + currentPeriodEndAt: "2026-08-27T12:00:00Z", + isActiveFreeTrial: true, + planName: "Free", + }), + }); + assert.equal(selectStatusHint(hints, [])?.id, "quota-full"); + assert.equal(selectStatusHint(hints, ["quota-full"])?.id, "trial-expiry"); + assert.equal(selectStatusHint(hints, ["quota-full", "trial-expiry"]), null); +}); + +test("a suppressed state takes over when the higher one clears", () => { + const withDebt = evaluateStatusHints({ + ...QUIET, + availableBalanceMicroUnits: 0, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + assert.equal(selectStatusHint(withDebt.hints, [])?.id, "account-debt"); + const recovered = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + assert.equal(selectStatusHint(recovered.hints, [])?.id, "quota-full"); +}); + +test("dismissals survive while the state holds and revive on re-entry", () => { + const full = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + const dismissed = ["quota-full", "trial-expiry"] as const; + // Still full: the quota dismissal stands; the trial state is settled + // absent, so its stale dismissal is forgotten. + assert.deepEqual(reconcileDismissed(dismissed, full), ["quota-full"]); + // Freed: the dismissal is forgotten, so the next fill shows the banner. + assert.deepEqual( + reconcileDismissed(["quota-full"], evaluateStatusHints(QUIET)), + [] + ); + // Unknown quota: nothing is decided yet, so the dismissal is kept. + assert.deepEqual( + reconcileDismissed( + ["quota-full"], + evaluateStatusHints({ ...QUIET, quota: null }) + ), + ["quota-full"] + ); + // Reconciling is identity-stable when nothing changes. + const stable = ["quota-full"] as const; + assert.equal(reconcileDismissed(stable, full), stable); +}); diff --git a/apps/ui/src/features/status-hint/status-hint-model.ts b/apps/ui/src/features/status-hint/status-hint-model.ts new file mode 100644 index 00000000..f7d4c03d --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-model.ts @@ -0,0 +1,291 @@ +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; +import type { + BillingQuotaType, + BillingUsageRow, +} from "@/features/billing/billing-usage-data"; + +/** + * The status hint surface's domain model (design spec §7): which billing + * states currently hold, in severity order, and the single-slot and + * dismiss/revive rules the banner applies to them. Pure — the hook feeds it + * already-proxied account, subscription, and quota reads. + */ + +export type StatusHintId = + | "payment-due" + | "account-debt" + | "quota-full" + | "trial-expiry"; + +export type StatusHintTone = "destructive" | "info" | "warning"; + +export interface StatusHint { + cta: { href: string; label: string }; + description: string; + /** Critical, blocking, system-condition hints get no close button. */ + dismissible: boolean; + id: StatusHintId; + title: string; + tone: StatusHintTone; +} + +export type StatusHintQuotaRow = Pick< + BillingUsageRow, + "label" | "percentUsed" | "type" +>; + +/** `null` marks an input that has not answered yet — unknown, not absent. */ +export interface StatusHintInputs { + /** Balance − DeductionBalance + usable credits, the platform's debt formula. */ + availableBalanceMicroUnits: number | null; + now: Date; + quota: readonly StatusHintQuotaRow[] | null; + subscription: WorkspaceSubscriptionSummary | null; +} + +export interface StatusHintEvaluation { + /** Every holding state, most severe first. */ + hints: StatusHint[]; + /** States whose inputs answered — their absence from `hints` is a fact. */ + settled: StatusHintId[]; +} + +/** Severity order: dunning > global suspension > hard stop > heads-up. */ +const SEVERITY: readonly StatusHintId[] = [ + "payment-due", + "account-debt", + "quota-full", + "trial-expiry", +]; + +const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", +}); + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The trial-expiry state opens this many days before the Free trial ends. */ +export const TRIAL_EXPIRY_NOTICE_DAYS = 3; + +/** The quota resources the catalog names (A2); traffic and GPU stay out. */ +const QUOTA_FULL_TYPES: ReadonlySet = new Set([ + "cpu", + "memory", + "storage", + "pod", + "nodeport", +]); + +function parsedDate(iso: string | null): Date | null { + if (iso == null || iso.trim() === "") { + return null; + } + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatDate(date: Date): string { + return DATE_FORMATTER.format(date); +} + +function paymentDueHint( + subscription: WorkspaceSubscriptionSummary +): StatusHint | null { + // A PAYG workspace in DEBT is Account Debt (no subscription, no dates). + if (subscription.isPayg || subscription.lifecycle !== "payment-due") { + return null; + } + // An unpriced Free plan is not a renewal target: its recovery is choosing + // a paid plan, so the CTA opens the Plan Picker and never says "renew". + const resubscribe = subscription.recoveryVoice === "resubscribe"; + const cta = resubscribe + ? { href: "/billing?mode=upgrade", label: "Upgrade plan" } + : { href: "/billing", label: "Renew plan" }; + const deadline = parsedDate(subscription.warningDeadlineAt); + if (subscription.warningStage === "deletion-imminent") { + // Copy advances with the stage; the visuals deliberately do not. + return { + cta, + description: + deadline == null + ? "Resources will be permanently deleted soon. This cannot be undone." + : `Resources will be permanently deleted on ${formatDate(deadline)}. This cannot be undone.`, + dismissible: false, + id: "payment-due", + title: "Workspace suspended — deletion imminent", + tone: "destructive", + }; + } + const when = deadline == null ? "soon" : `on ${formatDate(deadline)}`; + const wayOut = resubscribe + ? "unless you upgrade to a paid plan" + : "unless the subscription is renewed"; + return { + cta, + description: `This workspace is suspended. Resources will be deleted ${when} ${wayOut}.`, + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", + }; +} + +const ACCOUNT_DEBT_HINT: StatusHint = { + cta: { href: "/billing", label: "Top up balance" }, + description: + "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.", + dismissible: false, + id: "account-debt", + title: "Account balance in debt", + tone: "destructive", +}; + +function accountDebtHolds(inputs: StatusHintInputs): boolean | null { + // The platform treats only a strictly positive available amount as good + // standing; a PAYG workspace it already reports in DEBT is the same fact. + if ( + inputs.subscription?.isPayg && + inputs.subscription.lifecycle === "payment-due" + ) { + return true; + } + if (inputs.availableBalanceMicroUnits == null) { + return null; + } + return inputs.availableBalanceMicroUnits <= 0; +} + +/** "CPU" keeps its initialism mid-sentence; the rest read as common nouns. */ +function resourceNoun(label: string): string { + return label === label.toUpperCase() ? label : label.toLowerCase(); +} + +function quotaFullHint( + quota: readonly StatusHintQuotaRow[] +): StatusHint | null { + const full = quota.find( + (row) => QUOTA_FULL_TYPES.has(row.type) && row.percentUsed >= 100 + ); + if (full == null) { + return null; + } + return { + cta: { href: "/billing/usage", label: "View usage" }, + description: `New deployments can't start until ${resourceNoun(full.label)} is freed or the plan is upgraded.`, + dismissible: true, + id: "quota-full", + title: `${full.label} quota is full`, + tone: "warning", + }; +} + +/** Whole calendar days from `now` to `target` in the viewer's time zone. */ +function calendarDaysUntil(target: Date, now: Date): number { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date( + target.getFullYear(), + target.getMonth(), + target.getDate() + ); + return Math.round((end.getTime() - start.getTime()) / DAY_MS); +} + +function trialEndsIn(days: number): string { + if (days <= 0) { + return "today"; + } + if (days === 1) { + return "tomorrow"; + } + return `in ${days} days`; +} + +function trialExpiryHint( + subscription: WorkspaceSubscriptionSummary, + now: Date +): StatusHint | null { + if (!subscription.isActiveFreeTrial) { + return null; + } + const endsAt = parsedDate(subscription.currentPeriodEndAt); + if (endsAt == null) { + return null; + } + // Once expired, the payment-due pipeline owns the flow. + if (endsAt.getTime() < now.getTime()) { + return null; + } + const days = calendarDaysUntil(endsAt, now); + if (days > TRIAL_EXPIRY_NOTICE_DAYS) { + return null; + } + return { + cta: { href: "/billing?mode=upgrade", label: "View plans" }, + description: `Your workspace will be suspended when the trial ends on ${formatDate(endsAt)}. Upgrade to keep it running.`, + dismissible: true, + id: "trial-expiry", + title: `Free trial ends ${trialEndsIn(days)}`, + tone: "info", + }; +} + +export function evaluateStatusHints( + inputs: StatusHintInputs +): StatusHintEvaluation { + const { subscription } = inputs; + const debt = accountDebtHolds(inputs); + let accountDebt: StatusHint | null | undefined; + if (debt != null) { + accountDebt = debt ? ACCOUNT_DEBT_HINT : null; + } + const outcomes: Record = { + "account-debt": accountDebt, + "payment-due": + subscription == null ? undefined : paymentDueHint(subscription), + "quota-full": + inputs.quota == null ? undefined : quotaFullHint(inputs.quota), + "trial-expiry": + subscription == null + ? undefined + : trialExpiryHint(subscription, inputs.now), + }; + const hints: StatusHint[] = []; + const settled: StatusHintId[] = []; + for (const id of SEVERITY) { + const outcome = outcomes[id]; + if (outcome === undefined) { + continue; + } + settled.push(id); + if (outcome != null) { + hints.push(outcome); + } + } + return { hints, settled }; +} + +/** The one hint the single slot shows, skipping user-dismissed ones. */ +export function selectStatusHint( + hints: readonly StatusHint[], + dismissed: readonly StatusHintId[] +): StatusHint | null { + return hints.find((hint) => !dismissed.includes(hint.id)) ?? null; +} + +/** + * Edge semantics for dismissals: a dismissal stands while its state holds + * (or is still unknown) and is forgotten once the state is settled absent, + * so the banner revives when the state re-enters. Returns the same array + * when nothing changes, so stores can skip a write. + */ +export function reconcileDismissed( + dismissed: readonly StatusHintId[], + evaluation: StatusHintEvaluation +): readonly StatusHintId[] { + const holding = new Set(evaluation.hints.map((hint) => hint.id)); + const next = dismissed.filter( + (id) => holding.has(id) || !evaluation.settled.includes(id) + ); + return next.length === dismissed.length ? dismissed : next; +} diff --git a/apps/ui/src/features/status-hint/status-hint-store.ts b/apps/ui/src/features/status-hint/status-hint-store.ts new file mode 100644 index 00000000..3ce789db --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-store.ts @@ -0,0 +1,14 @@ +import { atomWithStorage, createJSONStorage } from "jotai/utils"; + +import type { StatusHintId } from "./status-hint-model"; + +/** + * Ids of dismissible hints the user closed. Session-scoped so a reload keeps + * a dismissal, while `reconcileDismissed` forgets it once its state is + * settled absent — re-entry revives the banner (edge semantics). + */ +export const statusHintDismissedAtom = atomWithStorage( + "status-hint-dismissed", + [], + createJSONStorage(() => sessionStorage) +); diff --git a/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts new file mode 100644 index 00000000..9de72432 --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance"; +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { loadWorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; +import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data"; +import { + BILLING_DEV_SCENARIOS, + type BillingDevScenario, +} from "@/features/billing/dev-mock-cookie"; +import { scenarioTestFetch } from "@/features/billing/server/dev-fixtures/scenario-test-fetch"; + +import { + evaluateStatusHints, + type StatusHintId, + selectStatusHint, +} from "./status-hint-model"; + +/** + * Pins the banner states to the dev fixtures through the same loaders the + * hook uses, so every state the design catalogs can be simulated locally + * and the fixtures keep meaning what their names promise. + */ + +const DATED_DELETION_PATTERN = /deleted on [A-Z][a-z]{2} \d/; +const RENEWAL_PATTERN = /renew/i; + +const CREDENTIALS = { + appToken: "test-token", + kubeconfig: "test-kubeconfig", + workspace: "ns-test", +}; + +async function hintFor(scenario: BillingDevScenario) { + const fetch = scenarioTestFetch(scenario); + const [subscription, balance, credits, quota] = await Promise.all([ + loadWorkspaceSubscriptionSummary(CREDENTIALS, { fetch }), + loadAccountBalanceMicroUnits(CREDENTIALS, fetch), + loadAccountCredits(CREDENTIALS, fetch), + loadWorkspaceQuotaUsage(CREDENTIALS, fetch), + ]); + const evaluation = evaluateStatusHints({ + availableBalanceMicroUnits: balance + credits.usableMicroUnits, + now: new Date(), + quota, + subscription, + }); + assert.deepEqual( + evaluation.settled, + ["payment-due", "account-debt", "quota-full", "trial-expiry"], + `${scenario}: every input answers` + ); + return { evaluation, hint: selectStatusHint(evaluation.hints, []) }; +} + +const EXPECTED: Record = { + active: null, + "active-balance": null, + cancelling: null, + deleted: null, + free: null, + "free-expired": "payment-due", + "free-expiring": "trial-expiry", + "mixed-workspaces": null, + paused: null, + payg: null, + "payg-debt": "account-debt", + "payg-debt-deletion": "account-debt", + "payg-debt-final": "account-debt", + "payment-due": "payment-due", + "payment-due-deletion": "payment-due", + "payment-due-final": "payment-due", + "pending-upgrade": null, + "quota-full": "quota-full", + "status-unknown": null, +}; + +test("every scenario lands on the banner state its name promises", async () => { + for (const scenario of BILLING_DEV_SCENARIOS) { + const { hint } = await hintFor(scenario); + assert.equal(hint?.id ?? null, EXPECTED[scenario], `${scenario}: slot`); + } +}); + +test("the payment-due scenarios walk the stages with identical visuals", async () => { + const suspended = (await hintFor("payment-due")).hint; + const deletion = (await hintFor("payment-due-deletion")).hint; + const final = (await hintFor("payment-due-final")).hint; + assert.equal(suspended?.title, "Workspace suspended — payment due"); + assert.match(suspended?.description ?? "", DATED_DELETION_PATTERN); + assert.equal(deletion?.title, "Workspace suspended — deletion imminent"); + assert.equal(final?.title, "Workspace suspended — deletion imminent"); + for (const stage of [deletion, final]) { + assert.equal(stage?.tone, suspended?.tone); + assert.equal(stage?.dismissible, false); + assert.deepEqual(stage?.cta, suspended?.cta); + } + // Account Debt is suppressed behind payment-due here and takes over once + // the subscription recovers; the slot is one banner regardless. + const { evaluation } = await hintFor("payment-due"); + assert.deepEqual( + evaluation.hints.map((hint) => hint.id), + ["payment-due", "account-debt"] + ); +}); + +test("an expired Free trial asks for an upgrade, never a renewal", async () => { + const { hint } = await hintFor("free-expired"); + assert.deepEqual(hint?.cta, { + href: "/billing?mode=upgrade", + label: "Upgrade plan", + }); + assert.doesNotMatch(hint?.description ?? "", RENEWAL_PATTERN); +}); + +test("quota-full names storage and stays a warning", async () => { + const { hint } = await hintFor("quota-full"); + assert.equal(hint?.title, "Storage quota is full"); + assert.equal(hint?.tone, "warning"); + assert.equal(hint?.dismissible, true); +}); + +test("free-expiring sits inside the three-day trial notice", async () => { + const { hint } = await hintFor("free-expiring"); + assert.equal(hint?.title, "Free trial ends in 3 days"); + assert.equal(hint?.tone, "info"); + assert.equal(hint?.cta.href, "/billing?mode=upgrade"); +}); diff --git a/apps/ui/src/features/status-hint/use-status-hint.ts b/apps/ui/src/features/status-hint/use-status-hint.ts new file mode 100644 index 00000000..8a1eea4c --- /dev/null +++ b/apps/ui/src/features/status-hint/use-status-hint.ts @@ -0,0 +1,128 @@ +"use client"; + +import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; + +import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance"; +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { accountCreditsSwrKey } from "@/features/billing/billing-subscription-settlement"; +import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; +import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +import { + evaluateStatusHints, + reconcileDismissed, + type StatusHint, + type StatusHintId, + selectStatusHint, +} from "./status-hint-model"; +import { statusHintDismissedAtom } from "./status-hint-store"; + +/** States clear on their own; the inputs follow the inbox's polling cadence. */ +export const STATUS_HINT_REFRESH_INTERVAL_MS = 5 * 60_000; + +export interface StatusHintSlot { + dismiss: (id: StatusHintId) => void; + hint: StatusHint | null; +} + +/** + * The single slot's contents, evaluated from already-proxied account, + * subscription, and quota reads: the subscription summary the App Sidebar + * already holds, the account's cash and usable credits (the platform's debt + * formula), and the workspace's resource quota. A read that has not + * answered leaves its state unknown — never lit, never cleared. + */ +export function useStatusHint(): StatusHintSlot { + const appToken = useAtomValue(appTokenAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const workspace = useAtomValue(namespaceAtom).trim(); + const credentialsReady = + appToken !== "" && kubeconfig !== "" && workspace !== ""; + const credentialKey = kubeconfigCredentialKey(kubeconfig); + + const subscription = useWorkspaceSubscriptionSummary({ + refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS, + }); + const swrOptions = { + refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS, + revalidateOnFocus: false, + shouldRetryOnError: false, + }; + const balance = useSWR( + credentialsReady + ? (["status-hint-balance", credentialKey, appToken] as const) + : null, + () => loadAccountBalanceMicroUnits({ appToken, kubeconfig }), + swrOptions + ); + // The settlement flow refreshes this key after a payment, so a top-up + // that lands through Brain clears Account Debt without waiting a cycle. + const credits = useSWR( + credentialsReady ? accountCreditsSwrKey({ appToken, kubeconfig }) : null, + () => loadAccountCredits({ appToken, kubeconfig }), + swrOptions + ); + const quota = useSWR( + credentialsReady + ? (["status-hint-quota", workspace, credentialKey, appToken] as const) + : null, + () => loadWorkspaceQuotaUsage({ appToken, kubeconfig, workspace }), + swrOptions + ); + + // Trial-expiry is a clock state as much as a data state: the window opens + // and the title counts down while the subscription payload stays the same, + // so the clock advances on the polling cadence too. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const timer = window.setInterval(() => { + setNow(new Date()); + }, STATUS_HINT_REFRESH_INTERVAL_MS); + return () => { + window.clearInterval(timer); + }; + }, []); + + const balanceMicroUnits = balance.data; + const usableCreditMicroUnits = credits.data?.usableMicroUnits; + const quotaRows = quota.data; + const subscriptionSummary = subscription.data; + const evaluation = useMemo( + () => + evaluateStatusHints({ + availableBalanceMicroUnits: + balanceMicroUnits == null || usableCreditMicroUnits == null + ? null + : balanceMicroUnits + usableCreditMicroUnits, + now, + quota: quotaRows ?? null, + subscription: subscriptionSummary ?? null, + }), + [ + balanceMicroUnits, + now, + quotaRows, + subscriptionSummary, + usableCreditMicroUnits, + ] + ); + + const [dismissed, setDismissed] = useAtom(statusHintDismissedAtom); + useEffect(() => { + setDismissed((previous) => reconcileDismissed(previous, evaluation)); + }, [evaluation, setDismissed]); + const dismiss = useCallback( + (id: StatusHintId) => { + setDismissed((previous) => + previous.includes(id) ? previous : [...previous, id] + ); + }, + [setDismissed] + ); + + return { dismiss, hint: selectStatusHint(evaluation.hints, dismissed) }; +} From 9fcd315d8426757109490b392121c202192ee426 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 01:04:33 +0800 Subject: [PATCH 06/14] fix(notifications): version CR ids by generation when spec lacks a timestamp; code-review cleanups - The Go read proxy exposes `version` (spec.timestamp, else metadata.generation, else creationTimestamp) and the client keys `cr:` ids by it: an in-place overwrite of a fixed-name CR is a new id no receipt covers, while a label patch never changes it. `timestamp` keeps its display/sort fallback. - Pin the subscription-change rule: an upgrade over a still-scheduled downgrade is the next receipt; the superseded downgrade stays as history and its transaction never re-writes. - Share what was duplicated: `lib/time` (DAY_MS/HOUR_MS/MINUTE_MS), the billing surface tint recipe, `MICRO_UNITS_PER_CURRENCY_UNIT`, and the `cr:`/`db:` id helpers in `notification-ids`. - One authorize+parse seam for the body-carrying notification handlers; one JSON post helper in the client. - `notificationCRItemSchema` is pinned to `NotificationCRItem`; `StatusHint.cta` is a `NotificationCTA`. - Sidebar CTA renders through `AppButton`; the subscription-summary SWR key uses `kubeconfigCredentialKey` like its siblings. Co-Authored-By: Claude Fable 5 --- apps/api/service/notification/notification.go | 12 ++++ .../service/notification/notification_test.go | 20 ++++-- .../ui/src/features/billing/billing-amount.ts | 3 +- .../features/billing/billing-plan-surface.tsx | 7 +- .../features/billing/billing-surface-tones.ts | 13 ++++ .../server/dev-fixtures/notifications.ts | 5 +- apps/ui/src/features/notifications/client.ts | 27 +++---- .../features/notifications/feed-model.test.ts | 8 ++- .../src/features/notifications/feed-model.ts | 29 ++------ .../features/notifications/http-handlers.ts | 72 +++++++++++-------- .../notifications/notification-ids.ts | 24 +++++++ .../notifications/notification-time.ts | 4 +- .../producer-subscription-change.test.ts | 45 ++++++++++++ .../producer-subscription-change.ts | 4 +- apps/ui/src/features/notifications/store.ts | 3 +- apps/ui/src/features/notifications/types.ts | 11 +-- .../shell/app-sidebar-notifications.tsx | 33 +++++---- .../use-workspace-subscription-summary.ts | 8 ++- .../status-hint/status-hint-banner.tsx | 12 +--- .../features/status-hint/status-hint-model.ts | 10 +-- apps/ui/src/lib/time.ts | 4 ++ .../api/src/hooks/use-notification-crs.ts | 8 ++- 22 files changed, 236 insertions(+), 126 deletions(-) create mode 100644 apps/ui/src/features/billing/billing-surface-tones.ts create mode 100644 apps/ui/src/lib/time.ts diff --git a/apps/api/service/notification/notification.go b/apps/api/service/notification/notification.go index 32983e67..d43af868 100644 --- a/apps/api/service/notification/notification.go +++ b/apps/api/service/notification/notification.go @@ -51,6 +51,7 @@ type Item struct { Importance string `json:"importance,omitempty" doc:"High, Medium, or Low"` DesktopPopup bool `json:"desktopPopup"` Timestamp int64 `json:"timestamp" doc:"spec.timestamp (Unix seconds); creationTimestamp when upstream omitted it"` + Version int64 `json:"version" doc:"The id's version component: spec.timestamp, or metadata.generation when upstream omitted it. An in-place overwrite changes it; a label patch never does."` CreationTimestamp string `json:"creationTimestamp,omitempty"` } @@ -182,12 +183,23 @@ func itemFromUnstructured(obj *unstructured.Unstructured, now time.Time) (Item, DesktopPopup: boolField(spec, "desktopPopup"), Timestamp: int64Field(spec, "timestamp"), } + // The id is versioned so an upstream overwrite of a fixed-name CR reads + // as a new message: spec.timestamp when the writer set one, else the + // object's generation — creationTimestamp would survive the overwrite and + // let an old receipt hide the revived message. + item.Version = item.Timestamp + if item.Version == 0 { + item.Version = obj.GetGeneration() + } created := obj.GetCreationTimestamp() if !created.IsZero() { item.CreationTimestamp = created.UTC().Format(time.RFC3339) if item.Timestamp == 0 { item.Timestamp = created.Unix() } + if item.Version == 0 { + item.Version = created.Unix() + } } return item, true } diff --git a/apps/api/service/notification/notification_test.go b/apps/api/service/notification/notification_test.go index 07a80d65..559ba3be 100644 --- a/apps/api/service/notification/notification_test.go +++ b/apps/api/service/notification/notification_test.go @@ -96,15 +96,18 @@ func TestListWithClientFlattensAndOrdersNewestFirst(t *testing.T) { if second.CreationTimestamp != "2026-08-20T10:00:00Z" { t.Fatalf("expected creationTimestamp to pass through, got %q", second.CreationTimestamp) } + if second.Version != 1756200000 { + t.Fatalf("expected the id version to be spec.timestamp, got %d", second.Version) + } } func TestListWithClientFallsBackToCreationTimestampAndTreatsMissingLabelAsUnread(t *testing.T) { - client := testClient( - testNotification("announcement", "ns-a", "", map[string]interface{}{ - "title": "Welcome", - "message": "Hello", - }), - ) + announcement := testNotification("announcement", "ns-a", "", map[string]interface{}{ + "title": "Welcome", + "message": "Hello", + }) + announcement.SetGeneration(3) + client := testClient(announcement) got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) if err != nil { @@ -121,6 +124,11 @@ func TestListWithClientFallsBackToCreationTimestampAndTreatsMissingLabelAsUnread if item.Timestamp != wantCreated { t.Fatalf("expected timestamp to fall back to creationTimestamp %d, got %d", wantCreated, item.Timestamp) } + // The display time may fall back to creation, but the id must not: an + // in-place overwrite keeps creationTimestamp and bumps generation. + if item.Version != 3 { + t.Fatalf("expected the id version to fall back to metadata.generation 3, got %d", item.Version) + } } func TestListWithClientHonoursStartAndEndWindows(t *testing.T) { diff --git a/apps/ui/src/features/billing/billing-amount.ts b/apps/ui/src/features/billing/billing-amount.ts index fe605cad..fefb3ad1 100644 --- a/apps/ui/src/features/billing/billing-amount.ts +++ b/apps/ui/src/features/billing/billing-amount.ts @@ -1,6 +1,7 @@ import type { BillingCurrency } from "@/features/billing/config-core"; -const MICRO_UNITS_PER_CURRENCY_UNIT = 1_000_000; +/** Upstream money is carried in micro-units: 1,000,000 per dollar (or yuan). */ +export const MICRO_UNITS_PER_CURRENCY_UNIT = 1_000_000; const USD_FORMATTER = new Intl.NumberFormat("en-US", { currency: "USD", currencyDisplay: "narrowSymbol", diff --git a/apps/ui/src/features/billing/billing-plan-surface.tsx b/apps/ui/src/features/billing/billing-plan-surface.tsx index 1496b200..7c4a72ee 100644 --- a/apps/ui/src/features/billing/billing-plan-surface.tsx +++ b/apps/ui/src/features/billing/billing-plan-surface.tsx @@ -51,6 +51,7 @@ import { type SubscriptionWarningStage, subscriptionLifecycleAllowsBillingActions, } from "@/features/billing/billing-plan-data"; +import { BILLING_SURFACE_TONES } from "@/features/billing/billing-surface-tones"; import type { BillingCurrency } from "@/features/billing/config-core"; import { useSealosDesktopUrl } from "@/lib/sealos-desktop-url"; @@ -280,9 +281,9 @@ const ACCOUNT_DEBT_WARNING_COPY: Partial< // destructive (red). The semantic color stays on the icon and title only — // the description keeps its muted default. const SUBSCRIPTION_WARNING_TONES: Record = { - cancelling: "bg-amber-400/10 text-amber-600 dark:text-amber-400", - "deletion-imminent": "bg-red-500/10 text-destructive", - expired: "bg-red-500/10 text-destructive", + cancelling: BILLING_SURFACE_TONES.warning, + "deletion-imminent": BILLING_SURFACE_TONES.destructive, + expired: BILLING_SURFACE_TONES.destructive, }; function SubscriptionWarningBanner({ diff --git a/apps/ui/src/features/billing/billing-surface-tones.ts b/apps/ui/src/features/billing/billing-surface-tones.ts new file mode 100644 index 00000000..c05843cf --- /dev/null +++ b/apps/ui/src/features/billing/billing-surface-tones.ts @@ -0,0 +1,13 @@ +/** + * The one tint recipe billing state surfaces share — the Plan view's + * subscription warning and the status hint banner alike: a soft background + * wash with the semantic color on icon and title only, so the description + * keeps its muted default. + */ +export type BillingSurfaceTone = "destructive" | "info" | "warning"; + +export const BILLING_SURFACE_TONES: Record = { + destructive: "bg-red-500/10 text-destructive", + info: "bg-blue-400/10 text-blue-600 dark:text-blue-400", + warning: "bg-amber-400/10 text-amber-600 dark:text-amber-400", +}; diff --git a/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts index 831aeead..b75f8568 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts @@ -1,5 +1,4 @@ import type { NotificationCRItem } from "@workspace/api/hooks"; - import type { BillingDevScenario } from "@/features/billing/dev-mock-cookie"; import { markNotificationReadRequestSchema, @@ -7,6 +6,7 @@ import { type NotificationMessage, type NotificationPayload, } from "@/features/notifications/types"; +import { DAY_MS, HOUR_MS } from "@/lib/time"; import { billingDevMockWorkspace, resolveBillingDevMock } from "./index"; @@ -25,8 +25,6 @@ import { billingDevMockWorkspace, resolveBillingDevMock } from "./index"; /** Anchored at module load so fixture ids stay stable across polls. */ const FIXTURE_NOW_MS = Date.now(); -const DAY_MS = 24 * 60 * 60 * 1000; -const HOUR_MS = 60 * 60 * 1000; const DEBT_SYSTEM = "Debt-System"; const SUBSCRIPTION_SYSTEM = "Workspace-Subscription-System"; @@ -314,6 +312,7 @@ function platformItem( namespace: workspace, timestamp: Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000), title: fixture.title, + version: Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000), }; } diff --git a/apps/ui/src/features/notifications/client.ts b/apps/ui/src/features/notifications/client.ts index 7d06aa02..8019afae 100644 --- a/apps/ui/src/features/notifications/client.ts +++ b/apps/ui/src/features/notifications/client.ts @@ -44,23 +44,16 @@ export async function postNotificationReadReceipts( if (ids.length === 0) { return; } - const response = await fetchImpl( - notificationsUrl("/read", credentials.namespace), - { - body: JSON.stringify({ ids }), - headers: { - ...personalResourceAuthHeaders(credentials), - "content-type": "application/json", - }, - method: "POST", - } + await postNotificationJson( + "/read", + credentials, + { ids }, + "Mark-read", + fetchImpl ); - if (!response.ok) { - throw new Error(`Mark-read request failed (${response.status})`); - } } -async function postObservation( +async function postNotificationJson( pathname: string, credentials: NotificationClientCredentials, body: unknown, @@ -92,7 +85,7 @@ export function reportWorkspaceQuotaObservation( snapshot: WorkspaceResourceQuotaSnapshot, fetchImpl: typeof fetch = globalThis.fetch ): Promise { - return postObservation( + return postNotificationJson( "/quota-observation", credentials, { quota: snapshot }, @@ -107,7 +100,7 @@ export function reportGiftCreditObservation( observation: GiftObservationRequest, fetchImpl: typeof fetch = globalThis.fetch ): Promise { - return postObservation( + return postNotificationJson( "/gift-observation", credentials, observation, @@ -122,7 +115,7 @@ export function reportSubscriptionChangeObservation( observation: SubscriptionChangeObservationRequest, fetchImpl: typeof fetch = globalThis.fetch ): Promise { - return postObservation( + return postNotificationJson( "/subscription-change", credentials, observation, diff --git a/apps/ui/src/features/notifications/feed-model.test.ts b/apps/ui/src/features/notifications/feed-model.test.ts index c7d3c3be..52185494 100644 --- a/apps/ui/src/features/notifications/feed-model.test.ts +++ b/apps/ui/src/features/notifications/feed-model.test.ts @@ -4,13 +4,12 @@ import { test } from "node:test"; import type { NotificationCRItem } from "@workspace/api/hooks"; import { - crNotificationId, isGiftOnlyNewcomer, mergeNotificationFeed, notificationKindForCR, - notificationSource, renderNotificationMessage, } from "./feed-model"; +import { crNotificationId, notificationSource } from "./notification-ids"; import { formatNotificationDate } from "./notification-time"; import type { NotificationMessage } from "./types"; @@ -18,6 +17,8 @@ const T0 = 1_756_200_000; // Unix seconds const QUOTA_BODY_RE = /New deployments can't start/; function cr(overrides: Partial): NotificationCRItem { + // As upstream writes them: the version is the spec timestamp. + const timestamp = overrides.timestamp ?? T0; return { desktopPopup: true, from: "Debt-System", @@ -26,8 +27,9 @@ function cr(overrides: Partial): NotificationCRItem { message: "Your account balance is exhausted; services are suspended.", name: "debt-choice-debtperiod", namespace: "ns-a", - timestamp: T0, + timestamp, title: "Balance exhausted", + version: timestamp, ...overrides, }; } diff --git a/apps/ui/src/features/notifications/feed-model.ts b/apps/ui/src/features/notifications/feed-model.ts index 73b1e4f5..d728ec88 100644 --- a/apps/ui/src/features/notifications/feed-model.ts +++ b/apps/ui/src/features/notifications/feed-model.ts @@ -1,14 +1,14 @@ import type { NotificationCRItem } from "@workspace/api/hooks"; +import { MICRO_UNITS_PER_CURRENCY_UNIT } from "@/features/billing/billing-amount"; import type { AppNotification, AppNotificationKind, - AppNotificationSource, NotificationCTA, } from "@/features/shell/app-sidebar-notifications-model"; import { CR_OVERRIDES, GIFT_FILTERED_CR_NAMES } from "./cr-overrides"; -import { dbNotificationId } from "./notification-ids"; +import { crNotificationId, dbNotificationId } from "./notification-ids"; import { formatNotificationDate } from "./notification-time"; import type { NotificationMessage, @@ -24,25 +24,6 @@ import type { * fixture CRs and rows pin contents, order, and unread here. */ -/** - * A platform notification's id is versioned by the CR's own timestamp: the - * platform overwrites fixed-name CRs in place on escalation, and the newer - * timestamp makes the revived message a new id that no old receipt covers. - */ -export function crNotificationId(name: string, timestamp: number): string { - return `cr:${name}:${timestamp}`; -} - -export function notificationSource(id: string): AppNotificationSource | null { - if (id.startsWith("cr:")) { - return "cr"; - } - if (id.startsWith("db:")) { - return "db"; - } - return null; -} - /** Upstream `spec.from` values written by the platform's billing controllers. */ const BILLING_SENDERS: ReadonlySet = new Set([ "Debt-System", @@ -77,15 +58,13 @@ interface RenderedNotification { title: string; } -const MICRO_UNITS_PER_DOLLAR = 1_000_000; - /** * The gift's nominal whole-dollar amount (design spec §10 rule 5): the gift * is granted in whole dollars and only burns down, so rounding the remainder * up recovers the grant a newcomer was told about. */ function wholeDollars(microUnits: number): string { - return `$${Math.max(1, Math.ceil(microUnits / MICRO_UNITS_PER_DOLLAR))}`; + return `$${Math.max(1, Math.ceil(microUnits / MICRO_UNITS_PER_CURRENCY_UNIT))}`; } const SUBSCRIPTION_CHANGE_TITLES: Record = { @@ -214,7 +193,7 @@ export function platformNotification( item: NotificationCRItem, receipts: ReadonlySet ): AppNotification { - const id = crNotificationId(item.name, item.timestamp); + const id = crNotificationId(item.name, item.version); const override = CR_OVERRIDES[item.name]; return { body: override?.body ?? item.message, diff --git a/apps/ui/src/features/notifications/http-handlers.ts b/apps/ui/src/features/notifications/http-handlers.ts index 095427d1..7da83165 100644 --- a/apps/ui/src/features/notifications/http-handlers.ts +++ b/apps/ui/src/features/notifications/http-handlers.ts @@ -78,6 +78,30 @@ export function createNotificationHandlers( : result; }; + /** Verify the actor, then parse the JSON body; a bad body is a 400. */ + const authorizeBody = async ( + request: Request, + schema: z.ZodType, + message: string + ): Promise< + | { ok: true; actor: VerifiedPersonalResourceActor; input: T } + | { ok: false; response: Response } + > => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization; + } + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return { + ok: false, + response: jsonError({ code: "invalid_request", message, status: 400 }), + }; + } + return { actor: authorization.actor, input: parsed.data, ok: true }; + }; + /** * Every producer's observation point has one shape: verify the actor, * parse the body, hand the verified namespace (and user) to the producer, @@ -95,22 +119,17 @@ export function createNotificationHandlers( unavailableMessage: string; }) => async (request: Request): Promise => { - const authorization = await authorize(request); - if (!authorization.ok) { - return authorization.response; - } - const body = await request.json().catch(() => null); - const parsed = config.schema.safeParse(body); - if (!parsed.success) { - return jsonError({ - code: "invalid_request", - message: config.message, - status: 400, - }); + const authorized = await authorizeBody( + request, + config.schema, + config.message + ); + if (!authorized.ok) { + return authorized.response; } try { return Response.json( - await config.produce(authorization.actor, parsed.data) + await config.produce(authorized.actor, authorized.input) ); } catch { return unavailable(config.route, config.unavailableMessage); @@ -136,25 +155,18 @@ export function createNotificationHandlers( } }, markRead: async (request: Request): Promise => { - const authorization = await authorize(request); - if (!authorization.ok) { - return authorization.response; - } - const body = await request.json().catch(() => null); - const parsed = markNotificationReadRequestSchema.safeParse(body); - if (!parsed.success) { - return jsonError({ - code: "invalid_request", - message: "Invalid mark-read request.", - status: 400, - }); + const authorized = await authorizeBody( + request, + markNotificationReadRequestSchema, + "Invalid mark-read request." + ); + if (!authorized.ok) { + return authorized.response; } + const { ids } = authorized.input; try { - await dependencies.store.markRead( - readerOf(authorization.actor), - parsed.data.ids - ); - return Response.json({ read: parsed.data.ids }); + await dependencies.store.markRead(readerOf(authorized.actor), ids); + return Response.json({ read: ids }); } catch (error) { const superseded = supersededBindingResponse(error); if (superseded != null) { diff --git a/apps/ui/src/features/notifications/notification-ids.ts b/apps/ui/src/features/notifications/notification-ids.ts index 5ebb0c36..3e5860bc 100644 --- a/apps/ui/src/features/notifications/notification-ids.ts +++ b/apps/ui/src/features/notifications/notification-ids.ts @@ -1,6 +1,30 @@ +import type { AppNotificationSource } from "@/features/shell/app-sidebar-notifications-model"; + +/** The `cr:` prefix every platform notification id carries. */ +export const CR_NOTIFICATION_ID_PREFIX = "cr:"; /** The `db:` prefix every Brain-produced notification id carries. */ export const DB_NOTIFICATION_ID_PREFIX = "db:"; +/** + * A platform notification's id is versioned by the CR's own version (its + * `spec.timestamp`, or its generation when upstream wrote none): the + * platform overwrites fixed-name CRs in place on escalation, and the newer + * version makes the revived message a new id that no old receipt covers. + */ +export function crNotificationId(name: string, version: number): string { + return `${CR_NOTIFICATION_ID_PREFIX}${name}:${version}`; +} + export function dbNotificationId(messageId: string): string { return `${DB_NOTIFICATION_ID_PREFIX}${messageId}`; } + +export function notificationSource(id: string): AppNotificationSource | null { + if (id.startsWith(CR_NOTIFICATION_ID_PREFIX)) { + return "cr"; + } + if (id.startsWith(DB_NOTIFICATION_ID_PREFIX)) { + return "db"; + } + return null; +} diff --git a/apps/ui/src/features/notifications/notification-time.ts b/apps/ui/src/features/notifications/notification-time.ts index bd0142cc..7eb7c709 100644 --- a/apps/ui/src/features/notifications/notification-time.ts +++ b/apps/ui/src/features/notifications/notification-time.ts @@ -1,6 +1,4 @@ -const MINUTE_MS = 60_000; -const HOUR_MS = 60 * MINUTE_MS; -const DAY_MS = 24 * HOUR_MS; +import { DAY_MS, HOUR_MS, MINUTE_MS } from "@/lib/time"; /** * Relative time for a Notification row, from a real timestamp (epoch ms): diff --git a/apps/ui/src/features/notifications/producer-subscription-change.test.ts b/apps/ui/src/features/notifications/producer-subscription-change.test.ts index 6a9897ce..be4c8758 100644 --- a/apps/ui/src/features/notifications/producer-subscription-change.test.ts +++ b/apps/ui/src/features/notifications/producer-subscription-change.test.ts @@ -84,6 +84,51 @@ test("one receipt per successful change; the same transaction observed twice wri ); }); +test("an upgrade over a scheduled downgrade is the next receipt; the superseded downgrade stays as history and never re-writes", async () => { + const scheduled = await observeSubscriptionChangeForNotifications(store, { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + namespace: "ns-seq", + now: NOW, + planName: "Hobby", + transactionId: "txn-down", + }); + const upgrade = await observeSubscriptionChangeForNotifications(store, { + change: "upgraded", + namespace: "ns-seq", + now: new Date(NOW.getTime() + 60_000), + planName: "Pro", + transactionId: "txn-up", + }); + // The platform later settles the same downgrade transaction: same id, no + // second entry. + const settled = await observeSubscriptionChangeForNotifications(store, { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + namespace: "ns-seq", + now: new Date(NOW.getTime() + 120_000), + planName: "Hobby", + transactionId: "txn-down", + }); + + assert.deepEqual(scheduled, { produced: true }); + assert.deepEqual(upgrade, { produced: true }); + assert.deepEqual(settled, { produced: false }); + const messages = await store.listMessages("ns-seq"); + assert.deepEqual( + messages.map((message) => message.payload), + [ + { change: "upgraded", kind: "subscription-change", planName: "Pro" }, + { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + kind: "subscription-change", + planName: "Hobby", + }, + ] + ); +}); + test("the same transaction id in another workspace is another receipt; blanks write nothing", async () => { assert.deepEqual( await observeSubscriptionChangeForNotifications(store, { diff --git a/apps/ui/src/features/notifications/producer-subscription-change.ts b/apps/ui/src/features/notifications/producer-subscription-change.ts index 1ffb247d..48bfb4e1 100644 --- a/apps/ui/src/features/notifications/producer-subscription-change.ts +++ b/apps/ui/src/features/notifications/producer-subscription-change.ts @@ -7,7 +7,9 @@ import type { SubscriptionChange } from "./types"; * transaction observation point. The platform's transaction id is the * identity — the same change observed again (a checkout poll and the Stripe * return leg both settle the same payment) writes nothing. Never released: - * a receipt records history, it has no recovery. + * a receipt records history, it has no recovery — an upgrade over a still + * scheduled downgrade is simply the next receipt, and the superseded + * downgrade's entry stays as the record of what the user did. */ export const SUBSCRIPTION_CHANGE_DEDUPE_PREFIX = "subscription-change"; diff --git a/apps/ui/src/features/notifications/store.ts b/apps/ui/src/features/notifications/store.ts index 41ef1305..137608cb 100644 --- a/apps/ui/src/features/notifications/store.ts +++ b/apps/ui/src/features/notifications/store.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import { requireCurrentIdentityBinding } from "@/lib/identity-fingerprint-core"; +import { DAY_MS } from "@/lib/time"; import type { NotificationPgDatabase } from "./db-types"; import { DB_NOTIFICATION_ID_PREFIX } from "./notification-ids"; @@ -18,8 +19,6 @@ import type { NotificationPayload, } from "./types"; -const DAY_MS = 24 * 60 * 60 * 1000; - type ReceiptInsert = typeof notificationReadReceipts.$inferInsert; /** diff --git a/apps/ui/src/features/notifications/types.ts b/apps/ui/src/features/notifications/types.ts index 63494f97..6d3b80fa 100644 --- a/apps/ui/src/features/notifications/types.ts +++ b/apps/ui/src/features/notifications/types.ts @@ -1,3 +1,4 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; import { z } from "zod"; import { workspaceResourceQuotaSnapshotSchema } from "@/features/billing/workspace-resource-quota"; @@ -96,9 +97,9 @@ export const notificationMessageSchema = z export type NotificationMessage = z.infer; /** - * One upstream Notification CR as the Go read proxy flattens it (mirrors - * `NotificationCRItem` in `@workspace/api/hooks`); only the dev fixtures - * ever send it over Brain's own wire. + * One upstream Notification CR as the Go read proxy flattens it — pinned to + * `NotificationCRItem` from `@workspace/api/hooks` so the two cannot drift; + * only the dev fixtures ever send it over Brain's own wire. */ export const notificationCRItemSchema = z .object({ @@ -114,8 +115,10 @@ export const notificationCRItemSchema = z timestamp: z.number().int().nonnegative(), title: z.string(), uid: z.string().optional(), + /** The id's version component (see `NotificationCRItem.version`). */ + version: z.number().int().nonnegative(), }) - .strict(); + .strict() satisfies z.ZodType; export const notificationFeedResponseSchema = z .object({ diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.tsx index a545ef2e..ce13ba5a 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications.tsx @@ -1,5 +1,6 @@ "use client"; +import { AppButton } from "@workspace/ui/components/app-button"; import { Popover, PopoverContent, @@ -103,19 +104,25 @@ function NotificationRow({
{cta == null ? null : ( - { - if (cta.href.startsWith("/billing")) { - recordBillingReturnRoute(); - } - onRead(); - }} - > - {cta.label} - + { + if (cta.href.startsWith("/billing")) { + recordBillingReturnRoute(); + } + onRead(); + }} + > + {cta.label} + + } + size="sm" + variant="secondary" + /> )} loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), { diff --git a/apps/ui/src/features/status-hint/status-hint-banner.tsx b/apps/ui/src/features/status-hint/status-hint-banner.tsx index 07dad25b..17094cec 100644 --- a/apps/ui/src/features/status-hint/status-hint-banner.tsx +++ b/apps/ui/src/features/status-hint/status-hint-banner.tsx @@ -7,8 +7,9 @@ import { Info, TriangleAlert, X } from "lucide-react"; import Link from "next/link"; import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; +import { BILLING_SURFACE_TONES } from "@/features/billing/billing-surface-tones"; -import type { StatusHint, StatusHintTone } from "./status-hint-model"; +import type { StatusHint } from "./status-hint-model"; import { useStatusHint } from "./use-status-hint"; /** @@ -18,13 +19,6 @@ import { useStatusHint } from "./use-status-hint"; * payment-due stage only ever changes its words, never its visuals. */ -// Same tint recipe as the Billing Area's subscription warning banner. -const SURFACE_TONES: Record = { - destructive: "bg-red-500/10 text-destructive", - info: "bg-blue-400/10 text-blue-600 dark:text-blue-400", - warning: "bg-amber-400/10 text-amber-600 dark:text-amber-400", -}; - export function StatusHintBannerView({ hint, onDismiss, @@ -37,7 +31,7 @@ export function StatusHintBannerView({
Date: Fri, 28 Aug 2026 10:05:51 +0800 Subject: [PATCH 07/14] fix(notifications): account-scoped gift hint, mark-read rollback and batching Review follow-ups on PR #305: - The gift hint (catalog D4) is account-level but was stored under the first observing namespace and the feed only listed that namespace, so other workspaces never showed it. `notification_messages` gains a nullable `user_uid` (migration 0016); an inbox now lists its workspace's rows plus the person's account-scoped rows, and `markRead` attaches receipts to either. ADR-0067 records the two scopes. - A failed read receipt was swallowed after the optimistic `readIds` write, leaving the item read for the session and unread after reload with no way to retry. The failure now rolls the optimistic ids back and says so with a toast. - "Mark all" with more than 200 unread ids hit the route's request limit and failed silently. The client splits receipts into batches of `NOTIFICATION_READ_BATCH_LIMIT`, sent in order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W33Mx9kYjBFoaQux2wKWJz --- .../0016_notification_account_scope.sql | 2 + apps/ui/drizzle/meta/0016_snapshot.json | 2848 +++++++++++++++++ apps/ui/drizzle/meta/_journal.json | 7 + .../src/features/notifications/client.test.ts | 61 + apps/ui/src/features/notifications/client.ts | 36 +- .../notifications/http-handlers.test.ts | 13 + .../features/notifications/http-handlers.ts | 7 +- .../producer-credit-hint.test.ts | 25 +- .../notifications/producer-credit-hint.ts | 10 +- .../producer-quota-exhausted.test.ts | 10 +- .../producer-subscription-change.test.ts | 10 +- .../producers-through-fixtures.test.ts | 16 +- apps/ui/src/features/notifications/schema.ts | 14 +- .../src/features/notifications/store.test.ts | 85 +- apps/ui/src/features/notifications/store.ts | 47 +- apps/ui/src/features/notifications/types.ts | 8 +- .../notifications/use-notification-feed.ts | 32 +- ...hybrid-cr-read-proxy-and-brain-postgres.md | 13 +- 18 files changed, 3181 insertions(+), 63 deletions(-) create mode 100644 apps/ui/drizzle/0016_notification_account_scope.sql create mode 100644 apps/ui/drizzle/meta/0016_snapshot.json create mode 100644 apps/ui/src/features/notifications/client.test.ts diff --git a/apps/ui/drizzle/0016_notification_account_scope.sql b/apps/ui/drizzle/0016_notification_account_scope.sql new file mode 100644 index 00000000..24610682 --- /dev/null +++ b/apps/ui/drizzle/0016_notification_account_scope.sql @@ -0,0 +1,2 @@ +ALTER TABLE "sealai_notification"."notification_messages" ADD COLUMN "user_uid" text;--> statement-breakpoint +CREATE INDEX "notification_messages_user_uid_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("user_uid","created_at"); \ No newline at end of file diff --git a/apps/ui/drizzle/meta/0016_snapshot.json b/apps/ui/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..16290a0c --- /dev/null +++ b/apps/ui/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2848 @@ +{ + "id": "d0adde67-61b8-414f-81d1-d979e2dc57f2", + "prevId": "bda42e6a-255a-42d0-a8c4-4f3a7505c783", + "version": "7", + "dialect": "postgresql", + "tables": { + "sealai_assistant.assistant_chat_messages": { + "name": "assistant_chat_messages", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chat_messages_chat_id_idx": { + "name": "assistant_chat_messages_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chat_messages_chat_id_created_idx": { + "name": "assistant_chat_messages_chat_id_created_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assistant_chat_messages_chat_id_assistant_chats_id_fk": { + "name": "assistant_chat_messages_chat_id_assistant_chats_id_fk", + "tableFrom": "assistant_chat_messages", + "tableTo": "assistant_chats", + "schemaTo": "sealai_assistant", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_chats": { + "name": "assistant_chats", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Chat'" + }, + "title_ai_generated": { + "name": "title_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chats_updated_at_idx": { + "name": "assistant_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chats_namespace_actor_updated_at_idx": { + "name": "assistant_chats_namespace_actor_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_devbox_runtimes": { + "name": "assistant_devbox_runtimes", + "schema": "sealai_assistant", + "columns": { + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pause_due_at": { + "name": "pause_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_due_at": { + "name": "delete_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_owner": { + "name": "cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_expires_at": { + "name": "cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_devbox_runtimes_pause_due_idx": { + "name": "assistant_devbox_runtimes_pause_due_idx", + "columns": [ + { + "expression": "pause_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"paused_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_devbox_runtimes_delete_due_idx": { + "name": "assistant_devbox_runtimes_delete_due_idx", + "columns": [ + { + "expression": "delete_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"delete_due_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_entitlements": { + "name": "assistant_entitlements", + "schema": "sealai_assistant", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "free_turns_used": { + "name": "free_turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_entitlements_updated_at_idx": { + "name": "assistant_entitlements_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_app_install_sessions": { + "name": "github_app_install_sessions", + "schema": "sealai_assistant", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_app_install_sessions_expires_at_idx": { + "name": "github_app_install_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_app_install_sessions_namespace_idx": { + "name": "github_app_install_sessions_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_connections": { + "name": "github_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github_app'" + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'User'" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_connections_updated_at_idx": { + "name": "github_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_updated_at_idx": { + "name": "github_connections_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_unique_idx": { + "name": "github_connections_namespace_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_installation_idx": { + "name": "github_connections_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_oauth_connections": { + "name": "github_oauth_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bearer'" + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_oauth_connections_updated_at_idx": { + "name": "github_oauth_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_github_login_idx": { + "name": "github_oauth_connections_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_current_owner_unique_idx": { + "name": "github_oauth_connections_current_owner_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_assistant\".\"github_oauth_connections\".\"owner_identity_version\" = 2", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_fingerprints": { + "name": "identity_fingerprints", + "schema": "sealai_assistant", + "columns": { + "cr_name": { + "name": "cr_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "minted_at": { + "name": "minted_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_uid_canonicalizations": { + "name": "identity_uid_canonicalizations", + "schema": "sealai_assistant", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canonical_user_uid": { + "name": "canonical_user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_uid_canonicalizations_canonical_idx": { + "name": "identity_uid_canonicalizations_canonical_idx", + "columns": [ + { + "expression": "canonical_user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_agent_calls": { + "name": "deploy_task_agent_calls", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deploy_task_agent_calls_pending_idx": { + "name": "deploy_task_agent_calls_pending_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_agent_calls_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_agent_calls_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_agent_calls", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_agent_calls_pk": { + "name": "deploy_task_agent_calls_pk", + "columns": ["task_id", "call_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_task_agent_calls_lease_epoch_nonnegative": { + "name": "deploy_task_agent_calls_lease_epoch_nonnegative", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"lease_epoch\" >= 0" + }, + "deploy_task_agent_calls_tool_name_valid": { + "name": "deploy_task_agent_calls_tool_name_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"tool_name\" IN ('template_ready', 'deployment_completed')" + }, + "deploy_task_agent_calls_state_valid": { + "name": "deploy_task_agent_calls_state_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" IN ('pending', 'running', 'succeeded', 'failed')" + }, + "deploy_task_agent_calls_request_hash_valid": { + "name": "deploy_task_agent_calls_request_hash_valid", + "value": "char_length(\"sealai_deployment\".\"deploy_task_agent_calls\".\"request_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_events": { + "name": "deploy_task_events", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "nextval('sealai_deployment.deploy_task_events_seq'::regclass)" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_events_task_created_at_idx": { + "name": "deploy_task_events_task_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_events_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_events_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_events", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_events_pk": { + "name": "deploy_task_events_pk", + "columns": ["task_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_messages": { + "name": "deploy_task_messages", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_messages_task_id_idx": { + "name": "deploy_task_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_task_messages_task_created_idx": { + "name": "deploy_task_messages_task_created_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_messages_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_messages_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_messages", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_tasks": { + "name": "deploy_tasks", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creating_actor": { + "name": "creating_actor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "marketing_attribution": { + "name": "marketing_attribution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_binding": { + "name": "credential_binding", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_connection_id": { + "name": "github_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "runner": { + "name": "runner", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_state": { + "name": "runtime_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_paused_at": { + "name": "runtime_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_owner": { + "name": "runtime_cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_expires_at": { + "name": "runtime_cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gateway_url": { + "name": "gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_session_id": { + "name": "gateway_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_thread_id": { + "name": "gateway_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_turn_id": { + "name": "gateway_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_state_snapshot": { + "name": "gateway_state_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "agent_turn_count": { + "name": "agent_turn_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_protocol": { + "name": "agent_protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mcp-v1'" + }, + "agent_control_token_hash": { + "name": "agent_control_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_control_token_revoked_at": { + "name": "agent_control_token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_template_digest": { + "name": "agent_template_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_schema_digest": { + "name": "agent_input_schema_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_checkpoint_id": { + "name": "agent_checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_revision": { + "name": "agent_input_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_completion_receipt": { + "name": "agent_completion_receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_details": { + "name": "failure_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifact_summary": { + "name": "artifact_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "canvas_projection": { + "name": "canvas_projection", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "timeline_snapshot": { + "name": "timeline_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocking_inputs": { + "name": "blocking_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_url": { + "name": "result_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_claimed_at": { + "name": "lease_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retried_from_task_id": { + "name": "retried_from_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_tasks_agent_control_token_hash_idx": { + "name": "deploy_tasks_agent_control_token_hash_idx", + "columns": [ + { + "expression": "agent_control_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_namespace_updated_at_idx": { + "name": "deploy_tasks_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_project_uid_updated_at_idx": { + "name": "deploy_tasks_project_uid_updated_at_idx", + "columns": [ + { + "expression": "project_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_status_updated_at_idx": { + "name": "deploy_tasks_status_updated_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_leased_expiry_idx": { + "name": "deploy_tasks_leased_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('running', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_queued_created_idx": { + "name": "deploy_tasks_queued_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_terminal_completed_idx": { + "name": "deploy_tasks_terminal_completed_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('completed', 'failed', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_runtime_paused_idx": { + "name": "deploy_tasks_runtime_paused_idx", + "columns": [ + { + "expression": "runtime_paused_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"runtime_provider\" = 'devbox' AND lower(coalesce(\"sealai_deployment\".\"deploy_tasks\".\"runtime_state\", '')) IN ('paused', 'archived')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_one_active_clone_idx": { + "name": "deploy_tasks_one_active_clone_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retried_from_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"retried_from_task_id\" IS NOT NULL AND \"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('queued', 'running', 'blocked', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_tasks_agent_turn_count_nonnegative": { + "name": "deploy_tasks_agent_turn_count_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_turn_count\" >= 0" + }, + "deploy_tasks_agent_input_revision_nonnegative": { + "name": "deploy_tasks_agent_input_revision_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_input_revision\" >= 0" + }, + "deploy_tasks_agent_protocol_valid": { + "name": "deploy_tasks_agent_protocol_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_protocol\" IN ('mcp-v1')" + }, + "deploy_tasks_agent_control_token_hash_valid": { + "name": "deploy_tasks_agent_control_token_hash_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NULL OR char_length(\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_onboarding.onboarding_profiles": { + "name": "onboarding_profiles", + "schema": "sealai_onboarding", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at_step": { + "name": "dismissed_at_step", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_type": { + "name": "role_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_other_text": { + "name": "role_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_context": { + "name": "usage_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_other_text": { + "name": "usage_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority_tags": { + "name": "priority_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_display_order": { + "name": "priority_display_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_other_text": { + "name": "priority_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_goal_text": { + "name": "open_goal_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.attribution_subjects": { + "name": "attribution_subjects", + "schema": "sealai_marketing", + "columns": { + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "attribution_subjects_pk": { + "name": "attribution_subjects_pk", + "columns": ["subject_type", "subject_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.lifecycle_events": { + "name": "lifecycle_events", + "schema": "sealai_marketing", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hashed_user_data": { + "name": "hashed_user_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lifecycle_events_action_transaction_idx": { + "name": "lifecycle_events_action_transaction_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_marketing\".\"lifecycle_events\".\"transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_messages": { + "name": "notification_messages", + "schema": "sealai_notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_messages_live_dedupe_key_idx": { + "name": "notification_messages_live_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_notification\".\"notification_messages\".\"released_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_namespace_created_at_idx": { + "name": "notification_messages_namespace_created_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_user_uid_created_at_idx": { + "name": "notification_messages_user_uid_created_at_idx", + "columns": [ + { + "expression": "user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_created_at_idx": { + "name": "notification_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_read_receipts": { + "name": "notification_read_receipts", + "schema": "sealai_notification", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_key": { + "name": "message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_read_receipts_message_id_idx": { + "name": "notification_read_receipts_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_read_receipts_message_id_notification_messages_id_fk": { + "name": "notification_read_receipts_message_id_notification_messages_id_fk", + "tableFrom": "notification_read_receipts", + "tableTo": "notification_messages", + "schemaTo": "sealai_notification", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_read_receipts_user_uid_message_key_pk": { + "name": "notification_read_receipts_user_uid_message_key_pk", + "columns": ["user_uid", "message_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_canvas_layouts": { + "name": "project_canvas_layouts", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name_snapshot": { + "name": "project_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nodes": { + "name": "nodes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_canvas_layouts_updated_at_idx": { + "name": "project_canvas_layouts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_canvas_layouts_pk": { + "name": "project_canvas_layouts_pk", + "columns": ["namespace", "project_uid"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_operations": { + "name": "project_delete_operations", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_id": { + "name": "preview_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_operations_expires_at_idx": { + "name": "project_delete_operations_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_operations_pk": { + "name": "project_delete_operations_pk", + "columns": ["namespace", "project_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_previews": { + "name": "project_delete_previews", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_previews_expires_at_idx": { + "name": "project_delete_previews_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_delete_previews_target_idx": { + "name": "project_delete_previews_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_previews_pk": { + "name": "project_delete_previews_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_management_audit_events": { + "name": "project_management_audit_events", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_management_audit_events_target_idx": { + "name": "project_management_audit_events_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_management_audit_events_pk": { + "name": "project_management_audit_events_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_navigation_preferences": { + "name": "project_navigation_preferences", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_project_ids": { + "name": "pinned_project_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_navigation_preferences_updated_at_idx": { + "name": "project_navigation_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_navigation_preferences_pk": { + "name": "project_navigation_preferences_pk", + "columns": ["namespace"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.projects": { + "name": "projects", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_namespace_lower_display_name_idx": { + "name": "projects_namespace_lower_display_name_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"display_name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_updated_at_idx": { + "name": "projects_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "projects_pk": { + "name": "projects_pk", + "columns": ["namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "sealai_assistant": "sealai_assistant", + "sealai_deployment": "sealai_deployment", + "sealai_onboarding": "sealai_onboarding", + "sealai_marketing": "sealai_marketing", + "sealai_notification": "sealai_notification", + "sealai_project": "sealai_project" + }, + "sequences": { + "sealai_deployment.deploy_task_events_seq": { + "name": "deploy_task_events_seq", + "schema": "sealai_deployment", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/ui/drizzle/meta/_journal.json b/apps/ui/drizzle/meta/_journal.json index 934b42e2..8313f0f5 100644 --- a/apps/ui/drizzle/meta/_journal.json +++ b/apps/ui/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1787841043876, "tag": "0015_notification_center", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787882489078, + "tag": "0016_notification_account_scope", + "breakpoints": true } ] } diff --git a/apps/ui/src/features/notifications/client.test.ts b/apps/ui/src/features/notifications/client.test.ts new file mode 100644 index 00000000..3b0ab85f --- /dev/null +++ b/apps/ui/src/features/notifications/client.test.ts @@ -0,0 +1,61 @@ +import { test } from "bun:test"; +import assert from "node:assert/strict"; + +import { postNotificationReadReceipts, readReceiptBatches } from "./client"; + +const CREDENTIALS = { appToken: "t", kubeconfig: "k", namespace: "ns-a" }; +const MARK_READ_FAILED_RE = /Mark-read request failed \(503\)/; + +function ids(count: number): string[] { + return Array.from({ length: count }, (_, index) => `cr:name-${index}:1`); +} + +test("readReceiptBatches splits ids at the route's limit and keeps order", () => { + assert.deepEqual(readReceiptBatches([], 3), []); + assert.deepEqual(readReceiptBatches(["a", "b", "c"], 3), [["a", "b", "c"]]); + assert.deepEqual(readReceiptBatches(["a", "b", "c", "d"], 3), [ + ["a", "b", "c"], + ["d"], + ]); + assert.deepEqual( + readReceiptBatches(ids(401)).map((b) => b.length), + [200, 200, 1] + ); +}); + +test("postNotificationReadReceipts sends one request per batch, none for no ids", async () => { + const bodies: string[][] = []; + const fetchImpl = ((_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { ids: string[] }; + bodies.push(body.ids); + return Promise.resolve( + new Response(JSON.stringify({ read: body.ids }), { status: 200 }) + ); + }) as typeof fetch; + + await postNotificationReadReceipts(CREDENTIALS, [], fetchImpl); + assert.equal(bodies.length, 0); + + await postNotificationReadReceipts(CREDENTIALS, ids(450), fetchImpl); + assert.deepEqual( + bodies.map((b) => b.length), + [200, 200, 50] + ); + assert.deepEqual(bodies.flat(), ids(450)); +}); + +test("postNotificationReadReceipts rejects on a failed batch", async () => { + let calls = 0; + const fetchImpl = ((_url: unknown, _init?: RequestInit) => { + calls += 1; + return Promise.resolve( + new Response("", { status: calls === 2 ? 503 : 200 }) + ); + }) as typeof fetch; + + await assert.rejects( + postNotificationReadReceipts(CREDENTIALS, ids(250), fetchImpl), + MARK_READ_FAILED_RE + ); + assert.equal(calls, 2); +}); diff --git a/apps/ui/src/features/notifications/client.ts b/apps/ui/src/features/notifications/client.ts index 8019afae..9154e561 100644 --- a/apps/ui/src/features/notifications/client.ts +++ b/apps/ui/src/features/notifications/client.ts @@ -3,6 +3,7 @@ import { personalResourceAuthHeaders } from "@/lib/personal-resource-headers"; import { type GiftObservationRequest, + NOTIFICATION_READ_BATCH_LIMIT, type NotificationFeedResponse, notificationFeedResponseSchema, type SubscriptionChangeObservationRequest, @@ -35,22 +36,37 @@ export async function fetchNotificationFeed( return notificationFeedResponseSchema.parse(await response.json()); } -/** Records per-user receipts for the given source-prefixed ids. */ +/** Splits ids into request-sized batches so "mark all" never exceeds the route's limit. */ +export function readReceiptBatches( + ids: readonly string[], + limit: number = NOTIFICATION_READ_BATCH_LIMIT +): string[][] { + const batches: string[][] = []; + for (let start = 0; start < ids.length; start += limit) { + batches.push(ids.slice(start, start + limit)); + } + return batches; +} + +/** + * Records per-user receipts for the given source-prefixed ids, one request + * per batch in order. Rejects on the first failed batch; earlier batches + * stay recorded (receipts are idempotent, so a retry re-sends them all). + */ export async function postNotificationReadReceipts( credentials: NotificationClientCredentials, ids: readonly string[], fetchImpl: typeof fetch = globalThis.fetch ): Promise { - if (ids.length === 0) { - return; + for (const batch of readReceiptBatches(ids)) { + await postNotificationJson( + "/read", + credentials, + { ids: batch }, + "Mark-read", + fetchImpl + ); } - await postNotificationJson( - "/read", - credentials, - { ids }, - "Mark-read", - fetchImpl - ); } async function postNotificationJson( diff --git a/apps/ui/src/features/notifications/http-handlers.test.ts b/apps/ui/src/features/notifications/http-handlers.test.ts index 251a3154..d8d138e2 100644 --- a/apps/ui/src/features/notifications/http-handlers.test.ts +++ b/apps/ui/src/features/notifications/http-handlers.test.ts @@ -245,6 +245,19 @@ test("a visible gift writes one hint per user through the route; a retry writes giftMicroUnits: 720_000, kind: "credit-hint", }); + // Account-scoped (catalog D4): the same hint is listed from a workspace + // that never observed the gift. + const elsewhere = notificationFeedResponseSchema.parse( + await ( + await handlers.feed( + await request("", { actor: "carol", namespace: "ns-b" }) + ) + ).json() + ); + assert.deepEqual( + elsewhere.messages.filter((message) => message.kind === "credit-hint"), + [hint] + ); const bad = await handlers.observeGift( await request("/gift-observation", { diff --git a/apps/ui/src/features/notifications/http-handlers.ts b/apps/ui/src/features/notifications/http-handlers.ts index 7da83165..cdf9971a 100644 --- a/apps/ui/src/features/notifications/http-handlers.ts +++ b/apps/ui/src/features/notifications/http-handlers.ts @@ -56,8 +56,9 @@ function unavailable(route: string, message: string): Response { * stream plus the user's receipts, the per-user mark-read write, and the * producers' observation points (quota, gift credit, subscription change). * Every handler travels the verified-personal-actor choke point — receipts - * are uid-keyed rows (ADR-0059) and the stream is namespace-scoped, so both - * the workspace and the user must be verified. + * are uid-keyed rows (ADR-0059) and the stream is the verified workspace's + * messages plus the verified person's account-scoped ones, so both the + * workspace and the user must be verified. */ export function createNotificationHandlers( dependencies: NotificationHandlerDependencies @@ -145,7 +146,7 @@ export function createNotificationHandlers( const reader = readerOf(authorization.actor); try { const [messages, receipts] = await Promise.all([ - dependencies.store.listMessages(reader.namespace), + dependencies.store.listMessages(reader), dependencies.store.listReceipts(reader.userUid), ]); const body: NotificationFeedResponse = { messages, receipts }; diff --git a/apps/ui/src/features/notifications/producer-credit-hint.test.ts b/apps/ui/src/features/notifications/producer-credit-hint.test.ts index 0836375c..181587cc 100644 --- a/apps/ui/src/features/notifications/producer-credit-hint.test.ts +++ b/apps/ui/src/features/notifications/producer-credit-hint.test.ts @@ -39,7 +39,7 @@ test("the dedupe key names the user, not the workspace", () => { assert.equal(creditHintDedupeKey("uid-alice"), "credit-hint:uid-alice"); }); -test("a visible gift writes one hint per user; retries and other workspaces write nothing", async () => { +test("a visible gift writes one hint per user; retries and other workspaces write nothing, and every workspace lists it", async () => { const first = await observeGiftCreditForNotifications(store, { giftMicroUnits: 720_000, namespace: "ns-a", @@ -62,13 +62,25 @@ test("a visible gift writes one hint per user; retries and other workspaces writ assert.deepEqual(first, { produced: true }); assert.deepEqual(retry, { produced: false }); assert.deepEqual(elsewhere, { produced: false }); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "uid-alice", + }); assert.equal(messages.length, 1); assert.deepEqual(messages[0]?.payload, { giftMicroUnits: 720_000, kind: "credit-hint", }); - assert.deepEqual(await store.listMessages("ns-b"), []); + // Account-scoped: the one row follows the person into a workspace that + // never observed the gift, and stays out of another person's inbox there. + assert.deepEqual( + await store.listMessages({ namespace: "ns-b", userUid: "uid-alice" }), + messages + ); + assert.deepEqual( + await store.listMessages({ namespace: "ns-a", userUid: "uid-someone" }), + [] + ); }); test("another user gets their own hint; no gift writes nothing", async () => { @@ -90,7 +102,12 @@ test("another user gets their own hint; no gift writes nothing", async () => { }), { produced: true } ); - assert.equal((await store.listMessages("ns-a")).length, 2); + assert.equal( + (await store.listMessages({ namespace: "ns-a", userUid: "uid-bob" })) + .length, + 1, + "bob's inbox holds his own hint, not alice's" + ); }); test("a blank user or workspace observes nothing", async () => { diff --git a/apps/ui/src/features/notifications/producer-credit-hint.ts b/apps/ui/src/features/notifications/producer-credit-hint.ts index 08190160..8a9c563b 100644 --- a/apps/ui/src/features/notifications/producer-credit-hint.ts +++ b/apps/ui/src/features/notifications/producer-credit-hint.ts @@ -3,10 +3,11 @@ import type { NotificationStore } from "./store"; /** * The $1 gift hint producer (catalog row D4): the first time a new user's * gift credit is visible during one of their requests, exactly one - * reassuring entry appears — per user, not per workspace, so the dedupe key - * carries the bare user uid and the row lands in the workspace that first - * observed it. The key is never released: the gift is reissued monthly, the - * welcome is said once. + * reassuring entry appears — per user, not per workspace. The dedupe key + * carries the bare user uid and the row is account-scoped (`userUid` set), + * so it follows the person into every workspace's inbox; `namespace` only + * records where it was first observed. The key is never released: the gift + * is reissued monthly, the welcome is said once. */ export const CREDIT_HINT_DEDUPE_PREFIX = "credit-hint"; @@ -45,6 +46,7 @@ export async function observeGiftCreditForNotifications( namespace, now: input.now, payload: { giftMicroUnits: input.giftMicroUnits, kind: "credit-hint" }, + userUid, }); return { produced }; } diff --git a/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts index 14792a05..e1e25752 100644 --- a/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts +++ b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts @@ -84,7 +84,10 @@ test("crossing 100% writes one entry per resource; a retry writes nothing", asyn assert.deepEqual(first, { produced: ["cpu", "storage"], released: [] }); assert.deepEqual(retry, { produced: [], released: [] }); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); assert.equal(messages.length, 2, "exactly one entry per exhausted resource"); const resources = messages.map((message) => message.payload.kind === "quota-exhausted" ? message.payload.resource : "" @@ -130,7 +133,10 @@ test("falling below 100% resets; staying full re-sends nothing; re-crossing writ }); assert.deepEqual(reCrossed, { produced: ["storage"], released: [] }); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); assert.equal(messages.length, 3, "cpu once, storage twice across a recovery"); }); diff --git a/apps/ui/src/features/notifications/producer-subscription-change.test.ts b/apps/ui/src/features/notifications/producer-subscription-change.test.ts index be4c8758..a160a2c6 100644 --- a/apps/ui/src/features/notifications/producer-subscription-change.test.ts +++ b/apps/ui/src/features/notifications/producer-subscription-change.test.ts @@ -69,7 +69,10 @@ test("one receipt per successful change; the same transaction observed twice wri assert.deepEqual(first, { produced: true }); assert.deepEqual(again, { produced: false }); assert.deepEqual(downgrade, { produced: true }); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); assert.deepEqual( messages.map((message) => message.payload), [ @@ -114,7 +117,10 @@ test("an upgrade over a scheduled downgrade is the next receipt; the superseded assert.deepEqual(scheduled, { produced: true }); assert.deepEqual(upgrade, { produced: true }); assert.deepEqual(settled, { produced: false }); - const messages = await store.listMessages("ns-seq"); + const messages = await store.listMessages({ + namespace: "ns-seq", + userUid: "viewer-uid", + }); assert.deepEqual( messages.map((message) => message.payload), [ diff --git a/apps/ui/src/features/notifications/producers-through-fixtures.test.ts b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts index ddcb1e8d..942ea1b7 100644 --- a/apps/ui/src/features/notifications/producers-through-fixtures.test.ts +++ b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts @@ -106,7 +106,15 @@ test("quota-full's storage crosses 100% once; active's usage releases it", async snapshot: await quotaSnapshotFor("active"), }); assert.deepEqual(recovered, { produced: [], released: ["storage"] }); - assert.equal((await store.listMessages(WORKSPACE)).length, 1); + assert.equal( + ( + await store.listMessages({ + namespace: WORKSPACE, + userUid: "uid-newcomer", + }) + ).length, + 1 + ); }); test("free's visible gift writes the hint once; active carries no gift", async () => { @@ -148,9 +156,9 @@ test("active's settled checkout is one upgrade receipt, however often it is obse assert.deepEqual(await observe(), { produced: true }); assert.deepEqual(await observe(), { produced: false }); - const kinds = (await store.listMessages(WORKSPACE)).map( - (message) => message.kind - ); + const kinds = ( + await store.listMessages({ namespace: WORKSPACE, userUid: "uid-newcomer" }) + ).map((message) => message.kind); assert.deepEqual([...kinds].sort(), [ "credit-hint", "quota-exhausted", diff --git a/apps/ui/src/features/notifications/schema.ts b/apps/ui/src/features/notifications/schema.ts index f48c9b3d..873543b7 100644 --- a/apps/ui/src/features/notifications/schema.ts +++ b/apps/ui/src/features/notifications/schema.ts @@ -31,14 +31,20 @@ export const NOTIFICATION_RETENTION_DAYS = 365; * naming is dedupe, so a retried request re-inserts nothing. A producer * "releases" the key (`releasedAt`) when the underlying state recovers so the * next threshold crossing writes a fresh entry while history keeps the old - * one; the unique index therefore only spans live keys. + * one; the unique index therefore only spans live keys. A message is either + * workspace-scoped (`namespace` is the inbox it belongs to, `userUid` null) + * or account-scoped (`userUid` names the person: the message follows them + * into every workspace's inbox, and `namespace` only records where it was + * first observed). */ export const notificationMessages = ns.table( "notification_messages", { id: text("id").primaryKey(), - /** Workspace namespace; the inbox's aggregation boundary. */ + /** Workspace namespace: the inbox for workspace-scoped messages, the observing workspace for account-scoped ones. */ namespace: text("namespace").notNull(), + /** Bare global user UID (ADR-0059) for account-scoped messages; null for workspace-scoped ones. */ + userUid: text("user_uid"), kind: text("kind").notNull().$type(), /** Source Project when the message has one (deploy outcomes, later). */ projectUid: text("project_uid"), @@ -57,6 +63,10 @@ export const notificationMessages = ns.table( table.namespace, table.createdAt ), + index("notification_messages_user_uid_created_at_idx").on( + table.userUid, + table.createdAt + ), index("notification_messages_created_at_idx").on(table.createdAt), ] ); diff --git a/apps/ui/src/features/notifications/store.test.ts b/apps/ui/src/features/notifications/store.test.ts index 3f3c4ae0..49881873 100644 --- a/apps/ui/src/features/notifications/store.test.ts +++ b/apps/ui/src/features/notifications/store.test.ts @@ -75,7 +75,10 @@ test("produce writes one entry per dedupe key and retries dedupe", async () => { assert.equal(first, true); assert.equal(retry, false); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); assert.equal(messages.length, 1); assert.equal(messages[0]?.kind, "quota-exhausted"); assert.deepEqual(messages[0]?.payload, quotaPayload("storage")); @@ -101,7 +104,10 @@ test("release frees the dedupe key, keeps history, and lets the next crossing wr assert.equal(released, true); assert.equal(releasedAgain, false, "a released key releases nothing twice"); assert.equal(reproduced, true, "recovery resets the edge trigger"); - const messages = await store.listMessages("ns-a"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); assert.equal(messages.length, 2, "the released entry stays as history"); assert.equal(messages[0]?.createdAt, NOW.getTime() + 3000, "newest first"); }); @@ -115,8 +121,14 @@ test("messages are isolated per namespace", async () => { payload: quotaPayload("cpu"), }); - const a = await store.listMessages("ns-a"); - const b = await store.listMessages("ns-b"); + const a = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); + const b = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); assert.equal(a.length, 2); assert.equal(b.length, 1); assert.equal( @@ -128,7 +140,10 @@ test("messages are isolated per namespace", async () => { test("receipts are per user, follow the person across workspaces, and db receipts attach their row", async () => { await bindIdentity("alice", "alice-uid"); await bindIdentity("bob", "bob-uid"); - const [message] = await store.listMessages("ns-b"); + const [message] = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); assert.ok(message); const dbId = dbNotificationId(message.id); const crId = "cr:debt-choice-debtperiod:1756200000"; @@ -157,8 +172,49 @@ test("receipts are per user, follow the person across workspaces, and db receipt assert.equal(receipt?.messageId, message.id); }); +test("an account-scoped message follows the person into every workspace and takes its receipt from any of them", async () => { + await store.produce({ + dedupeKey: "credit-hint:alice-uid", + kind: "credit-hint", + namespace: "ns-a", + now: NOW, + payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, + userUid: "alice-uid", + }); + const inNsB = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); + const hint = inNsB.find((message) => message.kind === "credit-hint"); + assert.ok(hint, "a workspace that never observed the gift lists the hint"); + assert.equal( + (await store.listMessages({ namespace: "ns-a", userUid: "bob-uid" })).some( + (message) => message.kind === "credit-hint" + ), + false, + "another person's inbox in the observing workspace does not" + ); + + // The receipt attaches the row from a workspace other than the observing one. + const dbId = dbNotificationId(hint.id); + await store.markRead(reader({ namespace: "ns-b" }), [dbId]); + const [receipt] = await db + .select({ messageId: notificationReadReceipts.messageId }) + .from(notificationReadReceipts) + .where( + and( + eq(notificationReadReceipts.userUid, "alice-uid"), + eq(notificationReadReceipts.messageKey, dbId) + ) + ); + assert.equal(receipt?.messageId, hint.id); +}); + test("a db receipt for a foreign or unknown message is ignored, not refused", async () => { - const [foreign] = await store.listMessages("ns-a"); + const [foreign] = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); assert.ok(foreign); await store.markRead(reader({ namespace: "ns-b" }), [ @@ -190,7 +246,10 @@ test("writes sweep entries older than 365 days and their receipts cascade", asyn now: old, payload: quotaPayload("memory"), }); - const [oldMessage] = await store.listMessages("ns-c"); + const [oldMessage] = await store.listMessages({ + namespace: "ns-c", + userUid: "carol-uid", + }); assert.ok(oldMessage); await bindIdentity("carol", "carol-uid"); const carol = reader({ @@ -209,15 +268,19 @@ test("writes sweep entries older than 365 days and their receipts cascade", asyn payload: quotaPayload("cpu"), }); - assert.deepEqual(await store.listMessages("ns-c"), []); + assert.deepEqual( + await store.listMessages({ namespace: "ns-c", userUid: "carol-uid" }), + [] + ); assert.deepEqual( await store.listReceipts("carol-uid"), ["cr:keep:1"], "the swept row's receipt cascades; the CR receipt stays" ); assert.equal( - (await store.listMessages("ns-a")).length, - 2, - "entries inside the window survive" + (await store.listMessages({ namespace: "ns-a", userUid: "alice-uid" })) + .length, + 3, + "entries inside the window survive (two workspace rows and alice's hint)" ); }); diff --git a/apps/ui/src/features/notifications/store.ts b/apps/ui/src/features/notifications/store.ts index 137608cb..62a8a099 100644 --- a/apps/ui/src/features/notifications/store.ts +++ b/apps/ui/src/features/notifications/store.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; import { requireCurrentIdentityBinding } from "@/lib/identity-fingerprint-core"; import { DAY_MS } from "@/lib/time"; @@ -22,15 +22,22 @@ import type { type ReceiptInsert = typeof notificationReadReceipts.$inferInsert; /** - * The reader marking notifications read: bare uid key + crName for the - * re-check, plus the workspace whose `db:` rows the receipts may attach to. + * Whose inbox: the workspace's own messages plus the account-scoped ones + * that follow this person into every workspace. */ -export interface NotificationReader { - legacyWorkspaceActor: string; +export interface NotificationInbox { namespace: string; userUid: string; } +/** + * The reader marking notifications read: bare uid key + crName for the + * re-check, plus the inbox whose `db:` rows the receipts may attach to. + */ +export interface NotificationReader extends NotificationInbox { + legacyWorkspaceActor: string; +} + export interface ProduceNotificationInput { dedupeKey: string; kind: NotificationMessageKind; @@ -39,11 +46,17 @@ export interface ProduceNotificationInput { now?: Date; payload: NotificationPayload; projectUid?: string | null; + /** + * Set for account-scoped messages (the gift hint, later the expiry + * reminder): the row follows the person into every workspace's inbox and + * `namespace` only records where it was first observed. + */ + userUid?: string | null; } export interface NotificationStore { - /** The namespace's entries, newest first. */ - listMessages(namespace: string): Promise; + /** The inbox's entries — the workspace's own plus the person's account-scoped ones — newest first. */ + listMessages(inbox: NotificationInbox): Promise; /** The user's receipts, as source-prefixed ids. */ listReceipts(userUid: string): Promise; /** @@ -69,7 +82,7 @@ export interface NotificationStore { /** * `db:` receipts attach their message row so the sweep cascades; a `db:` id - * this namespace does not hold is skipped (it would outlive nothing). Every + * this inbox does not hold is skipped (it would outlive nothing). Every * other id is a platform key and stands alone. */ function receiptRows( @@ -101,6 +114,17 @@ function toMessage(row: NotificationMessageRow): NotificationMessage { }; } +/** The rows one inbox holds: the workspace's own and the person's account-scoped ones. */ +function inboxMessages(inbox: NotificationInbox) { + return or( + and( + eq(notificationMessages.namespace, inbox.namespace), + isNull(notificationMessages.userUid) + ), + eq(notificationMessages.userUid, inbox.userUid) + ); +} + /** * Persistence for the Notification Center's Brain-produced stream and read * receipts (ADR-0067). The handle's schema carries `identity_fingerprints` @@ -110,11 +134,11 @@ export function createNotificationStore( getDb: () => NotificationPgDatabase ): NotificationStore { return { - listMessages: async (namespace) => { + listMessages: async (inbox) => { const rows = await getDb() .select() .from(notificationMessages) - .where(eq(notificationMessages.namespace, namespace)) + .where(inboxMessages(inbox)) .orderBy( desc(notificationMessages.createdAt), desc(notificationMessages.id) @@ -152,7 +176,7 @@ export function createNotificationStore( .from(notificationMessages) .where( and( - eq(notificationMessages.namespace, reader.namespace), + inboxMessages(reader), inArray(notificationMessages.id, dbIds) ) ); @@ -184,6 +208,7 @@ export function createNotificationStore( namespace: input.namespace, payload: input.payload, projectUid: input.projectUid ?? null, + userUid: input.userUid ?? null, }) .onConflictDoNothing({ target: notificationMessages.dedupeKey, diff --git a/apps/ui/src/features/notifications/types.ts b/apps/ui/src/features/notifications/types.ts index 6d3b80fa..25605388 100644 --- a/apps/ui/src/features/notifications/types.ts +++ b/apps/ui/src/features/notifications/types.ts @@ -141,9 +141,15 @@ export type NotificationFeedResponse = z.infer< /** A notification id is source-prefixed: `cr::` or `db:`. */ export const NOTIFICATION_ID_PATTERN = /^(cr|db):.+$/; +/** Ids per mark-read request; the client splits larger batches. */ +export const NOTIFICATION_READ_BATCH_LIMIT = 200; + export const markNotificationReadRequestSchema = z .object({ - ids: z.array(z.string().regex(NOTIFICATION_ID_PATTERN)).min(1).max(200), + ids: z + .array(z.string().regex(NOTIFICATION_ID_PATTERN)) + .min(1) + .max(NOTIFICATION_READ_BATCH_LIMIT), }) .strict(); diff --git a/apps/ui/src/features/notifications/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts index 428c58bd..0d3769ad 100644 --- a/apps/ui/src/features/notifications/use-notification-feed.ts +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -8,6 +8,7 @@ import { } from "@workspace/api/hooks"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import { toast } from "sonner"; import useSWR from "swr"; import { loadAccountCredits } from "@/features/billing/account-credits"; @@ -44,9 +45,11 @@ export interface NotificationFeed { * (≤5 minutes) and Brain-produced entries plus the user's receipts from * Brain's store, merged client-side into one list with the display layer * applied (override table, gift-only filter). Mark-read is optimistic and - * dispatched per source. The account's credits and top-up history decide the - * gift-only filter and are the gift hint's observation point; while the - * billing Dev Mock serves the feed, its fixture CRs replace the cluster poll. + * dispatched per source; a failed receipt rolls the optimistic state back so + * the item reads unread again and the click can be retried. The account's + * credits and top-up history decide the gift-only filter and are the gift + * hint's observation point; while the billing Dev Mock serves the feed, its + * fixture CRs replace the cluster poll. */ export function useNotificationFeed(): NotificationFeed { const appToken = useAtomValue(appTokenAtom).trim(); @@ -158,10 +161,11 @@ export function useNotificationFeed(): NotificationFeed { if (targets.length === 0) { return; } + const targetIds = targets.map((target) => target.id); setReadIds((previous) => { const next = new Set(previous); - for (const target of targets) { - next.add(target.id); + for (const id of targetIds) { + next.add(id); } return next; }); @@ -170,9 +174,25 @@ export function useNotificationFeed(): NotificationFeed { } const plan = planReadDispatch(targets, subscription?.role); const credentials = { appToken, kubeconfig, namespace }; + // The receipt is the read state's source of truth (`readIds` is + // session-only): a failed write rolls the optimistic ids back so the + // dot returns and the user can click again, and says so once. postNotificationReadReceipts(credentials, plan.receiptIds) .then(() => refreshBrainFeed()) - .catch(() => undefined); + .catch(() => { + setReadIds((previous) => { + const next = new Set(previous); + for (const id of targetIds) { + next.delete(id); + } + return next; + }); + toast.error( + targetIds.length === 1 + ? "Couldn't mark the notification as read. Try again." + : "Couldn't mark notifications as read. Try again." + ); + }); if (fixturePlatformItems != null) { return; } diff --git a/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md index be5e9a67..260f5ce9 100644 --- a/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md +++ b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md @@ -26,8 +26,13 @@ Each message has exactly one source of truth, chosen by who produces it. text as written. - **Brain-produced messages live in Brain's own Postgres** under a new schema, `sealai_notification`: `notification_messages` (namespace, kind, project, - structured payload, `dedupe_key`) rendered client-side, isolated per - workspace namespace like the rest of the app's schemas. Producers write at + structured payload, `dedupe_key`, optional `user_uid`) rendered + client-side. A message is workspace-scoped (its namespace is the inbox, + like the rest of the app's schemas) or account-scoped (`user_uid` names + the person, the row follows them into every workspace's inbox, and the + namespace only records where it was first observed — the gift hint, later + the expiry reminder). An inbox lists its workspace's rows plus its + person's account rows. Producers write at natural observation points during user requests — no scheduler, no controller. The dedupe key is the idempotency mechanism (naming is dedupe; a partial unique index spans live keys, and recovery releases the key so a @@ -76,7 +81,9 @@ keeping Brain's own messages in Brain's own database needs none. - The inbox's aggregation boundary is the current workspace, because the caller's kubeconfig reaches one namespace. Account-level platform messages - still appear everywhere since upstream writes them to every user namespace. + still appear everywhere since upstream writes them to every user namespace; + account-scoped Brain messages appear everywhere because the inbox query + adds the person's `user_uid` rows. - Brain-produced messages are invisible to the desktop's own inbox; the desktop is a later channel if ever wanted. - Read state can diverge between Brain and the desktop for Developers (no From ccb78075785b69c7056dec3ddb0c28a5904784e3 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 10:25:17 +0800 Subject: [PATCH 08/14] chore(notifications): squash migrations 0015+0016 into one Both migrations were introduced on this branch and never left it: 0015 created the sealai_notification schema and 0016 added user_uid to notification_messages. Regenerated from schema.ts as a single 0015 so a fresh database gets the final table shape in one step. The regenerated `when` is later than the 0015 candidates on the other open PRs, so drizzle's timestamp ordering stays correct whichever lands first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KKrT7639VGVh1swM6dfFu8 --- apps/ui/drizzle/0015_notification_center.sql | 2 + .../0016_notification_account_scope.sql | 2 - apps/ui/drizzle/meta/0015_snapshot.json | 29 +- apps/ui/drizzle/meta/0016_snapshot.json | 2848 ----------------- apps/ui/drizzle/meta/_journal.json | 9 +- 5 files changed, 31 insertions(+), 2859 deletions(-) delete mode 100644 apps/ui/drizzle/0016_notification_account_scope.sql delete mode 100644 apps/ui/drizzle/meta/0016_snapshot.json diff --git a/apps/ui/drizzle/0015_notification_center.sql b/apps/ui/drizzle/0015_notification_center.sql index 73e6ec4f..0ff625f7 100644 --- a/apps/ui/drizzle/0015_notification_center.sql +++ b/apps/ui/drizzle/0015_notification_center.sql @@ -3,6 +3,7 @@ CREATE SCHEMA "sealai_notification"; CREATE TABLE "sealai_notification"."notification_messages" ( "id" text PRIMARY KEY NOT NULL, "namespace" text NOT NULL, + "user_uid" text, "kind" text NOT NULL, "project_uid" text, "payload" jsonb NOT NULL, @@ -22,5 +23,6 @@ CREATE TABLE "sealai_notification"."notification_read_receipts" ( ALTER TABLE "sealai_notification"."notification_read_receipts" ADD CONSTRAINT "notification_read_receipts_message_id_notification_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "sealai_notification"."notification_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "notification_messages_live_dedupe_key_idx" ON "sealai_notification"."notification_messages" USING btree ("dedupe_key") WHERE "sealai_notification"."notification_messages"."released_at" IS NULL;--> statement-breakpoint CREATE INDEX "notification_messages_namespace_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("namespace","created_at");--> statement-breakpoint +CREATE INDEX "notification_messages_user_uid_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("user_uid","created_at");--> statement-breakpoint CREATE INDEX "notification_messages_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("created_at");--> statement-breakpoint CREATE INDEX "notification_read_receipts_message_id_idx" ON "sealai_notification"."notification_read_receipts" USING btree ("message_id"); \ No newline at end of file diff --git a/apps/ui/drizzle/0016_notification_account_scope.sql b/apps/ui/drizzle/0016_notification_account_scope.sql deleted file mode 100644 index 24610682..00000000 --- a/apps/ui/drizzle/0016_notification_account_scope.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "sealai_notification"."notification_messages" ADD COLUMN "user_uid" text;--> statement-breakpoint -CREATE INDEX "notification_messages_user_uid_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("user_uid","created_at"); \ No newline at end of file diff --git a/apps/ui/drizzle/meta/0015_snapshot.json b/apps/ui/drizzle/meta/0015_snapshot.json index fe0961b3..56acc997 100644 --- a/apps/ui/drizzle/meta/0015_snapshot.json +++ b/apps/ui/drizzle/meta/0015_snapshot.json @@ -1,5 +1,5 @@ { - "id": "bda42e6a-255a-42d0-a8c4-4f3a7505c783", + "id": "0dbbaddf-7e79-4f0e-83bf-b3eb789b1de9", "prevId": "67332fdf-15e9-467a-aecf-25706210e908", "version": "7", "dialect": "postgresql", @@ -2074,6 +2074,12 @@ "primaryKey": false, "notNull": true }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, "kind": { "name": "kind", "type": "text", @@ -2150,6 +2156,27 @@ "method": "btree", "with": {} }, + "notification_messages_user_uid_created_at_idx": { + "name": "notification_messages_user_uid_created_at_idx", + "columns": [ + { + "expression": "user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "notification_messages_created_at_idx": { "name": "notification_messages_created_at_idx", "columns": [ diff --git a/apps/ui/drizzle/meta/0016_snapshot.json b/apps/ui/drizzle/meta/0016_snapshot.json deleted file mode 100644 index 16290a0c..00000000 --- a/apps/ui/drizzle/meta/0016_snapshot.json +++ /dev/null @@ -1,2848 +0,0 @@ -{ - "id": "d0adde67-61b8-414f-81d1-d979e2dc57f2", - "prevId": "bda42e6a-255a-42d0-a8c4-4f3a7505c783", - "version": "7", - "dialect": "postgresql", - "tables": { - "sealai_assistant.assistant_chat_messages": { - "name": "assistant_chat_messages", - "schema": "sealai_assistant", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "chat_id": { - "name": "chat_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parts": { - "name": "parts", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "assistant_chat_messages_chat_id_idx": { - "name": "assistant_chat_messages_chat_id_idx", - "columns": [ - { - "expression": "chat_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "assistant_chat_messages_chat_id_created_idx": { - "name": "assistant_chat_messages_chat_id_created_idx", - "columns": [ - { - "expression": "chat_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "assistant_chat_messages_chat_id_assistant_chats_id_fk": { - "name": "assistant_chat_messages_chat_id_assistant_chats_id_fk", - "tableFrom": "assistant_chat_messages", - "tableTo": "assistant_chats", - "schemaTo": "sealai_assistant", - "columnsFrom": ["chat_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.assistant_chats": { - "name": "assistant_chats", - "schema": "sealai_assistant", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workspace_actor": { - "name": "workspace_actor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'Chat'" - }, - "title_ai_generated": { - "name": "title_ai_generated", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "assistant_chats_updated_at_idx": { - "name": "assistant_chats_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "assistant_chats_namespace_actor_updated_at_idx": { - "name": "assistant_chats_namespace_actor_updated_at_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "workspace_actor", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.assistant_devbox_runtimes": { - "name": "assistant_devbox_runtimes", - "schema": "sealai_assistant", - "columns": { - "upstream_id": { - "name": "upstream_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "runtime_name": { - "name": "runtime_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pause_due_at": { - "name": "pause_due_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "paused_at": { - "name": "paused_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "delete_due_at": { - "name": "delete_due_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "cleanup_lease_owner": { - "name": "cleanup_lease_owner", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cleanup_lease_expires_at": { - "name": "cleanup_lease_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "assistant_devbox_runtimes_pause_due_idx": { - "name": "assistant_devbox_runtimes_pause_due_idx", - "columns": [ - { - "expression": "pause_due_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"paused_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "assistant_devbox_runtimes_delete_due_idx": { - "name": "assistant_devbox_runtimes_delete_due_idx", - "columns": [ - { - "expression": "delete_due_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"delete_due_at\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.assistant_entitlements": { - "name": "assistant_entitlements", - "schema": "sealai_assistant", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "free_turns_used": { - "name": "free_turns_used", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "assistant_entitlements_updated_at_idx": { - "name": "assistant_entitlements_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.github_app_install_sessions": { - "name": "github_app_install_sessions", - "schema": "sealai_assistant", - "columns": { - "state": { - "name": "state", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workspace_actor": { - "name": "workspace_actor", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "owner_identity_version": { - "name": "owner_identity_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "return_path": { - "name": "return_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "github_app_install_sessions_expires_at_idx": { - "name": "github_app_install_sessions_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_app_install_sessions_namespace_idx": { - "name": "github_app_install_sessions_namespace_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.github_connections": { - "name": "github_connections", - "schema": "sealai_assistant", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'github_app'" - }, - "installation_id": { - "name": "installation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_login": { - "name": "account_login", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "account_type": { - "name": "account_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'User'" - }, - "repository_selection": { - "name": "repository_selection", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'all'" - }, - "installed_by_user_id": { - "name": "installed_by_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "github_connections_updated_at_idx": { - "name": "github_connections_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_connections_namespace_updated_at_idx": { - "name": "github_connections_namespace_updated_at_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_connections_namespace_unique_idx": { - "name": "github_connections_namespace_unique_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_connections_installation_idx": { - "name": "github_connections_installation_idx", - "columns": [ - { - "expression": "installation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.github_oauth_connections": { - "name": "github_oauth_connections", - "schema": "sealai_assistant", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "workspace_actor": { - "name": "workspace_actor", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "owner_identity_version": { - "name": "owner_identity_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "github_login": { - "name": "github_login", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token_ciphertext": { - "name": "access_token_ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_type": { - "name": "token_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'bearer'" - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "github_oauth_connections_updated_at_idx": { - "name": "github_oauth_connections_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_oauth_connections_github_login_idx": { - "name": "github_oauth_connections_github_login_idx", - "columns": [ - { - "expression": "github_login", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "github_oauth_connections_current_owner_unique_idx": { - "name": "github_oauth_connections_current_owner_unique_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "workspace_actor", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"sealai_assistant\".\"github_oauth_connections\".\"owner_identity_version\" = 2", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.identity_fingerprints": { - "name": "identity_fingerprints", - "schema": "sealai_assistant", - "columns": { - "cr_name": { - "name": "cr_name", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_uid": { - "name": "user_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "minted_at": { - "name": "minted_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "observed_at": { - "name": "observed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_assistant.identity_uid_canonicalizations": { - "name": "identity_uid_canonicalizations", - "schema": "sealai_assistant", - "columns": { - "user_uid": { - "name": "user_uid", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "canonical_user_uid": { - "name": "canonical_user_uid", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "identity_uid_canonicalizations_canonical_idx": { - "name": "identity_uid_canonicalizations_canonical_idx", - "columns": [ - { - "expression": "canonical_user_uid", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_deployment.deploy_task_agent_calls": { - "name": "deploy_task_agent_calls", - "schema": "sealai_deployment", - "columns": { - "task_id": { - "name": "task_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "call_id": { - "name": "call_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "lease_epoch": { - "name": "lease_epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "tool_name": { - "name": "tool_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request": { - "name": "request", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "request_hash": { - "name": "request_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "claim_owner": { - "name": "claim_owner", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "claim_expires_at": { - "name": "claim_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "attempt": { - "name": "attempt", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "response": { - "name": "response", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error_code": { - "name": "error_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "deploy_task_agent_calls_pending_idx": { - "name": "deploy_task_agent_calls_pending_idx", - "columns": [ - { - "expression": "task_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" = 'pending'", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deploy_task_agent_calls_task_id_deploy_tasks_id_fk": { - "name": "deploy_task_agent_calls_task_id_deploy_tasks_id_fk", - "tableFrom": "deploy_task_agent_calls", - "tableTo": "deploy_tasks", - "schemaTo": "sealai_deployment", - "columnsFrom": ["task_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "deploy_task_agent_calls_pk": { - "name": "deploy_task_agent_calls_pk", - "columns": ["task_id", "call_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "deploy_task_agent_calls_lease_epoch_nonnegative": { - "name": "deploy_task_agent_calls_lease_epoch_nonnegative", - "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"lease_epoch\" >= 0" - }, - "deploy_task_agent_calls_tool_name_valid": { - "name": "deploy_task_agent_calls_tool_name_valid", - "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"tool_name\" IN ('template_ready', 'deployment_completed')" - }, - "deploy_task_agent_calls_state_valid": { - "name": "deploy_task_agent_calls_state_valid", - "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" IN ('pending', 'running', 'succeeded', 'failed')" - }, - "deploy_task_agent_calls_request_hash_valid": { - "name": "deploy_task_agent_calls_request_hash_valid", - "value": "char_length(\"sealai_deployment\".\"deploy_task_agent_calls\".\"request_hash\") = 64" - } - }, - "isRLSEnabled": false - }, - "sealai_deployment.deploy_task_events": { - "name": "deploy_task_events", - "schema": "sealai_deployment", - "columns": { - "task_id": { - "name": "task_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "seq": { - "name": "seq", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": "nextval('sealai_deployment.deploy_task_events_seq'::regclass)" - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "phase": { - "name": "phase", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deploy_task_events_task_created_at_idx": { - "name": "deploy_task_events_task_created_at_idx", - "columns": [ - { - "expression": "task_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deploy_task_events_task_id_deploy_tasks_id_fk": { - "name": "deploy_task_events_task_id_deploy_tasks_id_fk", - "tableFrom": "deploy_task_events", - "tableTo": "deploy_tasks", - "schemaTo": "sealai_deployment", - "columnsFrom": ["task_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "deploy_task_events_pk": { - "name": "deploy_task_events_pk", - "columns": ["task_id", "seq"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_deployment.deploy_task_messages": { - "name": "deploy_task_messages", - "schema": "sealai_deployment", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "task_id": { - "name": "task_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parts": { - "name": "parts", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deploy_task_messages_task_id_idx": { - "name": "deploy_task_messages_task_id_idx", - "columns": [ - { - "expression": "task_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_task_messages_task_created_idx": { - "name": "deploy_task_messages_task_created_idx", - "columns": [ - { - "expression": "task_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "deploy_task_messages_task_id_deploy_tasks_id_fk": { - "name": "deploy_task_messages_task_id_deploy_tasks_id_fk", - "tableFrom": "deploy_task_messages", - "tableTo": "deploy_tasks", - "schemaTo": "sealai_deployment", - "columnsFrom": ["task_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_deployment.deploy_tasks": { - "name": "deploy_tasks", - "schema": "sealai_deployment", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_uid": { - "name": "project_uid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "project_name": { - "name": "project_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "creating_actor": { - "name": "creating_actor", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "marketing_attribution": { - "name": "marketing_attribution", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "credential_binding": { - "name": "credential_binding", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "actor_user_id": { - "name": "actor_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "github_connection_id": { - "name": "github_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prompt": { - "name": "prompt", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "target": { - "name": "target", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "runner": { - "name": "runner", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_from": { - "name": "created_from", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'api'" - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "phase": { - "name": "phase", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "runtime_provider": { - "name": "runtime_provider", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "runtime_name": { - "name": "runtime_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "runtime_state": { - "name": "runtime_state", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "runtime_paused_at": { - "name": "runtime_paused_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "runtime_cleanup_lease_owner": { - "name": "runtime_cleanup_lease_owner", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "runtime_cleanup_lease_expires_at": { - "name": "runtime_cleanup_lease_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "gateway_url": { - "name": "gateway_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gateway_session_id": { - "name": "gateway_session_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gateway_thread_id": { - "name": "gateway_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gateway_turn_id": { - "name": "gateway_turn_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gateway_state_snapshot": { - "name": "gateway_state_snapshot", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "agent_turn_count": { - "name": "agent_turn_count", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "agent_protocol": { - "name": "agent_protocol", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'mcp-v1'" - }, - "agent_control_token_hash": { - "name": "agent_control_token_hash", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "agent_control_token_revoked_at": { - "name": "agent_control_token_revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "agent_template_digest": { - "name": "agent_template_digest", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "agent_input_schema_digest": { - "name": "agent_input_schema_digest", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "agent_checkpoint_id": { - "name": "agent_checkpoint_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "agent_input_revision": { - "name": "agent_input_revision", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "agent_completion_receipt": { - "name": "agent_completion_receipt", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "failure_details": { - "name": "failure_details", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "artifact_summary": { - "name": "artifact_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "canvas_projection": { - "name": "canvas_projection", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "timeline_snapshot": { - "name": "timeline_snapshot", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "blocking_inputs": { - "name": "blocking_inputs", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "preview_url": { - "name": "preview_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result_url": { - "name": "result_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cancel_requested_at": { - "name": "cancel_requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "lease_owner": { - "name": "lease_owner", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lease_epoch": { - "name": "lease_epoch", - "type": "bigint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "lease_claimed_at": { - "name": "lease_claimed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "lease_expires_at": { - "name": "lease_expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "retried_from_task_id": { - "name": "retried_from_task_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "deploy_tasks_agent_control_token_hash_idx": { - "name": "deploy_tasks_agent_control_token_hash_idx", - "columns": [ - { - "expression": "agent_control_token_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_namespace_updated_at_idx": { - "name": "deploy_tasks_namespace_updated_at_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_project_uid_updated_at_idx": { - "name": "deploy_tasks_project_uid_updated_at_idx", - "columns": [ - { - "expression": "project_uid", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_status_updated_at_idx": { - "name": "deploy_tasks_status_updated_at_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_leased_expiry_idx": { - "name": "deploy_tasks_leased_expiry_idx", - "columns": [ - { - "expression": "lease_expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('running', 'applying')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_queued_created_idx": { - "name": "deploy_tasks_queued_created_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" = 'queued'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_terminal_completed_idx": { - "name": "deploy_tasks_terminal_completed_idx", - "columns": [ - { - "expression": "completed_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('completed', 'failed', 'cancelled')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_runtime_paused_idx": { - "name": "deploy_tasks_runtime_paused_idx", - "columns": [ - { - "expression": "runtime_paused_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"runtime_provider\" = 'devbox' AND lower(coalesce(\"sealai_deployment\".\"deploy_tasks\".\"runtime_state\", '')) IN ('paused', 'archived')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "deploy_tasks_one_active_clone_idx": { - "name": "deploy_tasks_one_active_clone_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "retried_from_task_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"sealai_deployment\".\"deploy_tasks\".\"retried_from_task_id\" IS NOT NULL AND \"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('queued', 'running', 'blocked', 'applying')", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "deploy_tasks_agent_turn_count_nonnegative": { - "name": "deploy_tasks_agent_turn_count_nonnegative", - "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_turn_count\" >= 0" - }, - "deploy_tasks_agent_input_revision_nonnegative": { - "name": "deploy_tasks_agent_input_revision_nonnegative", - "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_input_revision\" >= 0" - }, - "deploy_tasks_agent_protocol_valid": { - "name": "deploy_tasks_agent_protocol_valid", - "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_protocol\" IN ('mcp-v1')" - }, - "deploy_tasks_agent_control_token_hash_valid": { - "name": "deploy_tasks_agent_control_token_hash_valid", - "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NULL OR char_length(\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\") = 64" - } - }, - "isRLSEnabled": false - }, - "sealai_onboarding.onboarding_profiles": { - "name": "onboarding_profiles", - "schema": "sealai_onboarding", - "columns": { - "user_uid": { - "name": "user_uid", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dismissed_at_step": { - "name": "dismissed_at_step", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "role_type": { - "name": "role_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role_other_text": { - "name": "role_other_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "usage_context": { - "name": "usage_context", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "usage_other_text": { - "name": "usage_other_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "priority_tags": { - "name": "priority_tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "priority_display_order": { - "name": "priority_display_order", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "priority_other_text": { - "name": "priority_other_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "open_goal_text": { - "name": "open_goal_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_marketing.attribution_subjects": { - "name": "attribution_subjects", - "schema": "sealai_marketing", - "columns": { - "subject_type": { - "name": "subject_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "subject_id": { - "name": "subject_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "first_touch": { - "name": "first_touch", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "last_touch": { - "name": "last_touch", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "gclid": { - "name": "gclid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gbraid": { - "name": "gbraid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "wbraid": { - "name": "wbraid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ad_personalization": { - "name": "ad_personalization", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unspecified'" - }, - "ad_user_data_consent": { - "name": "ad_user_data_consent", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unspecified'" - }, - "click_id_candidates": { - "name": "click_id_candidates", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "consent_provenance": { - "name": "consent_provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "attribution_subjects_pk": { - "name": "attribution_subjects_pk", - "columns": ["subject_type", "subject_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_marketing.lifecycle_events": { - "name": "lifecycle_events", - "schema": "sealai_marketing", - "columns": { - "event_id": { - "name": "event_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "event_name": { - "name": "event_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "deployment_id": { - "name": "deployment_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "first_touch": { - "name": "first_touch", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "last_touch": { - "name": "last_touch", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "gclid": { - "name": "gclid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "gbraid": { - "name": "gbraid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "wbraid": { - "name": "wbraid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "ad_personalization": { - "name": "ad_personalization", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unspecified'" - }, - "ad_user_data_consent": { - "name": "ad_user_data_consent", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unspecified'" - }, - "click_id_candidates": { - "name": "click_id_candidates", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "consent_provenance": { - "name": "consent_provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "hashed_user_data": { - "name": "hashed_user_data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "transaction_id": { - "name": "transaction_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "currency": { - "name": "currency", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "value": { - "name": "value", - "type": "numeric(24, 6)", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "lifecycle_events_action_transaction_idx": { - "name": "lifecycle_events_action_transaction_idx", - "columns": [ - { - "expression": "event_name", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "transaction_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"sealai_marketing\".\"lifecycle_events\".\"transaction_id\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_notification.notification_messages": { - "name": "notification_messages", - "schema": "sealai_notification", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_uid": { - "name": "user_uid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_uid": { - "name": "project_uid", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "dedupe_key": { - "name": "dedupe_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "released_at": { - "name": "released_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "notification_messages_live_dedupe_key_idx": { - "name": "notification_messages_live_dedupe_key_idx", - "columns": [ - { - "expression": "dedupe_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"sealai_notification\".\"notification_messages\".\"released_at\" IS NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_messages_namespace_created_at_idx": { - "name": "notification_messages_namespace_created_at_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_messages_user_uid_created_at_idx": { - "name": "notification_messages_user_uid_created_at_idx", - "columns": [ - { - "expression": "user_uid", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notification_messages_created_at_idx": { - "name": "notification_messages_created_at_idx", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_notification.notification_read_receipts": { - "name": "notification_read_receipts", - "schema": "sealai_notification", - "columns": { - "user_uid": { - "name": "user_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message_key": { - "name": "message_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message_id": { - "name": "message_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "read_at": { - "name": "read_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "notification_read_receipts_message_id_idx": { - "name": "notification_read_receipts_message_id_idx", - "columns": [ - { - "expression": "message_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "notification_read_receipts_message_id_notification_messages_id_fk": { - "name": "notification_read_receipts_message_id_notification_messages_id_fk", - "tableFrom": "notification_read_receipts", - "tableTo": "notification_messages", - "schemaTo": "sealai_notification", - "columnsFrom": ["message_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "notification_read_receipts_user_uid_message_key_pk": { - "name": "notification_read_receipts_user_uid_message_key_pk", - "columns": ["user_uid", "message_key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.project_canvas_layouts": { - "name": "project_canvas_layouts", - "schema": "sealai_project", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_uid": { - "name": "project_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_name_snapshot": { - "name": "project_name_snapshot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "nodes": { - "name": "nodes", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_canvas_layouts_updated_at_idx": { - "name": "project_canvas_layouts_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "project_canvas_layouts_pk": { - "name": "project_canvas_layouts_pk", - "columns": ["namespace", "project_uid"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.project_delete_operations": { - "name": "project_delete_operations", - "schema": "sealai_project", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_uid": { - "name": "actor_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "preview_id": { - "name": "preview_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_delete_operations_expires_at_idx": { - "name": "project_delete_operations_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "project_delete_operations_pk": { - "name": "project_delete_operations_pk", - "columns": ["namespace", "project_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.project_delete_previews": { - "name": "project_delete_previews", - "schema": "sealai_project", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_uid": { - "name": "actor_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "chat_id": { - "name": "chat_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_summary": { - "name": "resource_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "fingerprint": { - "name": "fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "consumed_at": { - "name": "consumed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_delete_previews_expires_at_idx": { - "name": "project_delete_previews_expires_at_idx", - "columns": [ - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "project_delete_previews_target_idx": { - "name": "project_delete_previews_target_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "project_delete_previews_pk": { - "name": "project_delete_previews_pk", - "columns": ["id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.project_management_audit_events": { - "name": "project_management_audit_events", - "schema": "sealai_project", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_uid": { - "name": "actor_uid", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "chat_id": { - "name": "chat_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_summary": { - "name": "resource_summary", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "failure_code": { - "name": "failure_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_management_audit_events_target_idx": { - "name": "project_management_audit_events_target_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "project_management_audit_events_pk": { - "name": "project_management_audit_events_pk", - "columns": ["id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.project_navigation_preferences": { - "name": "project_navigation_preferences", - "schema": "sealai_project", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pinned_project_ids": { - "name": "pinned_project_ids", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "project_navigation_preferences_updated_at_idx": { - "name": "project_navigation_preferences_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "project_navigation_preferences_pk": { - "name": "project_navigation_preferences_pk", - "columns": ["namespace"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "sealai_project.projects": { - "name": "projects", - "schema": "sealai_project", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "projects_namespace_lower_display_name_idx": { - "name": "projects_namespace_lower_display_name_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "lower(\"display_name\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "projects_updated_at_idx": { - "name": "projects_updated_at_idx", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "projects_pk": { - "name": "projects_pk", - "columns": ["namespace", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": { - "sealai_assistant": "sealai_assistant", - "sealai_deployment": "sealai_deployment", - "sealai_onboarding": "sealai_onboarding", - "sealai_marketing": "sealai_marketing", - "sealai_notification": "sealai_notification", - "sealai_project": "sealai_project" - }, - "sequences": { - "sealai_deployment.deploy_task_events_seq": { - "name": "deploy_task_events_seq", - "schema": "sealai_deployment", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "9223372036854775807", - "cache": "1", - "cycle": false - } - }, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/ui/drizzle/meta/_journal.json b/apps/ui/drizzle/meta/_journal.json index 8313f0f5..8f8d3fff 100644 --- a/apps/ui/drizzle/meta/_journal.json +++ b/apps/ui/drizzle/meta/_journal.json @@ -110,16 +110,9 @@ { "idx": 15, "version": "7", - "when": 1787841043876, + "when": 1787883888064, "tag": "0015_notification_center", "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1787882489078, - "tag": "0016_notification_account_scope", - "breakpoints": true } ] } From 3a24a0989114fecdb3c15782ab7ed56a21dd9345 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 10:39:15 +0800 Subject: [PATCH 09/14] feat(notifications): redesign the Notification Center panel around Severity Replace the kind axis (and the producer-less deploy-* kinds) with a Notification Severity derived from the message: the debt ladder and every workspace-debt stage are critical, low-balance tiers and quota exhaustion warn, receipts, hints, and announcements are info. The panel now renders the body (clamped; click expands and marks read), colors icon and CTA chip by severity with the Status Hint's tint recipe, filters with a SlidingToggle, offers 'Mark all as read' as a text button, shows short relative time with an absolute tooltip, and keeps a floor height so an empty inbox and a full one read as the same surface. CONTEXT.md gains the Severity term. Prototype variants live on prototype/notification-center-variants. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GStxUVYCv8odwpRXtc3gUy --- CONTEXT.md | 10 +- .../notification-fixtures.test.ts | 4 +- .../features/notifications/feed-model.test.ts | 33 ++- .../src/features/notifications/feed-model.ts | 50 +++- .../notifications/notification-time.test.ts | 18 +- .../notifications/notification-time.ts | 30 +- .../notifications/read-dispatch.test.ts | 2 +- .../app-sidebar-notifications-model.test.ts | 4 +- .../shell/app-sidebar-notifications-model.ts | 15 +- .../shell/app-sidebar-notifications.tsx | 276 +++++++++++------- 10 files changed, 272 insertions(+), 170 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index c7e9c33e..7cac283c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -720,10 +720,16 @@ _Avoid_: task center, activity feed, message center, alerts panel. ### Notification -One message addressed to the current user in the Notification Center: a system event (a deployment outcome, a database event), a billing or quota event, or a product announcement. Persistent and individually read/unread — read state is per message and per user, never a workspace-shared fact — which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. A Notification originates from the platform or from Brain itself; the two read identically in the Notification Center, though a platform-origin message may be withdrawn or revived by the platform when its underlying condition changes. +One message addressed to the current user in the Notification Center: a billing, subscription, or quota event, or a platform announcement, each carrying a Notification Severity. Persistent and individually read/unread — read state is per message and per user, never a workspace-shared fact — which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. A Notification originates from the platform or from Brain itself; the two read identically in the Notification Center, though a platform-origin message may be withdrawn or revived by the platform when its underlying condition changes. _Avoid_: alert, toast (for persistent items), event (for the user-facing message). +### Notification Severity + +How much a Notification's message matters to the reader, on three levels — **critical** (something is already suspended or faces deletion), **warning** (a threshold was crossed or a deadline approaches; action prevents the next stage), **info** (a receipt, a hint, or an announcement; nothing to fix). Derived from what the message is about, never chosen per message, and shown without visual escalation: a critical item is marked, not shouted. + +_Avoid_: priority, importance (the platform CR field), level, tone. + ### Status Hint The one banner at the top of the content area that explains a billing state while it holds — payment-due (under the Deletion Countdown), Account Debt, a full workspace quota, or an Active Free Trial about to end — and offers the way out. It is a state, not a message: it appears and vanishes with the condition, writes nothing to the Notification Center, and only the most severe holding state shows. The destructive states cannot be dismissed; a dismissed quota or trial hint stays hidden until its state ends and re-enters. @@ -766,7 +772,7 @@ _Avoid_: indicator capsule, FAB. ### Dev Mock -A dev/demo-only mode in which one feature's API answers are served from fixtures according to the selected Mock Scenario. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state. +A dev/demo-only mode in which a feature's API answers are served from fixtures according to the selected Mock Scenario. One Dev Mock answers for every surface that derives from the same facts — the billing mock also serves the billing-born Notifications and, through them, the Status Hint — so those surfaces can never disagree; surfaces with unrelated facts (platform-origin Notifications, a Deployment Task Timeline, a Conversation) get Dev Mocks of their own, and independent Dev Mocks compose. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state. _Avoid_: mock group, mock tweak, mock override. diff --git a/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts b/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts index 764975c3..57b2d397 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts @@ -131,7 +131,9 @@ test("payg-debt-final: the whole account debt ladder, overridden, with only the test("active: an unknown platform name falls back to its own text beside the upgrade receipt", async () => { const merged = await mergedFor("active"); - const announcement = merged.find((item) => item.kind === "announcement"); + const announcement = merged.find( + (item) => item.source === "cr" && item.severity === "info" + ); assert.equal(announcement?.cta, undefined); assert.ok(announcement?.body, "the original body survives"); assert.ok( diff --git a/apps/ui/src/features/notifications/feed-model.test.ts b/apps/ui/src/features/notifications/feed-model.test.ts index 52185494..55e41689 100644 --- a/apps/ui/src/features/notifications/feed-model.test.ts +++ b/apps/ui/src/features/notifications/feed-model.test.ts @@ -6,7 +6,7 @@ import type { NotificationCRItem } from "@workspace/api/hooks"; import { isGiftOnlyNewcomer, mergeNotificationFeed, - notificationKindForCR, + notificationSeverityForCR, renderNotificationMessage, } from "./feed-model"; import { crNotificationId, notificationSource } from "./notification-ids"; @@ -84,7 +84,7 @@ test("both streams merge into one list, newest first, with source-prefixed ids", ); }); -test("platform copy outside the override table is shown as-is and kinds follow the upstream sender", () => { +test("platform copy outside the override table is shown as-is and severity follows name and sender", () => { const [item] = mergeNotificationFeed({ crItems: [cr({ name: "debt-choice-newtier" })], dbMessages: [], @@ -95,26 +95,33 @@ test("platform copy outside the override table is shown as-is and kinds follow t item?.body, "Your account balance is exhausted; services are suspended." ); - assert.equal(item?.kind, "billing"); + assert.equal(item?.severity, "critical"); assert.equal(item?.crName, "debt-choice-newtier"); assert.equal( - notificationKindForCR({ from: "Debt-System", name: "x" }), - "billing" + notificationSeverityForCR({ from: "Debt-System", name: "x" }), + "warning" ); assert.equal( - notificationKindForCR({ name: "workspace-debt-debt-pre-deletion" }), - "billing" + notificationSeverityForCR({ name: "workspace-debt-debt-pre-deletion" }), + "critical" ); assert.equal( - notificationKindForCR({ from: "Admin", name: "release-notes" }), - "announcement" + notificationSeverityForCR({ + from: "Debt-System", + name: "debt-choice-lowbalanceperiod", + }), + "warning" + ); + assert.equal( + notificationSeverityForCR({ from: "Admin", name: "release-notes" }), + "info" ); }); test("Brain entries render from their payload", () => { const rendered = renderNotificationMessage(dbMessage({})); - assert.equal(rendered.kind, "quota"); + assert.equal(rendered.severity, "warning"); assert.equal(rendered.title, "Storage quota is full"); assert.match(rendered.body, QUOTA_BODY_RE); assert.equal( @@ -209,7 +216,7 @@ test("the 8 fixed platform names render Brain-voiced copy and a CTA; the CR text "Resources can be permanently deleted at any time. This cannot be undone." ); assert.deepEqual(item?.cta, { href: "/billing", label: "Renew plan" }); - assert.equal(item?.kind, "billing"); + assert.equal(item?.severity, "critical"); assert.equal(item?.crName, "workspace-debt-debtfinaldeletion"); assert.equal(source.message, "Radical resource release", "display-only"); @@ -348,7 +355,7 @@ test("the gift hint renders the reassuring welcome copy with no CTA", () => { payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, }) ); - assert.equal(rendered.kind, "billing"); + assert.equal(rendered.severity, "info"); assert.equal(rendered.title, "You have a $1 welcome gift"); assert.equal( rendered.body, @@ -385,7 +392,7 @@ test("subscription-change receipts are one factual sentence per change, no CTA", }, }) ); - assert.equal(upgraded.kind, "billing"); + assert.equal(upgraded.severity, "info"); assert.equal(upgraded.title, "Subscription upgraded"); assert.equal(upgraded.body, "This workspace is now on Pro."); assert.equal(upgraded.cta, undefined); diff --git a/apps/ui/src/features/notifications/feed-model.ts b/apps/ui/src/features/notifications/feed-model.ts index d728ec88..e1a1c147 100644 --- a/apps/ui/src/features/notifications/feed-model.ts +++ b/apps/ui/src/features/notifications/feed-model.ts @@ -3,8 +3,8 @@ import type { NotificationCRItem } from "@workspace/api/hooks"; import { MICRO_UNITS_PER_CURRENCY_UNIT } from "@/features/billing/billing-amount"; import type { AppNotification, - AppNotificationKind, NotificationCTA, + NotificationSeverity, } from "@/features/shell/app-sidebar-notifications-model"; import { CR_OVERRIDES, GIFT_FILTERED_CR_NAMES } from "./cr-overrides"; @@ -30,17 +30,35 @@ const BILLING_SENDERS: ReadonlySet = new Set([ "Workspace-Subscription-System", ]); -export function notificationKindForCR( +/** The low-balance warning tiers: a threshold crossed, nothing suspended yet. */ +const WARNING_TIER_CR_NAMES: ReadonlySet = new Set([ + "debt-choice-lowbalanceperiod", + "debt-choice-criticalbalanceperiod", +]); + +/** + * A platform message's Severity from its fixed name and sender: the debt + * ladder from `debtperiod` on and every `workspace-debt-*` stage mean + * something is already suspended or faces deletion (critical); the + * low-balance tiers and any other billing-controller message warn; anything + * else is an announcement (info). + */ +export function notificationSeverityForCR( item: Pick -): AppNotificationKind { - if ( - (item.from != null && BILLING_SENDERS.has(item.from)) || - item.name.startsWith("debt-choice-") || - item.name.startsWith("workspace-debt-") - ) { - return "billing"; +): NotificationSeverity { + if (item.name.startsWith("workspace-debt-")) { + return "critical"; + } + if (WARNING_TIER_CR_NAMES.has(item.name)) { + return "warning"; + } + if (item.name.startsWith("debt-choice-")) { + return "critical"; + } + if (item.from != null && BILLING_SENDERS.has(item.from)) { + return "warning"; } - return "announcement"; + return "info"; } const QUOTA_RESOURCE_LABELS: Record = { @@ -54,7 +72,7 @@ const QUOTA_RESOURCE_LABELS: Record = { interface RenderedNotification { body: string; cta?: NotificationCTA; - kind: AppNotificationKind; + severity: NotificationSeverity; title: string; } @@ -121,7 +139,7 @@ const RENDERERS: { date === "" ? "It covers your first deployments and expires a month after it was granted." : `It covers your first deployments and expires on ${date}.`, - kind: "billing", + severity: "info", title: `You have a ${wholeDollars(payload.giftMicroUnits)} welcome gift`, }; }, @@ -130,13 +148,13 @@ const RENDERERS: { return { body: `${label} is at 100%. New deployments can't start.`, cta: { href: "/billing/usage", label: "View usage" }, - kind: "quota", + severity: "warning", title: `${label} quota is full`, }; }, "subscription-change": (payload) => ({ body: subscriptionChangeBody(payload), - kind: "billing", + severity: "info", title: SUBSCRIPTION_CHANGE_TITLES[payload.change], }), }; @@ -200,7 +218,7 @@ export function platformNotification( crName: item.name, ...(override == null ? {} : { cta: override.cta }), id, - kind: notificationKindForCR(item), + severity: notificationSeverityForCR(item), source: "cr", timestamp: item.timestamp * 1000, title: override?.title ?? item.title, @@ -220,7 +238,7 @@ export function brainNotification( body: rendered.body, ...(rendered.cta == null ? {} : { cta: rendered.cta }), id, - kind: rendered.kind, + severity: rendered.severity, source: "db", timestamp: message.createdAt, title: rendered.title, diff --git a/apps/ui/src/features/notifications/notification-time.test.ts b/apps/ui/src/features/notifications/notification-time.test.ts index a873649e..7ad627b5 100644 --- a/apps/ui/src/features/notifications/notification-time.test.ts +++ b/apps/ui/src/features/notifications/notification-time.test.ts @@ -10,18 +10,18 @@ const DAY = 24 * HOUR; const SHORT_DATE_RE = /^[A-Z][a-z]{2} \d{1,2}$/; test("relative time steps from just now through days", () => { - assert.equal(formatNotificationTime(NOW - 10_000, NOW), "Just now"); - assert.equal(formatNotificationTime(NOW - 2 * MINUTE, NOW), "2m ago"); - assert.equal(formatNotificationTime(NOW - 26 * MINUTE, NOW), "26m ago"); - assert.equal(formatNotificationTime(NOW - HOUR, NOW), "1h ago"); - assert.equal(formatNotificationTime(NOW - 23 * HOUR, NOW), "23h ago"); - assert.equal(formatNotificationTime(NOW - 30 * HOUR, NOW), "Yesterday"); - assert.equal(formatNotificationTime(NOW - 2 * DAY, NOW), "2d ago"); - assert.equal(formatNotificationTime(NOW - 6 * DAY, NOW), "6d ago"); + assert.equal(formatNotificationTime(NOW - 10_000, NOW), "now"); + assert.equal(formatNotificationTime(NOW - 2 * MINUTE, NOW), "2m"); + assert.equal(formatNotificationTime(NOW - 26 * MINUTE, NOW), "26m"); + assert.equal(formatNotificationTime(NOW - HOUR, NOW), "1h"); + assert.equal(formatNotificationTime(NOW - 23 * HOUR, NOW), "23h"); + assert.equal(formatNotificationTime(NOW - 30 * HOUR, NOW), "1d"); + assert.equal(formatNotificationTime(NOW - 2 * DAY, NOW), "2d"); + assert.equal(formatNotificationTime(NOW - 6 * DAY, NOW), "6d"); }); test("older than a week falls back to a short date; the future reads as now", () => { assert.match(formatNotificationTime(NOW - 30 * DAY, NOW), SHORT_DATE_RE); - assert.equal(formatNotificationTime(NOW + HOUR, NOW), "Just now"); + assert.equal(formatNotificationTime(NOW + HOUR, NOW), "now"); assert.equal(formatNotificationTime(Number.NaN, NOW), ""); }); diff --git a/apps/ui/src/features/notifications/notification-time.ts b/apps/ui/src/features/notifications/notification-time.ts index 7eb7c709..efe0be3c 100644 --- a/apps/ui/src/features/notifications/notification-time.ts +++ b/apps/ui/src/features/notifications/notification-time.ts @@ -1,8 +1,9 @@ import { DAY_MS, HOUR_MS, MINUTE_MS } from "@/lib/time"; /** - * Relative time for a Notification row, from a real timestamp (epoch ms): - * "Just now", "26m ago", "3h ago", "Yesterday", "5d ago", then a short date. + * The short relative time on a Notification row, from a real timestamp + * (epoch ms): "now", "26m", "3h", "5d", then a short date. The row pairs it + * with `formatNotificationTimestamp` as a tooltip for the exact moment. */ export function formatNotificationTime( timestamp: number, @@ -13,19 +14,16 @@ export function formatNotificationTime( } const elapsed = Math.max(0, now - timestamp); if (elapsed < MINUTE_MS) { - return "Just now"; + return "now"; } if (elapsed < HOUR_MS) { - return `${Math.floor(elapsed / MINUTE_MS)}m ago`; + return `${Math.floor(elapsed / MINUTE_MS)}m`; } if (elapsed < DAY_MS) { - return `${Math.floor(elapsed / HOUR_MS)}h ago`; - } - if (elapsed < 2 * DAY_MS) { - return "Yesterday"; + return `${Math.floor(elapsed / HOUR_MS)}h`; } if (elapsed < 7 * DAY_MS) { - return `${Math.floor(elapsed / DAY_MS)}d ago`; + return `${Math.floor(elapsed / DAY_MS)}d`; } return new Date(timestamp).toLocaleDateString("en-US", { day: "numeric", @@ -33,6 +31,20 @@ export function formatNotificationTime( }); } +/** The absolute moment behind a row's short time ("Aug 21, 2026, 3:05 PM"). */ +export function formatNotificationTimestamp(timestamp: number): string { + if (!Number.isFinite(timestamp)) { + return ""; + } + return new Date(timestamp).toLocaleString("en-US", { + day: "numeric", + hour: "numeric", + minute: "2-digit", + month: "short", + year: "numeric", + }); +} + /** * An absolute date for Notification bodies (design spec §10 rule 5, "Sep 3" * style); an unparsable instant renders as an empty string so a sentence diff --git a/apps/ui/src/features/notifications/read-dispatch.test.ts b/apps/ui/src/features/notifications/read-dispatch.test.ts index d2335c22..d3445183 100644 --- a/apps/ui/src/features/notifications/read-dispatch.test.ts +++ b/apps/ui/src/features/notifications/read-dispatch.test.ts @@ -8,7 +8,7 @@ import { planReadDispatch, shouldSyncCRReadLabel } from "./read-dispatch"; function item(overrides: Partial): AppNotification { return { id: "db:m1", - kind: "quota", + severity: "warning", source: "db", timestamp: 0, title: "Storage quota is full", diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts index b63d7b1b..f368fecb 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts @@ -12,10 +12,10 @@ import { function item(overrides: Partial): AppNotification { return { id: "db:n1", - kind: "deploy-success", + severity: "info", source: "db", timestamp: Date.UTC(2026, 7, 27, 12, 0, 0), - title: "Deployment complete", + title: "Subscription upgraded", unread: false, ...overrides, }; diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts index 6a7c3a00..ebc2201e 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts @@ -7,12 +7,13 @@ * by real time. */ -export type AppNotificationKind = - | "announcement" - | "billing" - | "deploy-failure" - | "deploy-success" - | "quota"; +/** + * Notification Severity: how much the message matters, derived from what it + * is about — critical (already suspended or facing deletion), warning (a + * threshold crossed or a deadline near; action prevents the next stage), + * info (a receipt, a hint, an announcement). Shown without escalation. + */ +export type NotificationSeverity = "critical" | "warning" | "info"; /** Which stream a Notification came from; also its id prefix. */ export type AppNotificationSource = "cr" | "db"; @@ -40,8 +41,8 @@ export interface AppNotification { crName?: string; cta?: NotificationCTA; id: string; - kind: AppNotificationKind; project?: string; + severity: NotificationSeverity; source: AppNotificationSource; timestamp: number; title: string; diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.tsx index ce13ba5a..23ee8bba 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications.tsx @@ -7,127 +7,158 @@ import { PopoverTrigger, } from "@workspace/ui/components/popover"; import { useSidebar } from "@workspace/ui/components/sidebar"; +import { SlidingToggle } from "@workspace/ui/components/sliding-toggle"; import { cn } from "@workspace/ui/lib/utils"; import { Bell, - CheckCheck, - CircleCheck, - CircleX, - CreditCard, - Gauge, + CircleAlert, + Info, type LucideIcon, - Megaphone, + TriangleAlert, } from "lucide-react"; import Link from "next/link"; import { useRef, useState } from "react"; import { BillingDevMockGate } from "@/features/billing/billing-dev-mock-gate"; import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; -import { formatNotificationTime } from "@/features/notifications/notification-time"; +import { + formatNotificationTime, + formatNotificationTimestamp, +} from "@/features/notifications/notification-time"; import { type NotificationFeed, useNotificationFeed, } from "@/features/notifications/use-notification-feed"; import { type AppNotification, - type AppNotificationKind, isNotificationUnread, + type NotificationSeverity, type NotificationTab, notificationBadgeLabel, visibleNotifications, } from "@/features/shell/app-sidebar-notifications-model"; import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-toggle"; -const KIND_META: Record< - AppNotificationKind, +/** + * Severity is marked, never shouted (CONTEXT.md, Notification Severity): the + * icon and the CTA chip take the semantic color, the same three hues as the + * Status Hint's tint recipe; the card itself stays neutral at every level. + */ +const SEVERITY_META: Record< + NotificationSeverity, { icon: LucideIcon; tint: string } > = { - announcement: { icon: Megaphone, tint: "text-blue-400" }, - billing: { icon: CreditCard, tint: "text-neutral-300" }, - "deploy-failure": { icon: CircleX, tint: "text-red-400" }, - "deploy-success": { icon: CircleCheck, tint: "text-emerald-400" }, - quota: { icon: Gauge, tint: "text-amber-400" }, + critical: { icon: CircleAlert, tint: "text-destructive" }, + info: { icon: Info, tint: "text-blue-600 dark:text-blue-400" }, + warning: { icon: TriangleAlert, tint: "text-amber-600 dark:text-amber-400" }, }; -function NotificationKindIcon({ kind }: { kind: AppNotificationKind }) { - const meta = KIND_META[kind]; - const Icon = meta.icon; +function NotificationTime({ timestamp }: { timestamp: number }) { return ( - - - + ); } -function NotificationRow({ +function NotificationCard({ + expanded, item, onRead, + onToggle, unread, }: { + expanded: boolean; item: AppNotification; onRead: () => void; + onToggle: () => void; unread: boolean; }) { - const meta = [item.project, formatNotificationTime(item.timestamp)] - .filter(Boolean) - .join(" · "); - // The CTA is the row's one way out (design spec §10): a sibling link, not - // a nested control, so the row stays a plain button. Billing CTAs record - // the return route first so the Billing Area's close button comes back. + const meta = SEVERITY_META[item.severity]; + const Icon = meta.icon; + // The CTA is the card's one way out (design spec §10): a sibling link + // below the message, not a control nested in the row button. Billing CTAs + // record the return route first so the Billing Area's close button comes + // back here. const cta = item.cta; return (
+ {/* Click = read it: expands a clamped body and marks the item read. */} - {cta == null ? null : ( - { - if (cta.href.startsWith("/billing")) { - recordBillingReturnRoute(); - } - onRead(); - }} - > - {cta.label} - - } - size="sm" - variant="secondary" - /> - )} +
+ {cta == null ? null : ( + { + if (cta.href.startsWith("/billing")) { + recordBillingReturnRoute(); + } + onRead(); + }} + > + {cta.label} + + } + size="sm" + variant="secondary" + /> + )} +
@@ -135,9 +166,10 @@ function NotificationRow({ ); } -function NotificationsEmptyState() { +function NotificationsEmptyState({ tab }: { tab: NotificationTab }) { + const nothingYet = tab === "all"; return ( -
+
- No notifications + {nothingYet ? "No notifications yet" : "You're all caught up"} - You're all caught up. + {nothingYet + ? "Billing, quota, and platform messages land here." + : "Nothing unread."}
); @@ -158,60 +192,80 @@ function NotificationsEmptyState() { function NotificationsPanel({ feed }: { feed: NotificationFeed }) { const { items, markAllRead, markRead, readIds, unreadCount } = feed; const [tab, setTab] = useState("all"); + const [expanded, setExpanded] = useState>( + () => new Set() + ); const visible = visibleNotifications(items, tab, readIds); + const toggle = (item: AppNotification) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(item.id)) { + next.delete(item.id); + } else { + next.add(item.id); + } + return next; + }); + markRead(item); + }; + return ( <> -
+
Notifications - + Mark all as read +
-
- {( - [ - { key: "all", label: "All" }, +
+ 0 ? `Unread ${unreadCount}` : "Unread", + label: ( + + Unread + {unreadCount > 0 ? ( + + {unreadCount} + + ) : null} + + ), + value: "unread", }, - ] as const - ).map((entry) => ( - - ))} + ]} + size="sm" + value={tab} + width="full" + />
{visible.length === 0 ? ( - + ) : ( -
-
+
+
{visible.map((item) => ( - markRead(item)} + onToggle={() => toggle(item)} unread={isNotificationUnread(item, readIds)} /> ))} @@ -317,11 +371,13 @@ export function AppSidebarNotifications() { {/* Collapsed rail: anchor the icon slot, sideOffset 6 (the rail-wide convention for popovers) — the default trigger anchor sits at the - clipped full-width row's edge, far past the visible rail. */} + clipped full-width row's edge, far past the visible rail. The + panel keeps a floor height so an empty inbox and a one-item inbox + read as the same surface. */} From f78062dc8757c52b2c9637394da8d168fd31375a Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 11:00:31 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix(notifications):=20review=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20merge=20re-keys=20messages,=20retrying=20observa?= =?UTF-8?q?tions,=20panel=20UI=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account merge now re-keys account-scoped notification_messages (user_uid and the uid-bearing dedupe key) to the survivor, collapsing onto a live survivor row; produce() re-checks the identity binding for account-scoped rows like markRead does, and the observation routes map a superseded binding to 401. - The gift-hint observation latch clears on failure and every credits poll is an observation opportunity, so a failed report no longer hides D4 for the session. - Workspace quota is observed on the Status Hint's polling cadence through a shared observeWorkspaceQuotaForInbox that also refreshes the inbox, so the banner and the inbox see the same snapshot. - Interaction test pins that the 8 CR overrides and A1/D4/B5 render title, body, and CTA in the panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LLcM4LbLxbAr6aFFUwNx2n --- .../features/notifications/http-handlers.ts | 11 +- .../producer-credit-hint.test.ts | 24 +- .../notifications/producer-credit-hint.ts | 9 +- .../producers-through-fixtures.test.ts | 7 +- .../notifications/quota-observation.test.ts | 100 ++++++++ .../notifications/quota-observation.ts | 72 ++++++ .../src/features/notifications/store.test.ts | 37 ++- apps/ui/src/features/notifications/store.ts | 34 ++- .../notifications/use-notification-feed.ts | 76 +++--- ...sidebar-notifications.interaction.test.tsx | 223 ++++++++++++++++++ .../shell/app-sidebar-notifications.tsx | 4 +- apps/ui/src/features/shell/app-sidebar.tsx | 23 +- .../features/status-hint/use-status-hint.ts | 16 +- .../src/lib/identity-fingerprint-core.test.ts | 183 ++++++++++++++ apps/ui/src/lib/identity-fingerprint-core.ts | 45 +++- 15 files changed, 796 insertions(+), 68 deletions(-) create mode 100644 apps/ui/src/features/notifications/quota-observation.test.ts create mode 100644 apps/ui/src/features/notifications/quota-observation.ts create mode 100644 apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx diff --git a/apps/ui/src/features/notifications/http-handlers.ts b/apps/ui/src/features/notifications/http-handlers.ts index cdf9971a..4531aea5 100644 --- a/apps/ui/src/features/notifications/http-handlers.ts +++ b/apps/ui/src/features/notifications/http-handlers.ts @@ -132,7 +132,11 @@ export function createNotificationHandlers( return Response.json( await config.produce(authorized.actor, authorized.input) ); - } catch { + } catch (error) { + const superseded = supersededBindingResponse(error); + if (superseded != null) { + return superseded; + } return unavailable(config.route, config.unavailableMessage); } }; @@ -181,8 +185,11 @@ export function createNotificationHandlers( produce: (actor, input) => observeGiftCreditForNotifications(dependencies.store, { ...input, + account: { + legacyWorkspaceActor: actor.legacyWorkspaceActor, + userUid: actor.owner.userUid, + }, namespace: actor.owner.namespace, - userUid: actor.owner.userUid, }), route: "gift-observation", schema: giftObservationRequestSchema, diff --git a/apps/ui/src/features/notifications/producer-credit-hint.test.ts b/apps/ui/src/features/notifications/producer-credit-hint.test.ts index 181587cc..9ca2691e 100644 --- a/apps/ui/src/features/notifications/producer-credit-hint.test.ts +++ b/apps/ui/src/features/notifications/producer-credit-hint.test.ts @@ -35,6 +35,16 @@ afterAll(() => pglite.close()); const NOW = new Date("2026-08-27T12:00:00Z"); +/** The verified binding every account-scoped write re-checks (ADR-0059). */ +async function account(name: string) { + const owner = { legacyWorkspaceActor: name, userUid: `uid-${name}` }; + await db + .insert(identityFingerprints) + .values({ crName: name, mintedAt: 1, userUid: owner.userUid }) + .onConflictDoNothing(); + return owner; +} + test("the dedupe key names the user, not the workspace", () => { assert.equal(creditHintDedupeKey("uid-alice"), "credit-hint:uid-alice"); }); @@ -44,19 +54,19 @@ test("a visible gift writes one hint per user; retries and other workspaces writ giftMicroUnits: 720_000, namespace: "ns-a", now: NOW, - userUid: "uid-alice", + account: await account("alice"), }); const retry = await observeGiftCreditForNotifications(store, { giftMicroUnits: 700_000, namespace: "ns-a", now: new Date(NOW.getTime() + 60_000), - userUid: "uid-alice", + account: await account("alice"), }); const elsewhere = await observeGiftCreditForNotifications(store, { giftMicroUnits: 700_000, namespace: "ns-b", now: new Date(NOW.getTime() + 120_000), - userUid: "uid-alice", + account: await account("alice"), }); assert.deepEqual(first, { produced: true }); @@ -89,7 +99,7 @@ test("another user gets their own hint; no gift writes nothing", async () => { giftMicroUnits: 0, namespace: "ns-a", now: NOW, - userUid: "uid-bob", + account: await account("bob"), }), { produced: false } ); @@ -98,7 +108,7 @@ test("another user gets their own hint; no gift writes nothing", async () => { giftMicroUnits: 1_000_000, namespace: "ns-a", now: NOW, - userUid: "uid-bob", + account: await account("bob"), }), { produced: true } ); @@ -115,15 +125,15 @@ test("a blank user or workspace observes nothing", async () => { await observeGiftCreditForNotifications(store, { giftMicroUnits: 1_000_000, namespace: " ", - userUid: "uid-carol", + account: await account("carol"), }), { produced: false } ); assert.deepEqual( await observeGiftCreditForNotifications(store, { + account: { legacyWorkspaceActor: "carol", userUid: "" }, giftMicroUnits: 1_000_000, namespace: "ns-a", - userUid: "", }), { produced: false } ); diff --git a/apps/ui/src/features/notifications/producer-credit-hint.ts b/apps/ui/src/features/notifications/producer-credit-hint.ts index 8a9c563b..44cdfd7a 100644 --- a/apps/ui/src/features/notifications/producer-credit-hint.ts +++ b/apps/ui/src/features/notifications/producer-credit-hint.ts @@ -1,4 +1,4 @@ -import type { NotificationStore } from "./store"; +import type { NotificationAccountOwner, NotificationStore } from "./store"; /** * The $1 gift hint producer (catalog row D4): the first time a new user's @@ -25,14 +25,15 @@ export type GiftCreditObservationStore = Pick; export async function observeGiftCreditForNotifications( store: GiftCreditObservationStore, input: { + /** The verified person the hint is for; the write re-checks the binding. */ + account: NotificationAccountOwner; giftMicroUnits: number; namespace: string; now?: Date; - userUid: string; } ): Promise { const namespace = input.namespace.trim(); - const userUid = input.userUid.trim(); + const userUid = input.account.userUid.trim(); if ( namespace === "" || userUid === "" || @@ -41,12 +42,12 @@ export async function observeGiftCreditForNotifications( return { produced: false }; } const produced = await store.produce({ + account: { ...input.account, userUid }, dedupeKey: creditHintDedupeKey(userUid), kind: "credit-hint", namespace, now: input.now, payload: { giftMicroUnits: input.giftMicroUnits, kind: "credit-hint" }, - userUid, }); return { produced }; } diff --git a/apps/ui/src/features/notifications/producers-through-fixtures.test.ts b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts index 942ea1b7..c1289c26 100644 --- a/apps/ui/src/features/notifications/producers-through-fixtures.test.ts +++ b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts @@ -41,6 +41,11 @@ await migrate(db, { ), }); const store = createNotificationStore(() => db); +// The gift hint is account-scoped: its write re-checks this binding. +await db + .insert(identityFingerprints) + .values({ crName: "newcomer", mintedAt: 1, userUid: "uid-newcomer" }) + .onConflictDoNothing(); afterAll(() => pglite.close()); @@ -122,9 +127,9 @@ test("free's visible gift writes the hint once; active carries no gift", async ( assert.ok(gift.giftMicroUnits > 0); const observe = (giftMicroUnits: number) => observeGiftCreditForNotifications(store, { + account: { legacyWorkspaceActor: "newcomer", userUid: "uid-newcomer" }, giftMicroUnits, namespace: WORKSPACE, - userUid: "uid-newcomer", }); assert.deepEqual(await observe(gift.giftMicroUnits), { produced: true }); assert.deepEqual(await observe(gift.giftMicroUnits), { produced: false }); diff --git a/apps/ui/src/features/notifications/quota-observation.test.ts b/apps/ui/src/features/notifications/quota-observation.test.ts new file mode 100644 index 00000000..62d881e0 --- /dev/null +++ b/apps/ui/src/features/notifications/quota-observation.test.ts @@ -0,0 +1,100 @@ +import { test } from "bun:test"; +import assert from "node:assert/strict"; + +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import type { NotificationClientCredentials } from "./client"; +import { + observeWorkspaceQuotaForInbox, + type WorkspaceQuotaObservationDependencies, +} from "./quota-observation"; + +const CREDENTIALS: NotificationClientCredentials = { + appToken: "token", + kubeconfig: "kubeconfig", + namespace: "ns-a", +}; + +const SNAPSHOT: WorkspaceResourceQuotaSnapshot = { + items: [{ limit: 10, type: "cpu", used: 10 }], +}; + +function dependencies( + overrides: Partial = {} +) { + const calls = { + loaded: [] as string[], + refreshed: 0, + reported: [] as { + credentials: NotificationClientCredentials; + snapshot: WorkspaceResourceQuotaSnapshot; + }[], + }; + const deps: WorkspaceQuotaObservationDependencies = { + loadSnapshot: (namespace) => { + calls.loaded.push(namespace); + return Promise.resolve(SNAPSHOT); + }, + refreshFeed: () => { + calls.refreshed += 1; + return Promise.resolve(); + }, + report: (credentials, snapshot) => { + calls.reported.push({ credentials, snapshot }); + return Promise.resolve(); + }, + ...overrides, + }; + return { calls, deps }; +} + +test("a read snapshot is reported for the workspace and the inbox is refreshed", async () => { + const { calls, deps } = dependencies(); + + assert.equal(await observeWorkspaceQuotaForInbox(CREDENTIALS, deps), true); + assert.deepEqual(calls.loaded, ["ns-a"]); + assert.deepEqual(calls.reported, [ + { credentials: CREDENTIALS, snapshot: SNAPSHOT }, + ]); + assert.equal(calls.refreshed, 1); +}); + +test("missing credentials or an empty snapshot report nothing", async () => { + let loads = 0; + const { calls, deps } = dependencies({ + loadSnapshot: () => { + loads += 1; + return Promise.resolve(undefined); + }, + }); + + assert.equal( + await observeWorkspaceQuotaForInbox({ ...CREDENTIALS, appToken: "" }, deps), + false + ); + assert.equal(loads, 0, "no credentials, no read"); + assert.equal(await observeWorkspaceQuotaForInbox(CREDENTIALS, deps), false); + assert.equal(loads, 1); + assert.equal(calls.reported.length, 0); + assert.equal(calls.refreshed, 0); +}); + +test("a failed read or report resolves false without throwing and never refreshes", async () => { + const readFailed = dependencies({ + loadSnapshot: () => Promise.reject(new Error("sdk down")), + }); + const reportFailed = dependencies({ + report: () => Promise.reject(new Error("503")), + }); + + assert.equal( + await observeWorkspaceQuotaForInbox(CREDENTIALS, readFailed.deps), + false + ); + assert.equal( + await observeWorkspaceQuotaForInbox(CREDENTIALS, reportFailed.deps), + false + ); + assert.equal(readFailed.calls.refreshed, 0); + assert.equal(reportFailed.calls.refreshed, 0); +}); diff --git a/apps/ui/src/features/notifications/quota-observation.ts b/apps/ui/src/features/notifications/quota-observation.ts new file mode 100644 index 00000000..6af2d1d3 --- /dev/null +++ b/apps/ui/src/features/notifications/quota-observation.ts @@ -0,0 +1,72 @@ +import { mutate } from "swr"; + +import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import { + type NotificationClientCredentials, + reportWorkspaceQuotaObservation, +} from "./client"; + +/** The SWR key prefix of every mounted Notification Center feed. */ +const NOTIFICATION_FEED_KEY_PREFIX = "notifications-feed"; + +export interface WorkspaceQuotaObservationDependencies { + loadSnapshot: ( + namespace: string + ) => Promise; + refreshFeed: () => Promise; + report: ( + credentials: NotificationClientCredentials, + snapshot: WorkspaceResourceQuotaSnapshot + ) => Promise; +} + +/** Revalidates every mounted inbox so a produced or released A1 shows now, not next poll. */ +function refreshNotificationFeeds(): Promise { + return mutate( + (key) => Array.isArray(key) && key[0] === NOTIFICATION_FEED_KEY_PREFIX + ); +} + +const DEFAULT_DEPENDENCIES: WorkspaceQuotaObservationDependencies = { + loadSnapshot: loadWorkspaceQuotaSnapshot, + refreshFeed: refreshNotificationFeeds, + report: reportWorkspaceQuotaObservation, +}; + +/** + * The client-side observation point of the quota-exhausted producer (A1): + * reads the workspace's quota snapshot and hands it to the producer, then + * refreshes the inbox so the entry (or its release) lands with the banner + * that shows the same state. Every quota surface that polls — the App + * Sidebar on mount, the Status Hint on its cadence — reports through here, + * so the banner and the inbox observe the same snapshot at the same time + * and the producer's edge-triggering sees every crossing and recovery. + * Best-effort: a failed read or report resolves false and never throws; the + * producer dedupes by naming, so the next poll simply observes again. + */ +export async function observeWorkspaceQuotaForInbox( + credentials: NotificationClientCredentials, + dependencies: WorkspaceQuotaObservationDependencies = DEFAULT_DEPENDENCIES +): Promise { + const namespace = credentials.namespace.trim(); + if ( + namespace === "" || + credentials.appToken === "" || + credentials.kubeconfig === "" + ) { + return false; + } + try { + const snapshot = await dependencies.loadSnapshot(namespace); + if (snapshot == null) { + return false; + } + await dependencies.report({ ...credentials, namespace }, snapshot); + } catch { + return false; + } + await dependencies.refreshFeed().catch(() => undefined); + return true; +} diff --git a/apps/ui/src/features/notifications/store.test.ts b/apps/ui/src/features/notifications/store.test.ts index 49881873..6e9cc7fb 100644 --- a/apps/ui/src/features/notifications/store.test.ts +++ b/apps/ui/src/features/notifications/store.test.ts @@ -174,12 +174,12 @@ test("receipts are per user, follow the person across workspaces, and db receipt test("an account-scoped message follows the person into every workspace and takes its receipt from any of them", async () => { await store.produce({ + account: { legacyWorkspaceActor: "alice", userUid: "alice-uid" }, dedupeKey: "credit-hint:alice-uid", kind: "credit-hint", namespace: "ns-a", now: NOW, payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, - userUid: "alice-uid", }); const inNsB = await store.listMessages({ namespace: "ns-b", @@ -237,6 +237,41 @@ test("marking read refuses a superseded identity binding", async () => { ); }); +test("producing an account-scoped message refuses a superseded identity binding; workspace rows need none", async () => { + await assert.rejects( + store.produce({ + account: { legacyWorkspaceActor: "alice", userUid: "someone-else-uid" }, + dedupeKey: "credit-hint:someone-else-uid", + kind: "credit-hint", + namespace: "ns-a", + now: NOW, + payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, + }), + SUPERSEDED_RE + ); + assert.equal( + ( + await db + .select({ id: notificationMessages.id }) + .from(notificationMessages) + .where(eq(notificationMessages.userUid, "someone-else-uid")) + ).length, + 0, + "the refused write left no row" + ); + // A workspace-scoped row is not a personal resource: no binding involved. + assert.equal( + await store.produce({ + dedupeKey: "quota-exhausted:ns-guard:memory", + kind: "quota-exhausted", + namespace: "ns-guard", + now: NOW, + payload: quotaPayload("memory"), + }), + true + ); +}); + test("writes sweep entries older than 365 days and their receipts cascade", async () => { const old = new Date(NOW.getTime() - 400 * DAY_MS); await store.produce({ diff --git a/apps/ui/src/features/notifications/store.ts b/apps/ui/src/features/notifications/store.ts index 62a8a099..42992abe 100644 --- a/apps/ui/src/features/notifications/store.ts +++ b/apps/ui/src/features/notifications/store.ts @@ -38,7 +38,23 @@ export interface NotificationReader extends NotificationInbox { legacyWorkspaceActor: string; } +/** + * The verified person an account-scoped row is keyed by: the bare uid the + * row carries plus the crName the write re-checks the binding against + * (ADR-0059), so a merge either sweeps the row or refuses the stale write. + */ +export interface NotificationAccountOwner { + legacyWorkspaceActor: string; + userUid: string; +} + export interface ProduceNotificationInput { + /** + * Set for account-scoped messages (the gift hint, later the expiry + * reminder): the row follows the person into every workspace's inbox and + * `namespace` only records where it was first observed. + */ + account?: NotificationAccountOwner | null; dedupeKey: string; kind: NotificationMessageKind; namespace: string; @@ -46,12 +62,6 @@ export interface ProduceNotificationInput { now?: Date; payload: NotificationPayload; projectUid?: string | null; - /** - * Set for account-scoped messages (the gift hint, later the expiry - * reminder): the row follows the person into every workspace's inbox and - * `namespace` only records where it was first observed. - */ - userUid?: string | null; } export interface NotificationStore { @@ -198,6 +208,16 @@ export function createNotificationStore( produce: (input) => getDb().transaction(async (tx) => { const now = input.now ?? new Date(); + // An account-scoped row is a uid-keyed personal resource: re-check + // the fingerprint in the same transaction, as markRead does, so an + // observation that authorized before a merge cannot commit a + // tombstone-keyed row after the merge's sweep (ADR-0059). + if (input.account != null) { + await requireCurrentIdentityBinding(tx, { + crName: input.account.legacyWorkspaceActor, + userUid: input.account.userUid, + }); + } const inserted = await tx .insert(notificationMessages) .values({ @@ -208,7 +228,7 @@ export function createNotificationStore( namespace: input.namespace, payload: input.payload, projectUid: input.projectUid ?? null, - userUid: input.userUid ?? null, + userUid: input.account?.userUid ?? null, }) .onConflictDoNothing({ target: notificationMessages.dedupeKey, diff --git a/apps/ui/src/features/notifications/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts index 0d3769ad..dacbd275 100644 --- a/apps/ui/src/features/notifications/use-notification-feed.ts +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -83,17 +83,58 @@ export function useNotificationFeed(): NotificationFeed { }); const { mutate: refreshCRList } = crList; + // The gift hint's observation point: a read that shows gift credit reports + // it. The latch holds while a report is in flight or after one landed; a + // failed report clears it so the next credits read (or a workspace or + // credential change) tries again instead of leaving D4 missing for the + // rest of the session. The producer dedupes by user, so a repeat is + // harmless. + const giftObservedFor = useRef(null); + const observeGift = useCallback( + (giftMicroUnits: number) => { + const key = `${namespace}|${credentialKey}`; + if ( + !credentialsReady || + giftMicroUnits <= 0 || + giftObservedFor.current === key + ) { + return; + } + giftObservedFor.current = key; + reportGiftCreditObservation( + { appToken, kubeconfig, namespace }, + { giftMicroUnits } + ) + .then(() => refreshBrainFeed()) + .catch(() => { + if (giftObservedFor.current === key) { + giftObservedFor.current = null; + } + }); + }, + [ + appToken, + credentialKey, + credentialsReady, + kubeconfig, + namespace, + refreshBrainFeed, + ] + ); // Account facts for the display layer: gift and usable credit plus whether // the account ever topped up. Both are account-scoped, so they key on the // credentials alone. Credits burn down, so they follow the inbox's own - // cadence; top-up history only ever grows, so one read per session is - // the truth for the session. + // cadence and every answered poll is a gift observation opportunity even + // when nothing changed (SWR keeps equal `data` referentially stable); + // top-up history only ever grows, so one read per session is the truth + // for the session. const credits = useSWR( credentialsReady ? (["notifications-credits", credentialKey, appToken] as const) : null, () => loadAccountCredits({ appToken, kubeconfig }), { + onSuccess: (data) => observeGift(data.giftMicroUnits), refreshInterval: NOTIFICATION_CR_REFRESH_INTERVAL_MS, revalidateOnFocus: false, shouldRetryOnError: false, @@ -113,35 +154,12 @@ export function useNotificationFeed(): NotificationFeed { toppedUp.data != null && isGiftOnlyNewcomer({ ...credits.data, hasToppedUp: toppedUp.data }); - // The gift hint's observation point: the first read that shows gift credit - // reports it once per session; the producer dedupes by user beyond that. - const giftObservedFor = useRef(null); + // Data already in SWR's cache answers without a fetch, so the first + // render observes it here; later polls observe through `onSuccess`. const giftMicroUnits = credits.data?.giftMicroUnits ?? 0; useEffect(() => { - const key = `${namespace}|${credentialKey}`; - if ( - !credentialsReady || - giftMicroUnits <= 0 || - giftObservedFor.current === key - ) { - return; - } - giftObservedFor.current = key; - reportGiftCreditObservation( - { appToken, kubeconfig, namespace }, - { giftMicroUnits } - ) - .then(() => refreshBrainFeed()) - .catch(() => undefined); - }, [ - appToken, - credentialKey, - credentialsReady, - giftMicroUnits, - kubeconfig, - namespace, - refreshBrainFeed, - ]); + observeGift(giftMicroUnits); + }, [giftMicroUnits, observeGift]); const items = useMemo(() => { const crItems = fixturePlatformItems ?? crList.data?.items; diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx new file mode 100644 index 00000000..997f4cab --- /dev/null +++ b/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "@testing-library/react/pure"; +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { CR_OVERRIDES } from "@/features/notifications/cr-overrides"; +import { mergeNotificationFeed } from "@/features/notifications/feed-model"; +import type { NotificationMessage } from "@/features/notifications/types"; +import type { NotificationFeed } from "@/features/notifications/use-notification-feed"; +import { withTestDom } from "@/features/project-canvas/react-test-harness"; + +import { + type AppNotification, + countUnreadNotifications, +} from "./app-sidebar-notifications-model"; + +/** + * AIM-334's display layer, as the user sees it: the 8 fixed-name CRs paint + * their Brain-voiced title, body, and CTA, and the Brain-produced entries + * (A1, D4, B5) paint the sentence that carries the fact — not just a title. + */ + +const NOW_SECONDS = Math.floor(Date.parse("2026-08-27T12:00:00Z") / 1000); + +function platformCR( + name: string, + overrides: Partial = {} +): NotificationCRItem { + return { + desktopPopup: true, + from: "Debt-System", + isRead: false, + message: `upstream body for ${name}`, + name, + namespace: "ns-a", + timestamp: NOW_SECONDS, + title: `upstream title for ${name}`, + version: NOW_SECONDS, + ...overrides, + }; +} + +const GIFT_HINT: NotificationMessage = { + createdAt: (NOW_SECONDS - 60) * 1000, + id: "gift-1", + kind: "credit-hint", + payload: { giftMicroUnits: 1_000_000, kind: "credit-hint" }, + projectUid: null, +}; + +const DOWNGRADE_RECEIPT: NotificationMessage = { + createdAt: (NOW_SECONDS - 120) * 1000, + id: "sub-1", + kind: "subscription-change", + payload: { + change: "downgraded", + effectiveAt: "2026-09-30T00:00:00Z", + kind: "subscription-change", + planName: "Hobby", + }, + projectUid: null, +}; + +const QUOTA_FULL: NotificationMessage = { + createdAt: (NOW_SECONDS - 180) * 1000, + id: "quota-1", + kind: "quota-exhausted", + payload: { kind: "quota-exhausted", limit: 10, resource: "cpu", used: 10 }, + projectUid: null, +}; + +function feedOf(items: readonly AppNotification[]): NotificationFeed { + return { + items, + markAllRead: () => undefined, + markRead: () => undefined, + readIds: new Set(), + unreadCount: countUnreadNotifications(items, new Set()), + }; +} + +async function renderPanel( + feed: NotificationFeed, + run: ( + rendered: ReturnType, + act: (run: () => void) => Promise + ) => void | Promise +) { + await withTestDom(async (act) => { + const { NotificationsPanel } = await import("./app-sidebar-notifications"); + let rendered: ReturnType | undefined; + try { + await act(() => { + rendered = render(); + }); + if (rendered != null) { + await run(rendered, act); + } + } finally { + await act(() => rendered?.unmount()); + } + }); +} + +test("every fixed-name debt-ladder CR renders its override title, body, and CTA", async () => { + const names = Object.keys(CR_OVERRIDES); + assert.equal(names.length, 8); + const items = mergeNotificationFeed({ + crItems: names.map((name, index) => + platformCR(name, { + timestamp: NOW_SECONDS - index, + version: NOW_SECONDS - index, + }) + ), + dbMessages: [], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + for (const name of names) { + const override = CR_OVERRIDES[name]; + assert.ok(override); + assert.ok(text.includes(override.title), `${name}: title is shown`); + assert.ok(text.includes(override.body), `${name}: body is shown`); + assert.equal( + rendered + .getAllByRole("button", { name: override.cta.label }) + .some((cta) => cta.getAttribute("href") === override.cta.href), + true, + `${name}: CTA ${override.cta.label} → ${override.cta.href}` + ); + // The display layer replaces upstream copy; it never shows both. + assert.equal(text.includes(`upstream title for ${name}`), false); + assert.equal(text.includes(`upstream body for ${name}`), false); + } + }); +}); + +test("an unknown platform message falls back to its upstream title and body", async () => { + const items = mergeNotificationFeed({ + crItems: [ + platformCR("release-notes", { from: "Sealos", message: "v3 shipped." }), + ], + dbMessages: [], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + assert.ok(text.includes("upstream title for release-notes")); + assert.ok(text.includes("v3 shipped.")); + }); +}); + +test("Brain-produced entries render the sentence that carries the fact", async () => { + const items = mergeNotificationFeed({ + crItems: [], + dbMessages: [GIFT_HINT, DOWNGRADE_RECEIPT, QUOTA_FULL], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + // D4: the welcome and what the gift is for. + assert.ok(text.includes("You have a $1 welcome gift")); + assert.ok( + text.includes( + "It covers your first deployments and expires a month after it was granted." + ) + ); + // B5: the receipt names the plan and the date. + assert.ok(text.includes("Subscription downgraded")); + assert.ok(text.includes("This workspace moves to Hobby on")); + // A1: the resource, the consequence, and the way out. + assert.ok(text.includes("CPU quota is full")); + assert.ok(text.includes("CPU is at 100%. New deployments can't start.")); + assert.equal( + rendered.getByRole("button", { name: "View usage" }).getAttribute("href"), + "/billing/usage" + ); + }); +}); + +test("clicking a row marks it read and expands its clamped body", async () => { + const items = mergeNotificationFeed({ + crItems: [platformCR("workspace-debt-debt")], + dbMessages: [], + receipts: [], + }); + const read: string[] = []; + const feed: NotificationFeed = { + ...feedOf(items), + markRead: (item) => { + read.push(item.id); + }, + }; + + await renderPanel(feed, async (rendered, act) => { + const row = rendered.container.querySelector( + '[data-slot="app-sidebar-notification-row"]' + ); + assert.ok(row); + // The innermost span holding the copy is the clamped one. + const body = [...row.querySelectorAll("span")] + .reverse() + .find((span) => + span.textContent?.includes( + CR_OVERRIDES["workspace-debt-debt"]?.body ?? "" + ) + ); + assert.ok(body, "the body is in the document before any click"); + assert.ok(body.classList.contains("line-clamp-3"), "clamped at rest"); + const toggle = row.querySelector("button"); + assert.ok(toggle); + await act(() => toggle.click()); + assert.deepEqual( + read, + items.map((item) => item.id) + ); + assert.equal(body.classList.contains("line-clamp-3"), false, "expanded"); + }); +}); diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.tsx index 23ee8bba..605ea602 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications.tsx @@ -20,6 +20,7 @@ import Link from "next/link"; import { useRef, useState } from "react"; import { BillingDevMockGate } from "@/features/billing/billing-dev-mock-gate"; import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; +import { NotificationsDevMockGate } from "@/features/notifications/dev-mock-gate"; import { formatNotificationTime, formatNotificationTimestamp, @@ -189,7 +190,7 @@ function NotificationsEmptyState({ tab }: { tab: NotificationTab }) { ); } -function NotificationsPanel({ feed }: { feed: NotificationFeed }) { +export function NotificationsPanel({ feed }: { feed: NotificationFeed }) { const { items, markAllRead, markRead, readIds, unreadCount } = feed; const [tab, setTab] = useState("all"); const [expanded, setExpanded] = useState>( @@ -315,6 +316,7 @@ export function AppSidebarNotifications() { {/* The billing Dev Mock's scenarios drive the inbox fixtures too, so its panel entry is reachable from anywhere the inbox is. */} + { - loadWorkspaceQuotaSnapshot(namespace) - .then((snapshot) => { - if (snapshot == null || appToken === "" || kubeconfig === "") { - return; - } - return reportWorkspaceQuotaObservation( - { appToken, kubeconfig, namespace }, - snapshot - ); - }) - .catch(() => undefined); + if (appToken === "" || kubeconfig === "") { + loadWorkspaceQuotaSnapshot(namespace).catch(() => undefined); + return; + } + observeWorkspaceQuotaForInbox({ appToken, kubeconfig, namespace }).catch( + () => undefined + ); }, [appToken, kubeconfig, namespace]); return ( diff --git a/apps/ui/src/features/status-hint/use-status-hint.ts b/apps/ui/src/features/status-hint/use-status-hint.ts index 8a1eea4c..ee303012 100644 --- a/apps/ui/src/features/status-hint/use-status-hint.ts +++ b/apps/ui/src/features/status-hint/use-status-hint.ts @@ -9,6 +9,7 @@ import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance import { loadAccountCredits } from "@/features/billing/account-credits"; import { accountCreditsSwrKey } from "@/features/billing/billing-subscription-settlement"; import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data"; +import { observeWorkspaceQuotaForInbox } from "@/features/notifications/quota-observation"; import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; @@ -66,12 +67,25 @@ export function useStatusHint(): StatusHintSlot { () => loadAccountCredits({ appToken, kubeconfig }), swrOptions ); + // Every quota poll is also the quota-exhausted producer's observation + // point (A1): the inbox observes on the banner's cadence, so the two + // cannot disagree for minutes, and a recovery between chat turns still + // releases the live key. const quota = useSWR( credentialsReady ? (["status-hint-quota", workspace, credentialKey, appToken] as const) : null, () => loadWorkspaceQuotaUsage({ appToken, kubeconfig, workspace }), - swrOptions + { + ...swrOptions, + onSuccess: () => { + observeWorkspaceQuotaForInbox({ + appToken, + kubeconfig, + namespace: workspace, + }).catch(() => undefined); + }, + } ); // Trial-expiry is a clock state as much as a data state: the window opens diff --git a/apps/ui/src/lib/identity-fingerprint-core.test.ts b/apps/ui/src/lib/identity-fingerprint-core.test.ts index 49c61540..c802f282 100644 --- a/apps/ui/src/lib/identity-fingerprint-core.test.ts +++ b/apps/ui/src/lib/identity-fingerprint-core.test.ts @@ -24,6 +24,10 @@ import { marketingAttributionSubjects, marketingLifecycleEvents, } from "@/features/marketing/schema"; +import { + notificationMessages, + notificationReadReceipts, +} from "@/features/notifications/schema"; import { onboardingProfiles } from "@/features/onboarding/schema"; import { @@ -682,6 +686,185 @@ test("where both merged accounts hold a profile, the survivor's row wins and the assert.equal(telemetry?.profilesReleased, 1); }); +function selectReceipts(userUids: string[]) { + return db + .select({ + messageId: notificationReadReceipts.messageId, + messageKey: notificationReadReceipts.messageKey, + userUid: notificationReadReceipts.userUid, + }) + .from(notificationReadReceipts) + .where(inArray(notificationReadReceipts.userUid, userUids)) + .orderBy( + notificationReadReceipts.userUid, + notificationReadReceipts.messageKey + ); +} + +function selectMessages(userUids: string[]) { + return db + .select({ + dedupeKey: notificationMessages.dedupeKey, + id: notificationMessages.id, + namespace: notificationMessages.namespace, + releasedAt: notificationMessages.releasedAt, + userUid: notificationMessages.userUid, + }) + .from(notificationMessages) + .where(inArray(notificationMessages.userUid, userUids)) + .orderBy(notificationMessages.userUid, notificationMessages.id); +} + +test("a merge re-keys the tombstone's read receipts and drops those the survivor already holds", async () => { + await observe({ + crName: "receipt-cr", + mintedAt: 10_000, + userUid: "receipt-tombstone-uid", + }); + await db.insert(notificationMessages).values({ + dedupeKey: "quota-exhausted:receipt-ns:cpu", + id: "receipt-msg", + kind: "quota-exhausted", + namespace: "receipt-ns", + payload: { kind: "quota-exhausted", limit: 1, resource: "cpu", used: 1 }, + }); + await db.insert(notificationReadReceipts).values([ + // Only the tombstone read these: they follow the survivor, the db one + // still attached to its row. + { + messageKey: "cr:debt-choice-debtperiod:1", + userUid: "receipt-tombstone-uid", + }, + { + messageId: "receipt-msg", + messageKey: "db:receipt-msg", + userUid: "receipt-tombstone-uid", + }, + // Both read this one: the survivor's stands, the tombstone's is dropped. + { + messageKey: "cr:workspace-debt-debt:1", + userUid: "receipt-tombstone-uid", + }, + { messageKey: "cr:workspace-debt-debt:1", userUid: "receipt-survivor-uid" }, + ]); + + const telemetry = await observeCapturingTelemetry({ + crName: "receipt-cr", + mintedAt: 11_000, + userUid: "receipt-survivor-uid", + }); + + assert.deepEqual( + await selectReceipts(["receipt-tombstone-uid", "receipt-survivor-uid"]), + [ + { + messageId: null, + messageKey: "cr:debt-choice-debtperiod:1", + userUid: "receipt-survivor-uid", + }, + { + messageId: null, + messageKey: "cr:workspace-debt-debt:1", + userUid: "receipt-survivor-uid", + }, + { + messageId: "receipt-msg", + messageKey: "db:receipt-msg", + userUid: "receipt-survivor-uid", + }, + ] + ); + assert.equal(telemetry?.receiptsRekeyed, 2); + assert.equal(telemetry?.receiptsReleased, 1); +}); + +test("a merge re-keys the tombstone's account-scoped messages, key included, and collapses onto the survivor's live row", async () => { + await observe({ + crName: "message-cr", + mintedAt: 12_000, + userUid: "message-tombstone-uid", + }); + const giftPayload = { + giftMicroUnits: 1_000_000, + kind: "credit-hint", + } as const; + await db.insert(notificationMessages).values([ + // The tombstone's welcome, observed in its own workspace. + { + dedupeKey: "credit-hint:message-tombstone-uid", + id: "message-tombstone-gift", + kind: "credit-hint", + namespace: "tombstone-ns", + payload: giftPayload, + userUid: "message-tombstone-uid", + }, + ]); + + const first = await observeCapturingTelemetry({ + crName: "message-cr", + mintedAt: 13_000, + userUid: "message-survivor-uid", + }); + + // The row and its dedupe key follow the survivor: a later gift observation + // for the survivor finds this row and writes no second welcome. + assert.deepEqual( + await selectMessages(["message-tombstone-uid", "message-survivor-uid"]), + [ + { + dedupeKey: "credit-hint:message-survivor-uid", + id: "message-tombstone-gift", + namespace: "tombstone-ns", + releasedAt: null, + userUid: "message-survivor-uid", + }, + ] + ); + assert.equal(first?.messagesRekeyed, 1); + assert.equal(first?.messagesReleased, 0); + + // A second merge into the same survivor, from an account that also holds + // a live welcome: one row per person, so the newcomer's is deleted and + // its receipt cascades. + await observe({ + crName: "message-cr-2", + mintedAt: 14_000, + userUid: "message-tombstone-2-uid", + }); + await db.insert(notificationMessages).values({ + dedupeKey: "credit-hint:message-tombstone-2-uid", + id: "message-tombstone-2-gift", + kind: "credit-hint", + namespace: "tombstone-2-ns", + payload: giftPayload, + userUid: "message-tombstone-2-uid", + }); + await db.insert(notificationReadReceipts).values({ + messageId: "message-tombstone-2-gift", + messageKey: "db:message-tombstone-2-gift", + userUid: "message-tombstone-2-uid", + }); + + const second = await observeCapturingTelemetry({ + crName: "message-cr-2", + mintedAt: 15_000, + userUid: "message-survivor-uid", + }); + + assert.deepEqual( + ( + await selectMessages(["message-tombstone-2-uid", "message-survivor-uid"]) + ).map((row) => row.id), + ["message-tombstone-gift"] + ); + assert.deepEqual( + await selectReceipts(["message-tombstone-2-uid", "message-survivor-uid"]), + [] + ); + assert.equal(second?.messagesRekeyed, 0); + assert.equal(second?.messagesReleased, 1); +}); + test("a write-transaction re-check passes only while the binding is current", async () => { await observe({ crName: "guard-cr", mintedAt: 5100, userUid: "guard-uid" }); diff --git a/apps/ui/src/lib/identity-fingerprint-core.ts b/apps/ui/src/lib/identity-fingerprint-core.ts index d2429a8b..0adfceec 100644 --- a/apps/ui/src/lib/identity-fingerprint-core.ts +++ b/apps/ui/src/lib/identity-fingerprint-core.ts @@ -1,4 +1,4 @@ -import { and, eq, notExists, sql } from "drizzle-orm"; +import { and, eq, isNull, notExists, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { @@ -17,7 +17,10 @@ import { marketingAttributionSubjects, marketingLifecycleEvents, } from "@/features/marketing/schema"; -import { notificationReadReceipts } from "@/features/notifications/schema"; +import { + notificationMessages, + notificationReadReceipts, +} from "@/features/notifications/schema"; import { onboardingProfiles } from "@/features/onboarding/schema"; import { rekeyCanonicalIdentityUids } from "@/lib/identity-uid-canonicalization"; @@ -229,6 +232,8 @@ async function rekeyPersonalResources( identityUidCanonicalizationsRekeyed: number; installSessionsRekeyed: number; lifecycleEventsRekeyed: number; + messagesReleased: number; + messagesRekeyed: number; profilesReleased: number; profilesRekeyed: number; receiptsReleased: number; @@ -449,6 +454,40 @@ async function rekeyPersonalResources( .where(eq(notificationReadReceipts.userUid, input.tombstoneUserUid)) .returning({ messageKey: notificationReadReceipts.messageKey }); + // Account-scoped notification messages (ADR-0067: the gift hint) are + // uid-keyed rows whose dedupe key names the uid too, so the key moves with + // the row — a later observation then finds the survivor's row instead of + // writing a second welcome. Where the survivor already holds a live row + // under the re-keyed name, the tombstone's is a duplicate of the same + // one-per-person fact and is deleted (its receipts cascade). + const survivorDedupeKey = sql`replace(${notificationMessages.dedupeKey}, ${input.tombstoneUserUid}, ${input.survivorUserUid})`; + const survivorMessages = alias(notificationMessages, "survivor_messages"); + const rekeyedMessages = await tx + .update(notificationMessages) + .set({ dedupeKey: survivorDedupeKey, userUid: input.survivorUserUid }) + .where( + and( + eq(notificationMessages.userUid, input.tombstoneUserUid), + notExists( + tx + .select({ id: survivorMessages.id }) + .from(survivorMessages) + .where( + and( + eq(survivorMessages.userUid, input.survivorUserUid), + isNull(survivorMessages.releasedAt), + eq(survivorMessages.dedupeKey, survivorDedupeKey) + ) + ) + ) + ) + ) + .returning({ id: notificationMessages.id }); + const releasedMessages = await tx + .delete(notificationMessages) + .where(eq(notificationMessages.userUid, input.tombstoneUserUid)) + .returning({ id: notificationMessages.id }); + return { attributionSubjectsRekeyed: rekeyedAttributionSubjects.length, attributionSubjectsReleased: releasedAttributionSubjects.length, @@ -460,6 +499,8 @@ async function rekeyPersonalResources( identityUidCanonicalizationsRekeyed, installSessionsRekeyed: rekeyedInstallSessions.length, lifecycleEventsRekeyed: rekeyedLifecycleEvents.length, + messagesReleased: releasedMessages.length, + messagesRekeyed: rekeyedMessages.length, profilesReleased: releasedProfiles.length, profilesRekeyed: rekeyedProfiles.length, receiptsReleased: releasedReceipts.length, From 2656febe9717a50d866c89327136b378eca31c54 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 11:06:38 +0800 Subject: [PATCH 11/14] fix(dev-mock): revalidate only mock-owned SWR keys; extract shared Dev Mock cookie helper (AIM-338, AIM-339) Switching a billing Mock Scenario used mutate(() => true), refiring every SWR key on the page; with the cluster unreachable those requests hang and queue the mock's own refetches behind them. The toggle now revalidates only the keys the billing fixtures shape. features/dev-mock/ carries the cookie grammar, the server-side resolver and the panel source every route-handler Dev Mock shares; the billing mock is the first consumer, behaviour unchanged. The production gate stays inlined at each route so the fixture import can still be statically dropped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9 --- .../src/features/billing/billing-dev-mock.tsx | 67 +++---------- .../src/features/billing/dev-mock-cookie.ts | 65 +++++-------- .../billing/dev-mock-swr-keys.test.ts | 36 +++++++ .../src/features/billing/dev-mock-swr-keys.ts | 26 +++++ .../billing/server/dev-fixtures/index.ts | 70 +++---------- apps/ui/src/features/dev-mock/cookie.ts | 97 +++++++++++++++++++ .../src/features/dev-mock/server/resolve.ts | 46 +++++++++ apps/ui/src/features/dev-mock/source.ts | 56 +++++++++++ 8 files changed, 309 insertions(+), 154 deletions(-) create mode 100644 apps/ui/src/features/billing/dev-mock-swr-keys.test.ts create mode 100644 apps/ui/src/features/billing/dev-mock-swr-keys.ts create mode 100644 apps/ui/src/features/dev-mock/cookie.ts create mode 100644 apps/ui/src/features/dev-mock/server/resolve.ts create mode 100644 apps/ui/src/features/dev-mock/source.ts diff --git a/apps/ui/src/features/billing/billing-dev-mock.tsx b/apps/ui/src/features/billing/billing-dev-mock.tsx index 649b5f6c..ebbbc97c 100644 --- a/apps/ui/src/features/billing/billing-dev-mock.tsx +++ b/apps/ui/src/features/billing/billing-dev-mock.tsx @@ -1,19 +1,16 @@ "use client"; -import { - type DevTweaksMockSource, - useDevTweaksMock, -} from "@workspace/dev-tweaks"; +import { useDevTweaksMock } from "@workspace/dev-tweaks"; import { mutate } from "swr"; +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + import { - BILLING_DEV_MOCK_COOKIE, BILLING_DEV_SCENARIOS, + billingDevMockCookie, DEFAULT_BILLING_DEV_SCENARIO, - formatBillingDevMockCookie, - isBillingDevScenario, - parseBillingDevMockCookie, } from "./dev-mock-cookie"; +import { isBillingDevMockSwrKey } from "./dev-mock-swr-keys"; /** * Billing's Dev Mock: registered with the dev tweaks panel while any @@ -24,57 +21,15 @@ import { export const BILLING_DEV_MOCK_KEY = "billing-mock"; -function readCookieState() { - const pair = document.cookie - .split(";") - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith(`${BILLING_DEV_MOCK_COOKIE}=`)); - return parseBillingDevMockCookie( - pair?.slice(BILLING_DEV_MOCK_COOKIE.length + 1) - ); -} - /** - * Cookie-backed mock source. `set` also drops the whole SWR cache: the - * scenario shapes every /api/billing/* answer, so billing keys must refetch — - * everything else is untouched data-wise. + * Cookie-backed mock source; a toggle revalidates the SWR keys the mock + * owns (see `dev-mock-swr-keys.ts`) — nothing else on the page is touched. */ -const billingDevMockSource: DevTweaksMockSource = { - load: () => { - const parsed = readCookieState(); - return parsed.kind === "set" ? parsed.state : null; - }, - set: (state) => { - const scenario = isBillingDevScenario(state.scenario) - ? state.scenario - : DEFAULT_BILLING_DEV_SCENARIO; - const value = formatBillingDevMockCookie({ - enabled: state.enabled, - scenario, - }); - // biome-ignore lint/suspicious/noDocumentCookie: the synchronous write must land before the SWR refetches fire; the async Cookie Store API cannot guarantee that. - document.cookie = `${BILLING_DEV_MOCK_COOKIE}=${value}; path=/; samesite=lax`; - mutate(() => true).catch(() => undefined); - }, - // The fixture dispatcher rewrites the cookie behind the panel's back (its - // Set-Cookie scenario transitions); the Cookie Store API pushes those, the - // focus/interval pair is the fallback. The mock store dedupes unchanged - // loads. - watch: (onChange) => { - const cookieStore = (window as { cookieStore?: EventTarget }).cookieStore; - cookieStore?.addEventListener("change", onChange); - window.addEventListener("focus", onChange); - const watchTimer = - cookieStore == null ? window.setInterval(onChange, 3000) : undefined; - return () => { - cookieStore?.removeEventListener("change", onChange); - window.removeEventListener("focus", onChange); - if (watchTimer != null) { - window.clearInterval(watchTimer); - } - }; +const billingDevMockSource = createDevMockCookieSource(billingDevMockCookie, { + revalidate: () => { + mutate(isBillingDevMockSwrKey).catch(() => undefined); }, -}; +}); /** Registers the mock while any /billing screen is mounted; renders nothing. */ export function BillingDevMockTweaks() { diff --git a/apps/ui/src/features/billing/dev-mock-cookie.ts b/apps/ui/src/features/billing/dev-mock-cookie.ts index 69f12cf6..8c24e52a 100644 --- a/apps/ui/src/features/billing/dev-mock-cookie.ts +++ b/apps/ui/src/features/billing/dev-mock-cookie.ts @@ -1,16 +1,15 @@ +import { + type DevMockState, + defineDevMockCookie, + type ParsedDevMockCookie, +} from "@/features/dev-mock/cookie"; + /** - * Cookie protocol shared by the billing dev-mock panel (client) and the - * fixture dispatcher (server). The session cookie is the single source of - * truth for mock state — there is no env var and no localStorage copy. - * - * Value grammar: `` while the mock is on, `off:` while it - * is off (the scenario part keeps the last selection so the panel can restore - * it). Anything else is invalid and makes the server answer 500 so typos fail - * loud instead of silently serving real data. + * The billing Dev Mock's cookie (grammar in `features/dev-mock/cookie.ts`). + * One scenario shapes every `/api/billing/*` answer, the billing-born + * Notifications, and through them the Status Hint and Chat Billing Mode. */ -export const BILLING_DEV_MOCK_COOKIE = "sealai-billing-dev-mock"; - export const BILLING_DEV_SCENARIOS = [ "payg", "payg-debt", @@ -37,49 +36,31 @@ export type BillingDevScenario = (typeof BILLING_DEV_SCENARIOS)[number]; export const DEFAULT_BILLING_DEV_SCENARIO: BillingDevScenario = "active"; -export interface BillingDevMockState { - enabled: boolean; - scenario: BillingDevScenario; -} +export const billingDevMockCookie = defineDevMockCookie({ + defaultScenario: DEFAULT_BILLING_DEV_SCENARIO, + name: "sealai-billing-dev-mock", + scenarios: BILLING_DEV_SCENARIOS, +}); + +export const BILLING_DEV_MOCK_COOKIE = billingDevMockCookie.name; -const OFF_PREFIX = "off:"; +export type BillingDevMockState = DevMockState; + +export type ParsedBillingDevMockCookie = + ParsedDevMockCookie; export function isBillingDevScenario( value: string ): value is BillingDevScenario { - return (BILLING_DEV_SCENARIOS as readonly string[]).includes(value); + return billingDevMockCookie.is(value); } export function formatBillingDevMockCookie(state: BillingDevMockState): string { - return state.enabled ? state.scenario : `${OFF_PREFIX}${state.scenario}`; + return billingDevMockCookie.format(state); } -export type ParsedBillingDevMockCookie = - | { kind: "invalid"; raw: string } - | { kind: "set"; state: BillingDevMockState } - | { kind: "unset" }; - export function parseBillingDevMockCookie( raw: string | null | undefined ): ParsedBillingDevMockCookie { - const value = raw?.trim() ?? ""; - if (value === "") { - return { kind: "unset" }; - } - if (value.startsWith(OFF_PREFIX)) { - const scenario = value.slice(OFF_PREFIX.length); - return { - kind: "set", - state: { - enabled: false, - scenario: isBillingDevScenario(scenario) - ? scenario - : DEFAULT_BILLING_DEV_SCENARIO, - }, - }; - } - if (isBillingDevScenario(value)) { - return { kind: "set", state: { enabled: true, scenario: value } }; - } - return { kind: "invalid", raw: value }; + return billingDevMockCookie.parse(raw); } diff --git a/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts b/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts new file mode 100644 index 00000000..f2967a4a --- /dev/null +++ b/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { isBillingDevMockSwrKey } from "./dev-mock-swr-keys"; + +describe("isBillingDevMockSwrKey", () => { + test("matches every key family the billing fixtures shape", () => { + for (const key of [ + ["billing-plan-snapshot", "ns-a", "token", "kc"], + ["billing-account-credits", "kc", "token"], + ["billing-ai-credits", "kc", "token", "ns-a"], + ["app-sidebar-subscription", "ns-a", "kc", "token"], + ["notifications-feed", "ns-a", "kc", "token"], + ["notifications-topped-up", "kc", "token"], + ["status-hint-balance", "kc", "token"], + ["status-hint-quota", "ns-a", "kc", "token"], + ["chat-free-turns", "ns-a"], + ]) { + expect(isBillingDevMockSwrKey(key)).toBe(true); + } + }); + + test("leaves cluster-bound and unrelated keys alone", () => { + for (const key of [ + ["/api/notification", "ns-a", "kc"], + ["workload-logs", "ns-a", "app"], + "/api/k8s/pods", + ["projects-explorer"], + null, + undefined, + 42, + [], + ]) { + expect(isBillingDevMockSwrKey(key)).toBe(false); + } + }); +}); diff --git a/apps/ui/src/features/billing/dev-mock-swr-keys.ts b/apps/ui/src/features/billing/dev-mock-swr-keys.ts new file mode 100644 index 00000000..b594920e --- /dev/null +++ b/apps/ui/src/features/billing/dev-mock-swr-keys.ts @@ -0,0 +1,26 @@ +/** + * Which SWR keys the billing Dev Mock owns. Switching a Mock Scenario + * revalidates exactly these — every surface whose answers the fixtures + * shape (Billing Area, the sidebar subscription badge, the Notification + * Center's Brain feed and account facts, the Status Hint, the chat + * free-turn count) — and nothing else. A blanket `mutate(() => true)` used + * to refire every key on the page; with the cluster unreachable those + * requests hang, saturate the browser's per-host connection pool, and + * queue the mock's own refetches behind them. + */ +export const BILLING_DEV_MOCK_SWR_KEY_PREFIXES = [ + "billing-", + "app-sidebar-subscription", + "notifications-", + "status-hint-", + "chat-free-turns", +] as const; + +/** SWR `mutate` filter: true for keys the billing Dev Mock's fixtures shape. */ +export function isBillingDevMockSwrKey(key: unknown): boolean { + const head = Array.isArray(key) ? key[0] : key; + return ( + typeof head === "string" && + BILLING_DEV_MOCK_SWR_KEY_PREFIXES.some((prefix) => head.startsWith(prefix)) + ); +} diff --git a/apps/ui/src/features/billing/server/dev-fixtures/index.ts b/apps/ui/src/features/billing/server/dev-fixtures/index.ts index 455a0db1..44e81d71 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -1,10 +1,11 @@ import { - BILLING_DEV_MOCK_COOKIE, - BILLING_DEV_SCENARIOS, type BillingDevScenario, - formatBillingDevMockCookie, - parseBillingDevMockCookie, + billingDevMockCookie, } from "@/features/billing/dev-mock-cookie"; +import { + type DevMockResolution, + resolveDevMock, +} from "@/features/dev-mock/server/resolve"; import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; /** @@ -897,71 +898,28 @@ const PAY_TRANSITIONS: Record< }, }; -function mockCookieValue(request: Request): string | undefined { - const header = request.headers.get("cookie") ?? ""; - for (const pair of header.split(";")) { - const separator = pair.indexOf("="); - if (separator === -1) { - continue; - } - if (pair.slice(0, separator).trim() === BILLING_DEV_MOCK_COOKIE) { - return decodeURIComponent(pair.slice(separator + 1).trim()); - } - } - return undefined; -} - function transitionHeaders( nextScenario: BillingDevScenario ): Record { - const value = formatBillingDevMockCookie({ - enabled: true, - scenario: nextScenario, - }); return { - "set-cookie": `${BILLING_DEV_MOCK_COOKIE}=${value}; Path=/; SameSite=Lax`, + "set-cookie": billingDevMockCookie.setCookieHeader({ + enabled: true, + scenario: nextScenario, + }), }; } -export type BillingDevMockResolution = - | { kind: "invalid"; response: Response } - | { kind: "off" } - | { kind: "set"; scenario: BillingDevScenario }; +export type BillingDevMockResolution = DevMockResolution; /** - * Reads the mock cookie off a request: off (no cookie, or disabled, or a - * real production build), invalid (a 500 that names the valid scenarios so - * typos fail loud), or the scenario to serve. Shared by every fixture - * dispatcher so the one cookie drives billing and notifications alike. + * Reads the billing mock cookie off a request (see `resolveDevMock`). Shared + * by every fixture dispatcher the billing scenario drives — billing, + * billing-born notifications, the chat free-trial judgment. */ export function resolveBillingDevMock( request: Request ): BillingDevMockResolution { - if ( - process.env.NODE_ENV === "production" && - process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" - ) { - return { kind: "off" }; - } - const parsed = parseBillingDevMockCookie(mockCookieValue(request)); - if ( - parsed.kind === "unset" || - (parsed.kind === "set" && !parsed.state.enabled) - ) { - return { kind: "off" }; - } - if (parsed.kind === "invalid") { - return { - kind: "invalid", - response: Response.json( - { - error: `Unknown billing mock scenario "${parsed.raw}". Valid scenarios: ${BILLING_DEV_SCENARIOS.join(", ")}. Toggle the mock from the dev tweaks pane (⌃⌥T).`, - }, - { status: 500 } - ), - }; - } - return { kind: "set", scenario: parsed.state.scenario }; + return resolveDevMock(billingDevMockCookie, request, "billing"); } /** The workspace fixtures address when the request names none. */ diff --git a/apps/ui/src/features/dev-mock/cookie.ts b/apps/ui/src/features/dev-mock/cookie.ts new file mode 100644 index 00000000..f52ae42f --- /dev/null +++ b/apps/ui/src/features/dev-mock/cookie.ts @@ -0,0 +1,97 @@ +/** + * The cookie protocol every route-handler Dev Mock speaks, shared by its + * dev-tweaks registration (client) and its fixture dispatcher (server). A + * Dev Mock's session cookie is the single source of truth for its state — + * no env var, no localStorage copy — and each Dev Mock owns one cookie, so + * independent mocks compose: the billing mock and the Notification Center + * mock can be on at once, each answering only its own surfaces. + * + * Value grammar: `` while the mock is on, `off:` while it + * is off (the scenario part keeps the last selection so the panel can + * restore it). Anything else is invalid and makes the server answer 500 so + * typos fail loud instead of silently serving real data. + */ + +const OFF_PREFIX = "off:"; + +export interface DevMockState { + enabled: boolean; + scenario: S; +} + +export type ParsedDevMockCookie = + | { kind: "invalid"; raw: string } + | { kind: "set"; state: DevMockState } + | { kind: "unset" }; + +export interface DevMockCookieDef { + defaultScenario: S; + /** The session cookie's name. */ + name: string; + scenarios: readonly S[]; +} + +export interface DevMockCookie extends DevMockCookieDef { + /** The `document.cookie` assignment the client writes on a toggle. */ + documentCookie(state: DevMockState): string; + format(state: DevMockState): string; + /** The raw cookie value off a request's `cookie` header, if present. */ + fromRequest(request: Request): string | undefined; + is(value: string): value is S; + parse(raw: string | null | undefined): ParsedDevMockCookie; + /** The cookie the server sends when it advances the scenario itself. */ + setCookieHeader(state: DevMockState): string; +} + +function cookieValue(header: string | null, name: string): string | undefined { + for (const pair of (header ?? "").split(";")) { + const separator = pair.indexOf("="); + if (separator === -1) { + continue; + } + if (pair.slice(0, separator).trim() === name) { + return decodeURIComponent(pair.slice(separator + 1).trim()); + } + } + return undefined; +} + +export function defineDevMockCookie( + def: DevMockCookieDef +): DevMockCookie { + const is = (value: string): value is S => + (def.scenarios as readonly string[]).includes(value); + const format = (state: DevMockState): string => + state.enabled ? state.scenario : `${OFF_PREFIX}${state.scenario}`; + return { + ...def, + documentCookie: (state) => + `${def.name}=${format(state)}; path=/; samesite=lax`, + format, + fromRequest: (request) => + cookieValue(request.headers.get("cookie"), def.name), + is, + parse: (raw) => { + const value = raw?.trim() ?? ""; + if (value === "") { + return { kind: "unset" }; + } + if (value.startsWith(OFF_PREFIX)) { + const scenario = value.slice(OFF_PREFIX.length); + return { + kind: "set", + state: { + enabled: false, + scenario: is(scenario) ? scenario : def.defaultScenario, + }, + }; + } + if (is(value)) { + return { kind: "set", state: { enabled: true, scenario: value } }; + } + return { kind: "invalid", raw: value }; + }, + setCookieHeader: (state) => + `${def.name}=${format(state)}; Path=/; SameSite=Lax`, + }; +} diff --git a/apps/ui/src/features/dev-mock/server/resolve.ts b/apps/ui/src/features/dev-mock/server/resolve.ts new file mode 100644 index 00000000..8ef242ef --- /dev/null +++ b/apps/ui/src/features/dev-mock/server/resolve.ts @@ -0,0 +1,46 @@ +import type { DevMockCookie } from "../cookie"; + +export type DevMockResolution = + | { kind: "invalid"; response: Response } + | { kind: "off" } + | { kind: "set"; scenario: S }; + +/** + * Reads a Dev Mock's cookie off a request: off (no cookie, or disabled, or a + * real production build), invalid (a 500 that names the valid scenarios so + * typos fail loud), or the scenario to serve. Every fixture dispatcher + * resolves through here; the dynamic-import gate that keeps fixtures out of + * production bundles stays inlined at each route (a helper call would not + * be statically dropped), this is the runtime backstop behind it. + */ +export function resolveDevMock( + cookie: DevMockCookie, + request: Request, + label: string +): DevMockResolution { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return { kind: "off" }; + } + const parsed = cookie.parse(cookie.fromRequest(request)); + if ( + parsed.kind === "unset" || + (parsed.kind === "set" && !parsed.state.enabled) + ) { + return { kind: "off" }; + } + if (parsed.kind === "invalid") { + return { + kind: "invalid", + response: Response.json( + { + error: `Unknown ${label} mock scenario "${parsed.raw}". Valid scenarios: ${cookie.scenarios.join(", ")}. Toggle the mock from the dev tweaks pane (⌃⌥T).`, + }, + { status: 500 } + ), + }; + } + return { kind: "set", scenario: parsed.state.scenario }; +} diff --git a/apps/ui/src/features/dev-mock/source.ts b/apps/ui/src/features/dev-mock/source.ts new file mode 100644 index 00000000..ab961815 --- /dev/null +++ b/apps/ui/src/features/dev-mock/source.ts @@ -0,0 +1,56 @@ +"use client"; + +import type { DevTweaksMockSource } from "@workspace/dev-tweaks"; + +import type { DevMockCookie } from "./cookie"; + +/** + * The dev-tweaks panel's remote control for a cookie-backed Dev Mock. The + * panel never owns the state: `load` re-reads the cookie, `set` writes it + * and then asks the feature to revalidate the SWR keys its fixtures shape, + * `watch` picks up rewrites the server makes behind the panel's back (a + * fixture's `Set-Cookie` scenario transition) — the Cookie Store API pushes + * those, the focus/interval pair is the fallback. + */ +export function createDevMockCookieSource( + cookie: DevMockCookie, + options: { revalidate: () => void } +): DevTweaksMockSource { + return { + load: () => { + const parsed = cookie.parse( + cookie.fromRequest( + new Request("http://mock.local", { + headers: { cookie: document.cookie }, + }) + ) + ); + return parsed.kind === "set" ? parsed.state : null; + }, + set: (state) => { + const scenario = cookie.is(state.scenario) + ? state.scenario + : cookie.defaultScenario; + // biome-ignore lint/suspicious/noDocumentCookie: the synchronous write must land before the SWR refetches fire; the async Cookie Store API cannot guarantee that. + document.cookie = cookie.documentCookie({ + enabled: state.enabled, + scenario, + }); + options.revalidate(); + }, + watch: (onChange) => { + const cookieStore = (window as { cookieStore?: EventTarget }).cookieStore; + cookieStore?.addEventListener("change", onChange); + window.addEventListener("focus", onChange); + const watchTimer = + cookieStore == null ? window.setInterval(onChange, 3000) : undefined; + return () => { + cookieStore?.removeEventListener("change", onChange); + window.removeEventListener("focus", onChange); + if (watchTimer != null) { + window.clearInterval(watchTimer); + } + }; + }, + }; +} From 14ddf4cc1bba1553c4f4b09488edd046e1d794e4 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 11:06:38 +0800 Subject: [PATCH 12/14] feat(notifications): Notification Center Dev Mock for platform-origin messages (AIM-339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second, independent Dev Mock (cookie sealai-notifications-dev-mock) serves announcements, deployment outcomes and database events as fixture platform CRs, layered over whatever answers below — the billing mock's feed or the real handler — so the two mocks stack. Read receipts for fixture ids stay in memory; the rest forward. Scenarios: announcement, deployment, db-event, mixed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9 --- .../notifications/dev-fixtures.test.ts | 169 ++++++++ .../features/notifications/dev-fixtures.ts | 406 ++++++++++++++++++ .../features/notifications/dev-mock-cookie.ts | 29 ++ .../features/notifications/dev-mock-gate.tsx | 22 + .../src/features/notifications/dev-mock.tsx | 44 ++ .../features/notifications/route-handlers.ts | 26 +- 6 files changed, 688 insertions(+), 8 deletions(-) create mode 100644 apps/ui/src/features/notifications/dev-fixtures.test.ts create mode 100644 apps/ui/src/features/notifications/dev-fixtures.ts create mode 100644 apps/ui/src/features/notifications/dev-mock-cookie.ts create mode 100644 apps/ui/src/features/notifications/dev-mock-gate.tsx create mode 100644 apps/ui/src/features/notifications/dev-mock.tsx diff --git a/apps/ui/src/features/notifications/dev-fixtures.test.ts b/apps/ui/src/features/notifications/dev-fixtures.test.ts new file mode 100644 index 00000000..70c44ca9 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-fixtures.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; + +import { billingDevMockCookie } from "@/features/billing/dev-mock-cookie"; +import { notificationDevMockResponse } from "@/features/billing/server/dev-fixtures/notifications"; +import { + platformNotificationDevMockItems, + withPlatformNotificationDevMock, +} from "./dev-fixtures"; +import { + NOTIFICATIONS_DEV_SCENARIOS, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; +import { mergeNotificationFeed } from "./feed-model"; +import { crNotificationId } from "./notification-ids"; +import { + type NotificationFeedResponse, + notificationFeedResponseSchema, +} from "./types"; + +const REAL_FEED: NotificationFeedResponse = { + messages: [], + receipts: ["db:real-1"], +}; + +function request( + path: string, + cookies: Record, + init?: RequestInit +): Request { + const req = new Request(`http://localhost${path}?namespace=ns-test`, init); + req.headers.set( + "cookie", + Object.entries(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; ") + ); + return req; +} + +const realFeed = () => Promise.resolve(Response.json(REAL_FEED)); +const realRead = async (req: Request) => + Response.json({ read: ((await req.json()) as { ids: string[] }).ids }); + +describe("platform notification dev mock", () => { + test("off hands the request down untouched", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "off:mixed", + }), + realFeed + ); + expect(await response.json()).toEqual(REAL_FEED); + }); + + test("an unknown scenario fails loud", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "nope", + }), + realFeed + ); + expect(response.status).toBe(500); + }); + + for (const scenario of NOTIFICATIONS_DEV_SCENARIOS) { + test(`${scenario} layers fixture platform items over the real feed`, async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: scenario, + }), + realFeed + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + const expected = platformNotificationDevMockItems(scenario, "ns-test"); + expect(expected.length).toBeGreaterThan(0); + expect(feed.platformItems).toEqual(expected); + expect(feed.receipts).toContain("db:real-1"); + const merged = mergeNotificationFeed({ + crItems: feed.platformItems ?? [], + dbMessages: feed.messages, + receipts: feed.receipts, + }); + expect(merged).toHaveLength(expected.length); + expect(new Set(merged.map((item) => item.id)).size).toBe(expected.length); + }); + } + + test("mixed is a month of mostly-read traffic, newest first", () => { + const items = platformNotificationDevMockItems("mixed", "ns-test"); + expect(items.length).toBeGreaterThanOrEqual(15); + expect(items.filter((item) => !item.isRead).length).toBeLessThan(5); + for (let index = 1; index < items.length; index += 1) { + expect(items[index - 1]?.timestamp).toBeGreaterThanOrEqual( + items[index]?.timestamp ?? 0 + ); + } + }); + + test("stacks on the billing mock's feed", async () => { + const cookies = { + [billingDevMockCookie.name]: "payg-debt", + [notificationsDevMockCookie.name]: "announcement", + }; + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", cookies), + async (req) => + (await notificationDevMockResponse("feed", req)) ?? realFeed() + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + const names = (feed.platformItems ?? []).map((item) => item.name); + expect(names).toContain("debt-choice-debtperiod"); + expect(names).toContain("announce-db-terminal"); + }); + + test("falls back to fixtures alone when the layer below fails", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "db-event", + }), + () => Promise.resolve(Response.json({ error: "down" }, { status: 503 })) + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + expect(feed.messages).toEqual([]); + expect(feed.platformItems).toHaveLength(1); + }); + + test("keeps fixture read receipts in memory and forwards the rest", async () => { + const item = platformNotificationDevMockItems("deployment", "ns-test")[0]; + if (item == null) { + throw new Error("deployment fixture is empty"); + } + const id = crNotificationId(item.name, item.version); + const cookies = { [notificationsDevMockCookie.name]: "deployment" }; + const forwarded: string[][] = []; + const next = async (req: Request) => { + forwarded.push(((await req.json()) as { ids: string[] }).ids); + return realRead( + new Request(req, { body: JSON.stringify({ ids: forwarded.at(-1) }) }) + ); + }; + const response = await withPlatformNotificationDevMock( + "read", + request("/api/notifications/read", cookies, { + body: JSON.stringify({ ids: [id, "db:real-1"] }), + headers: { "content-type": "application/json" }, + method: "POST", + }), + next + ); + expect(await response.json()).toEqual({ read: [id, "db:real-1"] }); + expect(forwarded).toEqual([["db:real-1"]]); + + const feed = notificationFeedResponseSchema.parse( + await ( + await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", cookies), + realFeed + ) + ).json() + ); + expect(feed.receipts).toContain(id); + }); +}); diff --git a/apps/ui/src/features/notifications/dev-fixtures.ts b/apps/ui/src/features/notifications/dev-fixtures.ts new file mode 100644 index 00000000..e6e062ac --- /dev/null +++ b/apps/ui/src/features/notifications/dev-fixtures.ts @@ -0,0 +1,406 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { DAY_MS, HOUR_MS } from "@/lib/time"; + +import { + type NotificationsDevScenario, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; +import { crNotificationId } from "./notification-ids"; +import { + markNotificationReadRequestSchema, + type NotificationFeedResponse, + notificationFeedResponseSchema, +} from "./types"; + +/** + * Platform-origin Notification fixtures (the `cr:` stream): announcements, + * deployment outcomes, and database events as the platform's controllers + * would write them. They ride the feed as `platformItems`, so the client + * takes them instead of polling the cluster and merges, sorts, and marks + * them read exactly as live CRs. The billing-born ladder stays the billing + * mock's; this layer only ever appends. Read state is session-only memory + * per scenario. + */ + +/** Anchored at module load so fixture ids stay stable across polls. */ +const FIXTURE_NOW_MS = Date.now(); + +const ADMIN = "Admin"; +const DEPLOYMENT_SYSTEM = "Deployment-System"; +const DATABASE_SYSTEM = "Database-System"; + +interface PlatformFixture { + ago: number; + from: string; + isRead: boolean; + message: string; + name: string; + title: string; +} + +const ANNOUNCEMENTS: readonly Omit[] = [ + { + from: ADMIN, + message: + "Open a terminal on any database from its page — no client install needed.", + name: "announce-db-terminal", + title: "New: database terminal", + }, + { + from: ADMIN, + message: + "Deployments now show a step-by-step timeline with the failing step called out.", + name: "announce-deploy-timeline", + title: "New: deployment timeline", + }, + { + from: ADMIN, + message: + "Scheduled maintenance on Sunday 02:00–04:00 UTC. Running workloads are not affected.", + name: "announce-maintenance-window", + title: "Scheduled maintenance", + }, + { + from: ADMIN, + message: "Custom domains can now be verified with a single CNAME record.", + name: "announce-custom-domains", + title: "Simpler custom domains", + }, + { + from: ADMIN, + message: + "Workspace members can be invited by email from the workspace settings.", + name: "announce-invites", + title: "Invite by email", + }, + { + from: ADMIN, + message: "Node pools were upgraded; new pods start on the faster nodes.", + name: "announce-node-upgrade", + title: "Faster nodes", + }, +]; + +const DEPLOYMENTS: readonly Omit[] = [ + { + from: DEPLOYMENT_SYSTEM, + message: "web-app is live on 2 replicas from main@4f1c2a9.", + name: "deploy-web-app-succeeded", + title: "web-app deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "api-server failed at the build step: `go build` exited with status 2.", + name: "deploy-api-server-failed", + title: "api-server deployment failed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "landing is live from main@b77e0d1.", + name: "deploy-landing-succeeded", + title: "landing deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "image-worker is waiting for a storage volume to bind; it will continue on its own.", + name: "deploy-image-worker-blocked", + title: "image-worker deployment waiting", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "docs-site is live from main@91aa3c0.", + name: "deploy-docs-site-succeeded", + title: "docs-site deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "auth-service rolled back to the previous image after a failed health check.", + name: "deploy-auth-service-rolled-back", + title: "auth-service rolled back", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "analytics is live on 1 replica from main@0c4d7e2.", + name: "deploy-analytics-succeeded", + title: "analytics deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "cron-runner deployment was cancelled before the build finished.", + name: "deploy-cron-runner-cancelled", + title: "cron-runner deployment cancelled", + }, +]; + +const DB_EVENTS: readonly Omit[] = [ + { + from: DATABASE_SYSTEM, + message: "The nightly backup of pg-main completed (1.2 GB).", + name: "db-pg-main-backup-completed", + title: "pg-main backup completed", + }, + { + from: DATABASE_SYSTEM, + message: + "redis-cache restarted after exceeding its memory limit; data in memory was lost.", + name: "db-redis-cache-restarted", + title: "redis-cache restarted", + }, + { + from: DATABASE_SYSTEM, + message: "pg-main storage is at 85% of its volume. Consider resizing.", + name: "db-pg-main-storage-warning", + title: "pg-main storage almost full", + }, + { + from: DATABASE_SYSTEM, + message: "search-index was restored from the backup taken 3 days ago.", + name: "db-search-index-restored", + title: "search-index restored", + }, +]; + +function spread( + entries: readonly Omit[], + options: { firstAgo: number; step: number; unread: number } +): PlatformFixture[] { + return entries.map((entry, index) => ({ + ...entry, + ago: options.firstAgo + index * options.step, + isRead: index >= options.unread, + })); +} + +/** One inbox per scenario, newest first. */ +function scenarioFixture( + scenario: NotificationsDevScenario +): PlatformFixture[] { + switch (scenario) { + case "announcement": + return spread(ANNOUNCEMENTS.slice(0, 1), { + firstAgo: 2 * DAY_MS, + step: 0, + unread: 1, + }); + case "deployment": + return spread(DEPLOYMENTS.slice(0, 2), { + firstAgo: HOUR_MS, + step: 2 * HOUR_MS, + unread: 2, + }); + case "db-event": + return spread(DB_EVENTS.slice(0, 1), { + firstAgo: 5 * HOUR_MS, + step: 0, + unread: 1, + }); + case "mixed": { + // Thirty days of traffic, interleaved by time, only the newest unread. + const items = [ + ...spread(DEPLOYMENTS, { + firstAgo: HOUR_MS, + step: 3.5 * DAY_MS, + unread: 2, + }), + ...spread(DB_EVENTS, { + firstAgo: 5 * HOUR_MS, + step: 7 * DAY_MS, + unread: 1, + }), + ...spread(ANNOUNCEMENTS, { + firstAgo: 2 * DAY_MS, + step: 5 * DAY_MS, + unread: 0, + }), + ]; + return items.sort((a, b) => a.ago - b.ago); + } + default: + return scenario satisfies never; + } +} + +function platformItem( + fixture: PlatformFixture, + workspace: string +): NotificationCRItem { + const seconds = Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000); + return { + desktopPopup: true, + from: fixture.from, + importance: fixture.from === ADMIN ? "Medium" : "High", + isRead: fixture.isRead, + message: fixture.message, + name: fixture.name, + namespace: workspace, + timestamp: seconds, + title: fixture.title, + version: seconds, + }; +} + +export function platformNotificationDevMockItems( + scenario: NotificationsDevScenario, + workspace: string +): NotificationCRItem[] { + return scenarioFixture(scenario).map((entry) => + platformItem(entry, workspace) + ); +} + +// Session-only read state, one inbox per scenario; nothing persists. +const receiptsByScenario = new Map>(); + +function receiptsFor(scenario: string): Set { + let receipts = receiptsByScenario.get(scenario); + if (receipts == null) { + receipts = new Set(); + receiptsByScenario.set(scenario, receipts); + } + return receipts; +} + +function fixtureIds( + scenario: NotificationsDevScenario, + workspace: string +): Set { + return new Set( + platformNotificationDevMockItems(scenario, workspace).map((item) => + crNotificationId(item.name, item.version) + ) + ); +} + +export type PlatformNotificationDevMockHandler = + | "feed" + | "observation" + | "read"; + +/** The next layer down: the billing mock's fixtures or the real handler. */ +export type NotificationDevMockNext = (request: Request) => Promise; + +/** The feed the mock answers alone when the layer below is unavailable. */ +function standaloneFeed( + scenario: NotificationsDevScenario, + workspace: string +): NotificationFeedResponse { + return { + messages: [], + platformItems: platformNotificationDevMockItems(scenario, workspace), + receipts: [...receiptsFor(scenario)], + }; +} + +async function feedResponse( + scenario: NotificationsDevScenario, + request: Request, + next: NotificationDevMockNext +): Promise { + const workspace = + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock"; + const below = await next(request); + if (!below.ok) { + return Response.json(standaloneFeed(scenario, workspace)); + } + const parsed = notificationFeedResponseSchema.safeParse( + await below.json().catch(() => null) + ); + if (!parsed.success) { + return Response.json(standaloneFeed(scenario, workspace)); + } + const body: NotificationFeedResponse = { + messages: parsed.data.messages, + platformItems: [ + ...(parsed.data.platformItems ?? []), + ...platformNotificationDevMockItems(scenario, workspace), + ], + receipts: [...new Set([...parsed.data.receipts, ...receiptsFor(scenario)])], + }; + return Response.json(body); +} + +async function readResponse( + scenario: NotificationsDevScenario, + request: Request, + next: NotificationDevMockNext +): Promise { + const parsed = markNotificationReadRequestSchema.safeParse( + await request + .clone() + .json() + .catch(() => null) + ); + if (!parsed.success) { + return next(request); + } + const workspace = + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock"; + const owned = fixtureIds(scenario, workspace); + const mine = parsed.data.ids.filter((id) => owned.has(id)); + const rest = parsed.data.ids.filter((id) => !owned.has(id)); + const receipts = receiptsFor(scenario); + for (const id of mine) { + receipts.add(id); + } + if (rest.length === 0) { + return Response.json({ read: mine }); + } + if (mine.length === 0) { + return next(request); + } + const forwarded = new Request(request, { + body: JSON.stringify({ ids: rest }), + }); + const below = await next(forwarded); + if (!below.ok) { + return below; + } + const payload: unknown = await below.json().catch(() => null); + const read = + typeof payload === "object" && + payload != null && + Array.isArray((payload as { read?: unknown }).read) + ? ((payload as { read: string[] }).read ?? []) + : rest; + return Response.json({ read: [...mine, ...read] }); +} + +/** + * Layers the platform fixtures over whatever answers below — the billing + * mock's feed or the real handler: the feed gains the fixture `cr:` items, + * a mark-read for fixture ids is kept in memory and the rest forwarded, + * observations pass straight through. Off hands the request down untouched. + */ +export function withPlatformNotificationDevMock( + handler: PlatformNotificationDevMockHandler, + request: Request, + next: NotificationDevMockNext +): Promise { + const resolution = resolveDevMock( + notificationsDevMockCookie, + request, + "Notification Center" + ); + if (resolution.kind === "off") { + return next(request); + } + if (resolution.kind === "invalid") { + return Promise.resolve(resolution.response); + } + const { scenario } = resolution; + switch (handler) { + case "feed": + return feedResponse(scenario, request, next); + case "read": + return readResponse(scenario, request, next); + case "observation": + return next(request); + default: + return handler satisfies never; + } +} diff --git a/apps/ui/src/features/notifications/dev-mock-cookie.ts b/apps/ui/src/features/notifications/dev-mock-cookie.ts new file mode 100644 index 00000000..04f3b8ec --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock-cookie.ts @@ -0,0 +1,29 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Notification Center Dev Mock's cookie: platform-origin Notifications + * (announcements, deployment outcomes, database events — the `cr:` stream) + * served from fixtures. Independent of the billing mock, whose scenario + * keeps driving the billing-born messages; both can be on at once and the + * inbox merges them. + */ + +export const NOTIFICATIONS_DEV_SCENARIOS = [ + "announcement", + "deployment", + "db-event", + "mixed", +] as const; + +export type NotificationsDevScenario = + (typeof NOTIFICATIONS_DEV_SCENARIOS)[number]; + +export const DEFAULT_NOTIFICATIONS_DEV_SCENARIO: NotificationsDevScenario = + "mixed"; + +export const notificationsDevMockCookie = + defineDevMockCookie({ + defaultScenario: DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + name: "sealai-notifications-dev-mock", + scenarios: NOTIFICATIONS_DEV_SCENARIOS, + }); diff --git a/apps/ui/src/features/notifications/dev-mock-gate.tsx b/apps/ui/src/features/notifications/dev-mock-gate.tsx new file mode 100644 index 00000000..02bf79d2 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock-gate.tsx @@ -0,0 +1,22 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Same inlined gate as dev-tweaks.tsx: a real production build statically +// drops the dynamic import and tree-shakes the mock module away; +// `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. +const NotificationsDevMockTweaks = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" + ? dynamic(() => + import("./dev-mock").then((mod) => mod.NotificationsDevMockTweaks) + ) + : null; + +/** Mounts the Notification Center mock's dev-tweaks registration. */ +export function NotificationsDevMockGate() { + if (!NotificationsDevMockTweaks) { + return null; + } + return ; +} diff --git a/apps/ui/src/features/notifications/dev-mock.tsx b/apps/ui/src/features/notifications/dev-mock.tsx new file mode 100644 index 00000000..06587f61 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; +import { mutate } from "swr"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + NOTIFICATIONS_DEV_SCENARIOS, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; + +export const NOTIFICATIONS_DEV_MOCK_KEY = "notifications-mock"; + +/** Only the Brain feed carries the fixture platform items. */ +function isNotificationFeedKey(key: unknown): boolean { + return Array.isArray(key) && key[0] === "notifications-feed"; +} + +const notificationsDevMockSource = createDevMockCookieSource( + notificationsDevMockCookie, + { + revalidate: () => { + mutate(isNotificationFeedKey).catch(() => undefined); + }, + } +); + +/** + * Registers the Notification Center's Dev Mock while the inbox is mounted; + * renders nothing. Stacks with the billing mock: this one adds the + * platform-origin messages, that one keeps the billing-born ones. + */ +export function NotificationsDevMockTweaks() { + useDevTweaksMock(NOTIFICATIONS_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + note: "Adds platform-origin Notifications from fixtures; stacks with the billing mock", + scenarios: NOTIFICATIONS_DEV_SCENARIOS, + source: notificationsDevMockSource, + title: "Notification Center mock", + }); + return null; +} diff --git a/apps/ui/src/features/notifications/route-handlers.ts b/apps/ui/src/features/notifications/route-handlers.ts index 0875f2e8..dd232f5d 100644 --- a/apps/ui/src/features/notifications/route-handlers.ts +++ b/apps/ui/src/features/notifications/route-handlers.ts @@ -8,10 +8,12 @@ import { notificationStore } from "./server-store"; type NotificationRouteHandler = (request: Request) => Promise; /** - * Lets the billing Dev Mock's notification fixtures answer first in dev and - * demo builds (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image); a real - * production build statically drops the dynamic import, so fixtures never - * reach production bundles — the same gate as the /api/billing routes. + * Lets the Dev Mocks answer first in dev and demo builds + * (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image): the Notification Center + * mock layers its platform fixtures over the billing mock's feed or the + * real handler, whichever is below. A real production build statically + * drops the dynamic imports, so fixtures never reach production bundles — + * the same gate as the /api/billing routes. */ function withNotificationDevMock( handler: NotificationDevMockHandler, @@ -24,11 +26,19 @@ function withNotificationDevMock( return real; } return async (request) => { - const { notificationDevMockResponse } = await import( - "@/features/billing/server/dev-fixtures/notifications" + const [ + { notificationDevMockResponse }, + { withPlatformNotificationDevMock }, + ] = await Promise.all([ + import("@/features/billing/server/dev-fixtures/notifications"), + import("./dev-fixtures"), + ]); + return withPlatformNotificationDevMock( + handler, + request, + async (layered) => + (await notificationDevMockResponse(handler, layered)) ?? real(layered) ); - const mocked = await notificationDevMockResponse(handler, request); - return mocked ?? real(request); }; } From 6b2edda0ca6134114d6437da4b08a41158367e38 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 11:06:38 +0800 Subject: [PATCH 13/14] feat(deploy): Deployment Task Timeline Dev Mock with static snapshots (AIM-336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cookie sealai-deploy-task-dev-mock serves one AI-runner fixture task per scenario (running, blocked, failed, succeeded, cancelled): the task list and projection stream answer with it so the dock chip appears, and the timeline routes answer for its id only, leaving real tasks reachable. The SSE routes send one snapshot frame and then only heartbeats, so the client never enters its reconnect loop. Toggling reloads the page — the stores hold SSE connections, not SWR keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9 --- .../deploy-task-projections/stream/route.ts | 5 +- .../deploy-tasks/[taskId]/timeline/route.ts | 7 +- .../[taskId]/timeline/stream/route.ts | 9 +- apps/ui/src/app/api/deploy-tasks/route.ts | 5 +- apps/ui/src/app/project/layout.tsx | 2 + .../features/deploy/task/dev-fixtures.test.ts | 149 ++++ .../src/features/deploy/task/dev-fixtures.ts | 684 ++++++++++++++++++ .../features/deploy/task/dev-mock-cookie.ts | 34 + .../features/deploy/task/dev-mock-gate.tsx | 22 + .../features/deploy/task/dev-mock-route.ts | 41 ++ apps/ui/src/features/deploy/task/dev-mock.tsx | 33 + 11 files changed, 987 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/features/deploy/task/dev-fixtures.test.ts create mode 100644 apps/ui/src/features/deploy/task/dev-fixtures.ts create mode 100644 apps/ui/src/features/deploy/task/dev-mock-cookie.ts create mode 100644 apps/ui/src/features/deploy/task/dev-mock-gate.tsx create mode 100644 apps/ui/src/features/deploy/task/dev-mock-route.ts create mode 100644 apps/ui/src/features/deploy/task/dev-mock.tsx diff --git a/apps/ui/src/app/api/deploy-task-projections/stream/route.ts b/apps/ui/src/app/api/deploy-task-projections/stream/route.ts index 23d12a8b..8f4c020c 100644 --- a/apps/ui/src/app/api/deploy-task-projections/stream/route.ts +++ b/apps/ui/src/app/api/deploy-task-projections/stream/route.ts @@ -2,6 +2,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import type { DeploymentTaskProjectionStreamEvent } from "@/features/deploy/task/projection"; import { type DeploymentTaskEventsSubscription, @@ -22,7 +23,7 @@ function encodeSse(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } -export async function GET(request: Request) { +async function handleGet(request: Request) { const url = new URL(request.url); const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -157,3 +158,5 @@ export async function GET(request: Request) { }, }); } + +export const GET = withDeployTaskDevMock("projections-stream", handleGet); diff --git a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts index 76f374f0..3143d9b7 100644 --- a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts @@ -4,6 +4,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { getDeployTaskTimelineSnapshot } from "@/features/deploy/task/service"; export const dynamic = "force-dynamic"; @@ -17,7 +18,7 @@ function jsonError(message: string, status: number) { return NextResponse.json({ error: message }, { status }); } -export async function GET(request: Request, context: RouteContext) { +async function handleGet(request: Request, context: RouteContext) { const { taskId } = await context.params; const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -42,3 +43,7 @@ export async function GET(request: Request, context: RouteContext) { } return NextResponse.json(snapshot); } + +export const GET = withDeployTaskDevMock("timeline", handleGet, (context) => + context.params.then((params) => params.taskId) +); diff --git a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts index 15d6fed9..9a02749c 100644 --- a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts @@ -2,6 +2,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { getDeployTaskTimelineSnapshot } from "@/features/deploy/task/service"; import { type DeploymentTaskTimelineSubscription, @@ -26,7 +27,7 @@ function encodeSse(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } -export async function GET(request: Request, context: RouteContext) { +async function handleGet(request: Request, context: RouteContext) { const { taskId } = await context.params; const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -163,3 +164,9 @@ export async function GET(request: Request, context: RouteContext) { }, }); } + +export const GET = withDeployTaskDevMock( + "timeline-stream", + handleGet, + (context) => context.params.then((params) => params.taskId) +); diff --git a/apps/ui/src/app/api/deploy-tasks/route.ts b/apps/ui/src/app/api/deploy-tasks/route.ts index ff26308a..d1258854 100644 --- a/apps/ui/src/app/api/deploy-tasks/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/route.ts @@ -10,6 +10,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { createDeployTaskAction } from "@/features/deploy/task/engine/actions"; import { getDeployTaskEngineContext } from "@/features/deploy/task/engine/server"; import { @@ -165,7 +166,7 @@ async function resolveCredentialBinding(input: { }; } -export async function GET(request: Request) { +async function handleGet(request: Request) { const url = new URL(request.url); const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -221,6 +222,8 @@ export async function GET(request: Request) { return NextResponse.json({ projections }); } +export const GET = withDeployTaskDevMock("list", handleGet); + export async function POST(request: Request) { const body = await request.json().catch(() => null); const parsed = requestSchema.safeParse(body); diff --git a/apps/ui/src/app/project/layout.tsx b/apps/ui/src/app/project/layout.tsx index 4be2bcc8..27c8b0dd 100644 --- a/apps/ui/src/app/project/layout.tsx +++ b/apps/ui/src/app/project/layout.tsx @@ -1,3 +1,4 @@ +import { DeployTaskDevMockGate } from "@/features/deploy/task/dev-mock-gate"; import { OnboardingGate } from "@/features/onboarding/onboarding-gate"; import { ProjectsExplorerDevMockGate } from "@/features/projects/explorer/projects-dev-mock-gate"; import { @@ -28,6 +29,7 @@ export default function ProjectLayout({ + diff --git a/apps/ui/src/features/deploy/task/dev-fixtures.test.ts b/apps/ui/src/features/deploy/task/dev-fixtures.test.ts new file mode 100644 index 00000000..7b368961 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-fixtures.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { + deployTaskDevMockProjection, + deployTaskDevMockResponse, + deployTaskDevMockTask, +} from "./dev-fixtures"; +import { + DEPLOY_TASK_DEV_SCENARIOS, + deployTaskDevMockCookie, + deployTaskDevMockTaskId, +} from "./dev-mock-cookie"; +import { deploymentTaskProjectionIsVisible } from "./projection"; + +const NOW_MS = Date.parse("2026-08-28T10:00:00.000Z"); + +function request(path: string, scenario: string): Request { + const req = new Request(`http://localhost${path}`); + req.headers.set("cookie", `${deployTaskDevMockCookie.name}=${scenario}`); + return req; +} + +describe("deployment task dev fixtures", () => { + for (const scenario of DEPLOY_TASK_DEV_SCENARIOS) { + test(`${scenario} is a coherent task, timeline and projection`, () => { + const snapshot = deployTaskDevMockTask(scenario, { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect(snapshot.task.id).toBe(deployTaskDevMockTaskId(scenario)); + expect(snapshot.timeline.taskId).toBe(snapshot.task.id); + expect(snapshot.timeline.status).toBe(snapshot.task.status); + expect(snapshot.timeline.steps.length).toBeGreaterThan(0); + expect(snapshot.timeline.steps.map((step) => step.order)).toEqual([ + ...snapshot.timeline.steps.keys(), + ]); + const projection = deployTaskDevMockProjection(scenario, { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect(projection.id).toBe(snapshot.task.id); + expect(projection.projectId).toBe("project-1"); + expect(projection.status).toBe(snapshot.task.status); + }); + } + + test("an active task places itself on the canvas", () => { + const projection = deployTaskDevMockProjection("running", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect( + deploymentTaskProjectionIsVisible(projection, new Date(NOW_MS)) + ).toBe(true); + }); + + test("blocked carries the inputs the pane asks for", () => { + const { task } = deployTaskDevMockTask("blocked", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: null, + }); + expect(task.blockingInputs.length).toBeGreaterThan(0); + }); + + test("failed names a scrubbed failure reason", () => { + const { task, timeline } = deployTaskDevMockTask("failed", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: null, + }); + expect(task.failureDetails?.reason).toBe("image-build-failed"); + expect(timeline.steps.at(-1)?.status).toBe("failed"); + }); + + test("the timeline route answers only for the fixture task", async () => { + const mocked = deployTaskDevMockResponse( + "timeline", + request("/api/deploy-tasks/x/timeline?namespace=ns-test", "succeeded"), + deployTaskDevMockTaskId("succeeded") + ); + expect(mocked?.status).toBe(200); + const body = (await mocked?.json()) as { task: { status: string } }; + expect(body.task.status).toBe("completed"); + expect( + deployTaskDevMockResponse( + "timeline", + request( + "/api/deploy-tasks/real/timeline?namespace=ns-test", + "succeeded" + ), + "real-task" + ) + ).toBeNull(); + }); + + test("the list answers with the fixture projection for the project", async () => { + const mocked = deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?namespace=ns-test&projectId=p1", "failed"), + null + ); + const body = (await mocked?.json()) as { projections: { id: string }[] }; + expect(body.projections.map((item) => item.id)).toEqual([ + "mock-task-failed", + ]); + }); + + test("the stream sends one snapshot frame and stays open", async () => { + const controller = new AbortController(); + const req = new Request( + "http://localhost/api/deploy-tasks/mock-task-running/timeline/stream?namespace=ns-test", + { signal: controller.signal } + ); + req.headers.set("cookie", `${deployTaskDevMockCookie.name}=running`); + const mocked = deployTaskDevMockResponse( + "timeline-stream", + req, + "mock-task-running" + ); + expect(mocked?.headers.get("content-type")).toContain("text/event-stream"); + const reader = mocked?.body?.getReader(); + const first = await reader?.read(); + const text = new TextDecoder().decode(first?.value); + expect(text.startsWith("event: snapshot\n")).toBe(true); + expect(JSON.parse(text.split("data: ")[1] ?? "{}").type).toBe("snapshot"); + controller.abort(); + const last = await reader?.read(); + expect(last?.done).toBe(true); + }); + + test("off and invalid behave like every Dev Mock", () => { + expect( + deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?projectId=p1", "off:running"), + null + ) + ).toBeNull(); + const invalid = deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?projectId=p1", "nope"), + null + ); + expect(invalid?.status).toBe(500); + }); +}); diff --git a/apps/ui/src/features/deploy/task/dev-fixtures.ts b/apps/ui/src/features/deploy/task/dev-fixtures.ts new file mode 100644 index 00000000..8f719e85 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-fixtures.ts @@ -0,0 +1,684 @@ +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { MINUTE_MS } from "@/lib/time"; + +import { + type DeployTaskDevScenario, + deployTaskDevMockCookie, + deployTaskDevMockTaskId, +} from "./dev-mock-cookie"; +import type { DeploymentTaskProjection } from "./projection"; +import type { DeployTaskStatus } from "./schema"; +import { + DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, + type DeploymentResultResourceCard, + type DeploymentTaskTimelineSnapshot, + type DeploymentTimelineEvent, + type DeploymentTimelineStep, + type DeploymentTimelineStepStatus, +} from "./timeline"; +import type { + DeploymentTaskTimelineSnapshotDTO, + DeploymentTaskTimelineStreamEvent, + DeployTaskDTO, + DeployTaskEventDTO, +} from "./types"; + +/** + * Deployment Task Timeline dev fixtures: one AI-runner task per scenario, + * deploying `acme/web-app` into the open Project. The timeline route and its + * SSE stream answer for the fixture task id; the task list and projection + * stream answer with the fixture alone so the Deployment Task Dock shows + * its chip. Timestamps are relative to the request so completion windows + * (canvas placeholder, dock notice) behave as they would live. Static: the + * stream sends the snapshot once and then only heartbeats. + */ + +const SECOND_MS = 1000; +const PROJECT_NAME = "web-app"; +const REPO = "acme/web-app"; + +interface FixtureClock { + at(offsetMs: number): string; + nowMs: number; +} + +function clock(nowMs: number): FixtureClock { + return { at: (offsetMs) => new Date(nowMs + offsetMs).toISOString(), nowMs }; +} + +function event( + time: FixtureClock, + offsetMs: number, + id: string, + message: string, + extra: Partial = {} +): DeploymentTimelineEvent { + return { + createdAt: time.at(offsetMs), + id, + message, + source: "runner", + ...extra, + }; +} + +function step( + id: string, + label: string, + order: number, + status: DeploymentTimelineStepStatus, + events: DeploymentTimelineEvent[], + resultCards?: DeploymentResultResourceCard[] +): DeploymentTimelineStep { + return { + events, + id, + label, + order, + ...(resultCards == null ? {} : { resultCards }), + status, + }; +} + +function resultCards( + time: FixtureClock, + namespace: string, + phase: "creating" | "ready" +): DeploymentResultResourceCard[] { + const ready = phase === "ready"; + return [ + { + events: [ + event( + time, + ready ? -40 * SECOND_MS : -20 * SECOND_MS, + "evt-ap", + ready + ? "AP workload has 1/1 ready replicas." + : "AP workload is starting; 0/1 replicas ready.", + { + source: "resource-observer", + ...(ready ? { severity: "success" } : {}), + } + ), + ], + id: `AP:${namespace}:${PROJECT_NAME}`, + latestStatusText: ready ? "1/1 replicas ready" : "0/1 replicas ready", + required: true, + resultRef: { kind: "AP", name: PROJECT_NAME, namespace }, + status: ready ? "running" : "creating", + title: PROJECT_NAME, + }, + { + events: [ + event(time, -50 * SECOND_MS, "evt-db", "Database cluster is ready.", { + severity: "success", + source: "resource-observer", + }), + ], + id: `DB:${namespace}:pg-main`, + latestStatusText: "Ready", + required: true, + resultRef: { kind: "DB", name: "pg-main", namespace }, + status: "running", + title: "pg-main", + }, + { + events: ready + ? [ + event( + time, + -30 * SECOND_MS, + "evt-pa", + "Public Address is accessible.", + { + severity: "success", + source: "health-check", + } + ), + ] + : [], + id: `PublicAccess:${namespace}:${PROJECT_NAME}:pa_web`, + ...(ready ? { latestStatusText: "accessible" } : {}), + required: false, + resultRef: { + apName: PROJECT_NAME, + id: "pa_web", + kind: "PublicAccess", + namespace, + }, + status: ready ? "running" : "pending", + title: "Public access", + }, + ]; +} + +interface ScenarioShape { + cancelRequestedAt: string | null; + completedAt: string | null; + error: string | null; + failureDetails: DeployTaskDTO["failureDetails"]; + phase: DeployTaskDTO["phase"]; + resultUrl: string | null; + status: DeployTaskStatus; + steps: DeploymentTimelineStep[]; +} + +function scenarioShape( + scenario: DeployTaskDevScenario, + time: FixtureClock, + namespace: string +): ScenarioShape { + const prepare = step( + "prepare-workspace", + "Prepare workspace", + 0, + "completed", + [event(time, -4 * MINUTE_MS, "evt-1", "Runtime workspace is ready.")] + ); + const analyze = step("analyze-source", "Analyze repository", 1, "completed", [ + event(time, -3.5 * MINUTE_MS, "evt-2", `Cloned ${REPO}@main.`), + event( + time, + -3 * MINUTE_MS, + "evt-3", + "Detected a Next.js app with a PostgreSQL dependency." + ), + ]); + const generate = step( + "generate-deployment", + "Generate deployment", + 2, + "completed", + [ + event( + time, + -2 * MINUTE_MS, + "evt-4", + "Planned 1 AP, 1 DB and 1 Public Address." + ), + ] + ); + switch (scenario) { + case "running": + return { + cancelRequestedAt: null, + completedAt: null, + error: null, + failureDetails: null, + phase: "apply", + resultUrl: null, + status: "applying", + steps: [ + prepare, + analyze, + generate, + step( + "create-resources", + "Create resources", + 3, + "running", + [ + event( + time, + -MINUTE_MS, + "evt-5", + "Applying deployment artifacts." + ), + ], + resultCards(time, namespace, "creating") + ), + ], + }; + case "blocked": + return { + cancelRequestedAt: null, + completedAt: null, + error: null, + failureDetails: null, + phase: "configure", + resultUrl: null, + status: "blocked", + steps: [ + prepare, + analyze, + step("generate-deployment", "Generate deployment", 2, "blocked", [ + event( + time, + -2 * MINUTE_MS, + "evt-4", + "Two values are needed before the deployment can continue.", + { severity: "warning" } + ), + ]), + step("create-resources", "Create resources", 3, "pending", []), + ], + }; + case "failed": + return { + cancelRequestedAt: null, + completedAt: time.at(-MINUTE_MS), + error: "Image build failed: `npm run build` exited with status 1.", + failureDetails: { + failureMessage: + "Image build failed: `npm run build` exited with status 1.", + reason: "image-build-failed", + stage: "apply", + }, + phase: "apply", + resultUrl: null, + status: "failed", + steps: [ + prepare, + analyze, + generate, + step("create-resources", "Create resources", 3, "failed", [ + event( + time, + -90 * SECOND_MS, + "evt-5", + "Building the container image." + ), + event( + time, + -MINUTE_MS, + "evt-6", + "Image build failed: `npm run build` exited with status 1.", + { + dedupeKey: DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, + reason: "image-build-failed", + severity: "error", + } + ), + ]), + ], + }; + case "succeeded": + return { + cancelRequestedAt: null, + completedAt: time.at(-30 * SECOND_MS), + error: null, + failureDetails: null, + phase: "completed", + resultUrl: "https://web-app.mock.sealos.run", + status: "completed", + steps: [ + prepare, + analyze, + generate, + step( + "create-resources", + "Create resources", + 3, + "completed", + [ + event( + time, + -MINUTE_MS, + "evt-5", + "Applying deployment artifacts." + ), + event( + time, + -30 * SECOND_MS, + "evt-6", + "All resources are ready.", + { + severity: "success", + } + ), + ], + resultCards(time, namespace, "ready") + ), + ], + }; + case "cancelled": + return { + cancelRequestedAt: time.at(-70 * SECOND_MS), + completedAt: time.at(-MINUTE_MS), + error: null, + failureDetails: { reason: "cancelled" }, + phase: "generate-artifacts", + resultUrl: null, + status: "cancelled", + steps: [ + prepare, + analyze, + step("generate-deployment", "Generate deployment", 2, "skipped", [ + event(time, -MINUTE_MS, "evt-4", "Cancelled by the user.", { + severity: "warning", + }), + ]), + step("create-resources", "Create resources", 3, "skipped", []), + ], + }; + default: + return scenario satisfies never; + } +} + +const RESOURCE_SLOTS = (namespace: string) => ({ + resources: [ + { + apiVersion: "apps/v1", + kind: "Deployment", + name: PROJECT_NAME, + namespace, + }, + { + apiVersion: "apps.kubeblocks.io/v1alpha1", + kind: "Cluster", + name: "pg-main", + namespace, + }, + ], + slots: [ + { + anchor: true, + expectedRef: { kind: "AP" as const, name: PROJECT_NAME, namespace }, + id: "slot-ap", + }, + { + expectedRef: { kind: "DB" as const, name: "pg-main", namespace }, + id: "slot-db", + }, + ], +}); + +export function deployTaskDevMockTask( + scenario: DeployTaskDevScenario, + input: { namespace: string; nowMs: number; projectId: string | null } +): DeploymentTaskTimelineSnapshotDTO { + const time = clock(input.nowMs); + const shape = scenarioShape(scenario, time, input.namespace); + const taskId = deployTaskDevMockTaskId(scenario); + const facts = RESOURCE_SLOTS(input.namespace); + const timeline: DeploymentTaskTimelineSnapshot = { + revision: shape.steps.length, + status: shape.status, + steps: shape.steps, + taskId, + updatedAt: time.at(-30 * SECOND_MS), + }; + const task: DeployTaskDTO = { + artifactSummary: { + resources: facts.resources, + }, + blockingInputs: + scenario === "blocked" + ? [ + { + id: "DATABASE_PASSWORD", + key: "DATABASE_PASSWORD", + label: "Database password", + required: true, + sensitive: true, + type: "secret", + }, + { + defaultValue: "3", + description: "Replicas for the web workload", + id: "REPLICAS", + key: "REPLICAS", + label: "Replicas", + options: ["1", "2", "3"], + required: true, + type: "text", + valueType: "choice", + }, + ] + : [], + cancelRequestedAt: shape.cancelRequestedAt, + canvasProjection: { + edges: [{ sourceSlotId: "slot-ap", targetSlotId: "slot-db" }], + slots: facts.slots, + ...(scenario === "succeeded" + ? { + resultMappings: facts.slots.map((slot) => ({ + actualRef: slot.expectedRef, + slotId: slot.id, + })), + } + : {}), + }, + completedAt: shape.completedAt, + createdAt: time.at(-5 * MINUTE_MS), + createdFrom: "ui", + error: shape.error, + failureDetails: shape.failureDetails, + gatewaySessionId: null, + gatewayStateSnapshot: null, + gatewayTurnId: null, + gatewayUrl: null, + id: taskId, + namespace: input.namespace, + phase: shape.phase, + previewUrl: null, + projectId: input.projectId, + projectName: PROJECT_NAME, + resultUrl: shape.resultUrl, + retriedFromTaskId: null, + runner: { kind: "ai", runtimeProvider: "devbox" }, + runtimeName: "devbox-web-app", + runtimeProvider: "devbox", + runtimeState: shape.completedAt == null ? "Running" : "Stopped", + source: { + branch: "main", + kind: "github", + repo: { + fullName: REPO, + name: PROJECT_NAME, + url: `https://github.com/${REPO}`, + }, + }, + startedAt: time.at(-4.5 * MINUTE_MS), + status: shape.status, + target: + input.projectId == null + ? { displayName: PROJECT_NAME, kind: "newProject" } + : { + kind: "existingProject", + projectId: input.projectId, + projectName: PROJECT_NAME, + }, + timelineSnapshot: timeline, + updatedAt: time.at(-30 * SECOND_MS), + }; + const events: DeployTaskEventDTO[] = shape.steps.flatMap((entry) => + entry.events.map((item, index) => ({ + createdAt: item.createdAt, + kind: `deployment_task.${entry.status}`, + message: item.message, + payload: item.reason == null ? {} : { reason: item.reason }, + phase: shape.phase, + seq: entry.order * 10 + index, + taskId, + })) + ); + return { events, task, timeline }; +} + +export function deployTaskDevMockProjection( + scenario: DeployTaskDevScenario, + input: { namespace: string; nowMs: number; projectId: string } +): DeploymentTaskProjection { + const { task } = deployTaskDevMockTask(scenario, input); + return { + artifactSummary: task.artifactSummary, + cancelRequestedAt: task.cancelRequestedAt ?? null, + canvasProjection: task.canvasProjection, + completedAt: task.completedAt, + display: { + resultSummary: PROJECT_NAME, + sourceKind: "github", + sourceSummary: `${REPO}@main`, + }, + id: task.id, + namespace: task.namespace, + phase: task.phase, + projectId: input.projectId, + ...(task.canvasProjection.resultMappings == null + ? {} + : { resultMappings: task.canvasProjection.resultMappings }), + retriedFromTaskId: null, + status: task.status, + updatedAt: task.updatedAt, + }; +} + +export type DeployTaskDevMockRoute = + | "list" + | "projections-stream" + | "timeline" + | "timeline-stream"; + +const HEARTBEAT_MS = 10_000; + +function encodeSse(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** One snapshot frame, then heartbeats until the client goes away. */ +function staticSseResponse( + request: Request, + event: string, + data: unknown +): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let timer: ReturnType | undefined; + const close = () => { + if (timer != null) { + clearInterval(timer); + timer = undefined; + } + try { + controller.close(); + } catch { + // Already closed by the client. + } + }; + if (request.signal.aborted) { + close(); + return; + } + request.signal.addEventListener("abort", close, { once: true }); + controller.enqueue(encoder.encode(encodeSse(event, data))); + timer = setInterval(() => { + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + close(); + } + }, HEARTBEAT_MS); + }, + }); + return new Response(stream, { + headers: { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no", + }, + }); +} + +function namespaceOf(request: Request): string { + return ( + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock" + ); +} + +/** + * Answers a deploy-task route from fixtures while the mock is on; null hands + * the request to the real handler. The timeline routes answer only for the + * fixture task so real tasks stay reachable; the list and projection + * stream answer with the fixture alone. + */ +export function deployTaskDevMockResponse( + route: DeployTaskDevMockRoute, + request: Request, + taskId: string | null +): Response | null { + const resolution = resolveDevMock( + deployTaskDevMockCookie, + request, + "deploy task" + ); + if (resolution.kind === "off") { + return null; + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const { scenario } = resolution; + const url = new URL(request.url); + const namespace = namespaceOf(request); + const nowMs = Date.now(); + switch (route) { + case "timeline": + case "timeline-stream": { + if (taskId !== deployTaskDevMockTaskId(scenario)) { + return null; + } + const snapshot = deployTaskDevMockTask(scenario, { + namespace, + nowMs, + projectId: null, + }); + if (route === "timeline") { + return Response.json(snapshot); + } + const frame: DeploymentTaskTimelineStreamEvent = { + snapshot, + type: "snapshot", + }; + return staticSseResponse(request, "snapshot", frame); + } + case "list": { + const projectId = url.searchParams.get("projectId")?.trim() || null; + if (url.searchParams.get("view") === "tasks") { + const { task } = deployTaskDevMockTask(scenario, { + namespace, + nowMs, + projectId, + }); + return Response.json({ nextCursor: null, tasks: [task] }); + } + if (projectId == null) { + return Response.json({ projections: [] }); + } + return Response.json({ + projections: [ + deployTaskDevMockProjection(scenario, { + namespace, + nowMs, + projectId, + }), + ], + }); + } + case "projections-stream": { + const projectId = url.searchParams.get("projectId")?.trim(); + if (!projectId) { + return Response.json( + { error: "Project ID is required." }, + { status: 400 } + ); + } + return staticSseResponse(request, "snapshot", { + projections: [ + deployTaskDevMockProjection(scenario, { + namespace, + nowMs, + projectId, + }), + ], + type: "snapshot", + }); + } + default: + return route satisfies never; + } +} diff --git a/apps/ui/src/features/deploy/task/dev-mock-cookie.ts b/apps/ui/src/features/deploy/task/dev-mock-cookie.ts new file mode 100644 index 00000000..a0aa2fa0 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock-cookie.ts @@ -0,0 +1,34 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Deployment Task Timeline Dev Mock's cookie: one fixture Deployment + * Task per scenario, served as a static timeline snapshot (no event + * replay) plus the canvas projection that lets its chip appear. + */ + +export const DEPLOY_TASK_DEV_SCENARIOS = [ + "running", + "blocked", + "failed", + "succeeded", + "cancelled", +] as const; + +export type DeployTaskDevScenario = (typeof DEPLOY_TASK_DEV_SCENARIOS)[number]; + +export const DEFAULT_DEPLOY_TASK_DEV_SCENARIO: DeployTaskDevScenario = + "running"; + +export const deployTaskDevMockCookie = + defineDevMockCookie({ + defaultScenario: DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + name: "sealai-deploy-task-dev-mock", + scenarios: DEPLOY_TASK_DEV_SCENARIOS, + }); + +/** The fixture task's id; the timeline route answers from fixtures only for it. */ +export function deployTaskDevMockTaskId( + scenario: DeployTaskDevScenario +): string { + return `mock-task-${scenario}`; +} diff --git a/apps/ui/src/features/deploy/task/dev-mock-gate.tsx b/apps/ui/src/features/deploy/task/dev-mock-gate.tsx new file mode 100644 index 00000000..41b87f3c --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock-gate.tsx @@ -0,0 +1,22 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Same inlined gate as dev-tweaks.tsx: a real production build statically +// drops the dynamic import and tree-shakes the mock module away; +// `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. +const DeployTaskDevMockTweaks = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" + ? dynamic(() => + import("./dev-mock").then((mod) => mod.DeployTaskDevMockTweaks) + ) + : null; + +/** Mounts the deployment task mock's dev-tweaks registration on /project. */ +export function DeployTaskDevMockGate() { + if (!DeployTaskDevMockTweaks) { + return null; + } + return ; +} diff --git a/apps/ui/src/features/deploy/task/dev-mock-route.ts b/apps/ui/src/features/deploy/task/dev-mock-route.ts new file mode 100644 index 00000000..c037bc91 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock-route.ts @@ -0,0 +1,41 @@ +import "server-only"; + +import type { DeployTaskDevMockRoute } from "./dev-fixtures"; + +type RouteContextArgs = C extends undefined + ? [context?: undefined] + : [context: C]; + +type RouteHandler = ( + request: Request, + ...args: RouteContextArgs +) => Promise; + +/** + * Lets the Deployment Task Timeline Dev Mock answer first in dev and demo + * builds (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image); a real production + * build statically drops the dynamic import, so fixtures never reach + * production bundles — the same gate as the /api/billing routes. + */ +export function withDeployTaskDevMock( + route: DeployTaskDevMockRoute, + handler: RouteHandler, + taskIdOf: (...args: RouteContextArgs) => Promise = () => + Promise.resolve(null) +): RouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return handler; + } + return async (request, ...args) => { + const { deployTaskDevMockResponse } = await import("./dev-fixtures"); + const mocked = deployTaskDevMockResponse( + route, + request, + await taskIdOf(...args) + ); + return mocked ?? handler(request, ...args); + }; +} diff --git a/apps/ui/src/features/deploy/task/dev-mock.tsx b/apps/ui/src/features/deploy/task/dev-mock.tsx new file mode 100644 index 00000000..401449cb --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + DEPLOY_TASK_DEV_SCENARIOS, + deployTaskDevMockCookie, +} from "./dev-mock-cookie"; + +export const DEPLOY_TASK_DEV_MOCK_KEY = "deploy-task-mock"; + +// The projection store and the timeline pane hold SSE connections keyed by +// credentials, not SWR keys; a reload is the one honest way to reconnect +// them to (or from) the fixtures. +const deployTaskDevMockSource = createDevMockCookieSource( + deployTaskDevMockCookie, + { revalidate: () => window.location.reload() } +); + +/** Registers the mock while a Project Canvas is mounted; renders nothing. */ +export function DeployTaskDevMockTweaks() { + useDevTweaksMock(DEPLOY_TASK_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + note: "Serves one Deployment Task and its timeline from fixtures (static snapshot); toggling reloads the page", + scenarios: DEPLOY_TASK_DEV_SCENARIOS, + source: deployTaskDevMockSource, + title: "Deployment task mock", + }); + return null; +} From dff02f0cf22035ac35e723b3d64e8d467618a253 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Fri, 28 Aug 2026 11:06:38 +0800 Subject: [PATCH 14/14] feat(chat): Conversation Dev Mock for the assistant transcript (AIM-337) Cookie sealai-chat-dev-mock serves the session bootstrap, thread list and thread messages from three fixture threads (long with tool cards in every state and a pinned chat context, short, empty). Sending still goes to the real POST /api/chat, and the session keeps the real handler's Chat Billing Mode when it answers so the billing mock keeps driving it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Nid6CzLVaZwacAdF5EhY9 --- apps/ui/src/app/project/layout.tsx | 2 + .../ui/src/features/chat/dev-fixtures.test.ts | 125 ++++++ apps/ui/src/features/chat/dev-fixtures.ts | 362 ++++++++++++++++++ apps/ui/src/features/chat/dev-mock-cookie.ts | 20 + apps/ui/src/features/chat/dev-mock-gate.tsx | 20 + apps/ui/src/features/chat/dev-mock.tsx | 32 ++ .../runtime/conversation-route-handlers.ts | 62 ++- 7 files changed, 608 insertions(+), 15 deletions(-) create mode 100644 apps/ui/src/features/chat/dev-fixtures.test.ts create mode 100644 apps/ui/src/features/chat/dev-fixtures.ts create mode 100644 apps/ui/src/features/chat/dev-mock-cookie.ts create mode 100644 apps/ui/src/features/chat/dev-mock-gate.tsx create mode 100644 apps/ui/src/features/chat/dev-mock.tsx diff --git a/apps/ui/src/app/project/layout.tsx b/apps/ui/src/app/project/layout.tsx index 27c8b0dd..014f6ec1 100644 --- a/apps/ui/src/app/project/layout.tsx +++ b/apps/ui/src/app/project/layout.tsx @@ -1,3 +1,4 @@ +import { ChatDevMockGate } from "@/features/chat/dev-mock-gate"; import { DeployTaskDevMockGate } from "@/features/deploy/task/dev-mock-gate"; import { OnboardingGate } from "@/features/onboarding/onboarding-gate"; import { ProjectsExplorerDevMockGate } from "@/features/projects/explorer/projects-dev-mock-gate"; @@ -30,6 +31,7 @@ export default function ProjectLayout({ + diff --git a/apps/ui/src/features/chat/dev-fixtures.test.ts b/apps/ui/src/features/chat/dev-fixtures.test.ts new file mode 100644 index 00000000..d4b4c24a --- /dev/null +++ b/apps/ui/src/features/chat/dev-fixtures.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { chatDevMockResponse } from "./dev-fixtures"; +import { CHAT_DEV_SCENARIOS, chatDevMockCookie } from "./dev-mock-cookie"; +import { readSelectedResourceContext } from "./persistence/types"; + +function request(path: string, scenario: string): Request { + const req = new Request(`http://localhost${path}`); + req.headers.set("cookie", `${chatDevMockCookie.name}=${scenario}`); + return req; +} + +const realSession = () => + Promise.resolve( + Response.json({ + chatId: "real", + freeTier: { billing: "blocked", limit: 10, remaining: 0 }, + messages: [], + threads: [], + }) + ); +const realDown = () => + Promise.resolve(Response.json({ error: "down" }, { status: 503 })); + +interface SessionBody { + chatId: string; + freeTier: { billing: string }; + messages: { id: string; parts: unknown[]; role: string }[]; + threads: { id: string }[]; +} + +describe("conversation dev fixtures", () => { + for (const scenario of CHAT_DEV_SCENARIOS) { + test(`${scenario} bootstraps a session whose thread is in the list`, async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", scenario), + realSession + ); + const body = (await response.json()) as SessionBody; + expect(body.chatId).toBe(`mock-chat-${scenario}`); + if (scenario !== "empty") { + expect(body.threads.map((thread) => thread.id)).toContain(body.chatId); + } + const messages = await chatDevMockResponse( + "messages", + request( + `/api/chat/messages?chatId=${body.chatId}&namespace=ns-test`, + scenario + ), + realDown + ); + const list = (await messages.json()) as Pick; + expect(list.messages).toEqual(body.messages); + }); + } + + test("keeps the real Chat Billing Mode when the real handler answers", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "short"), + realSession + ); + const body = (await response.json()) as SessionBody; + expect(body.freeTier.billing).toBe("blocked"); + expect(body.messages).toHaveLength(4); + }); + + test("falls back to a neutral posture when the real handler cannot", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "long"), + realDown + ); + const body = (await response.json()) as SessionBody; + expect(body.freeTier.billing).toBe("user"); + }); + + test("long carries tool cards in every state and a pinned context", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "long"), + realDown + ); + const body = (await response.json()) as SessionBody; + const states = new Set( + body.messages.flatMap((message) => + message.parts.flatMap((part) => { + const candidate = part as { state?: string; type: string }; + return candidate.type.startsWith("tool-") && candidate.state != null + ? [candidate.state] + : []; + }) + ) + ); + expect([...states].sort()).toEqual( + ["approval-requested", "output-available", "output-error"].sort() + ); + const pinned = body.messages + .map((message) => + readSelectedResourceContext( + message as Parameters[0] + ) + ) + .find((context) => context != null); + expect(pinned?.name).toBe("web-app"); + }); + + test("an unknown thread and an off cookie fall through", async () => { + const unknown = await chatDevMockResponse( + "messages", + request( + "/api/chat/messages?chatId=real-thread&namespace=ns-test", + "long" + ), + realDown + ); + expect(unknown.status).toBe(503); + const off = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "off:long"), + realSession + ); + expect(((await off.json()) as SessionBody).chatId).toBe("real"); + }); +}); diff --git a/apps/ui/src/features/chat/dev-fixtures.ts b/apps/ui/src/features/chat/dev-fixtures.ts new file mode 100644 index 00000000..c02c76ea --- /dev/null +++ b/apps/ui/src/features/chat/dev-fixtures.ts @@ -0,0 +1,362 @@ +import type { UIMessage } from "ai"; + +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { HOUR_MS, MINUTE_MS } from "@/lib/time"; + +import { type ChatDevScenario, chatDevMockCookie } from "./dev-mock-cookie"; +import type { + AssistantSessionPayload, + AssistantThreadDTO, + FreeTierState, +} from "./persistence/types"; +import { SELECTED_RESOURCE_CONTEXT_PART_TYPE } from "./persistence/types"; + +/** + * Conversation dev fixtures: three threads' worth of transcript so the chat + * pane can be designed against a real-looking history — tool cards in every + * state, a pinned chat context, code blocks, a long scroll. The session and + * message routes answer from here; the layer below (the real handler) + * still decides the Chat Billing Mode when it can answer, so the billing + * mock keeps driving it. Nothing here is ever written to Postgres. + */ + +/** Anchored at module load so thread timestamps stay stable across reloads. */ +const FIXTURE_NOW_MS = Date.now(); + +const THREAD_IDS = { + empty: "mock-chat-empty", + long: "mock-chat-long", + short: "mock-chat-short", +} as const; + +function at(agoMs: number): string { + return new Date(FIXTURE_NOW_MS - agoMs).toISOString(); +} + +function user( + id: string, + text: string, + extraParts: UIMessage["parts"] = [] +): UIMessage { + return { id, parts: [...extraParts, { text, type: "text" }], role: "user" }; +} + +function assistant(id: string, parts: UIMessage["parts"]): UIMessage { + return { id, parts, role: "assistant" }; +} + +const SHORT_TRANSCRIPT: UIMessage[] = [ + user("m1", "How do I expose my app on a custom domain?"), + assistant("m2", [ + { type: "step-start" }, + { + text: "You can attach a custom domain in three steps:\n\n1. Add the domain under **Networking → Domains**.\n2. Point a `CNAME` record at the generated target.\n3. Wait for the TLS certificate to be issued.\n\nOnce the certificate is **Ready**, traffic resolves automatically.", + type: "text", + }, + ]), + user("m3", "What if the certificate stays pending?"), + assistant("m4", [ + { type: "step-start" }, + { + text: "A pending certificate almost always means the `CNAME` has not propagated yet. Verify it with:\n\n```bash\ndig +short CNAME app.example.com\n```\n\nIf the target does not match, fix the DNS record and re-check in a few minutes.", + type: "text", + }, + ]), +]; + +const LONG_TRANSCRIPT: UIMessage[] = [ + user( + "l1", + "Deploy acme/web-app from GitHub into this project. It needs a Postgres database." + ), + assistant("l2", [ + { type: "step-start" }, + { + text: "I'll look at the repository first, then plan the resources.", + type: "text", + }, + { + input: { kind: "AP", name: "web-app", namespace: "ns-mock" }, + output: { found: false }, + state: "output-available", + toolCallId: "call-read-1", + type: "tool-readProductResource", + }, + { type: "step-start" }, + { + text: "There's no `web-app` workload yet, so this will be a fresh deployment. The repo is a Next.js app with a `DATABASE_URL` dependency. Here's the plan:\n\n| Resource | Kind | Notes |\n|---|---|---|\n| `web-app` | AP | Node 20, 1 replica, port 3000 |\n| `pg-main` | DB | PostgreSQL 16, 1 Gi |\n| `web-app` | Public Access | HTTPS on the platform domain |\n\nShall I create the database first?", + type: "text", + }, + ]), + user("l3", "Yes, go ahead."), + assistant("l4", [ + { type: "step-start" }, + { + approval: { approved: true, id: "approval-db-1" }, + input: { + intention: "create the PostgreSQL cluster web-app depends on", + kind: "DB", + name: "pg-main", + operation: "create", + spec: { storageGi: 1, type: "postgresql", version: "16" }, + }, + output: { name: "pg-main", status: "Creating" }, + state: "output-available", + toolCallId: "call-write-db", + type: "tool-writeProductResource", + }, + { type: "step-start" }, + { + text: "`pg-main` is being created. I'll start a deployment task for the app while it comes up.", + type: "text", + }, + { + input: { + branch: "main", + repo: "acme/web-app", + target: "existingProject", + }, + output: { status: "running", taskId: "mock-task-running" }, + state: "output-available", + toolCallId: "call-deploy-1", + type: "tool-startDeploymentTask", + }, + { type: "step-start" }, + { + text: "The deployment task is running — you can follow it in the timeline. I'll check back once the image is built.", + type: "text", + }, + ]), + user("l5", "The build failed. What went wrong?", [ + { + data: { kind: "AP", name: "web-app", namespace: "ns-mock" }, + type: SELECTED_RESOURCE_CONTEXT_PART_TYPE, + }, + ]), + assistant("l6", [ + { type: "step-start" }, + { + input: { taskId: "mock-task-running" }, + output: { + error: "Image build failed: `npm run build` exited with status 1.", + reason: "image-build-failed", + status: "failed", + }, + state: "output-available", + toolCallId: "call-task-1", + type: "tool-readDeploymentTask", + }, + { + input: { container: "build", lines: 40, name: "web-app" }, + errorText: "Build logs are no longer available for this task.", + state: "output-error", + toolCallId: "call-logs-1", + type: "tool-readWorkloadLogs", + }, + { type: "step-start" }, + { + text: "The build step failed with `npm run build` exiting 1, and the build logs have already been rotated. Two common causes for this repo shape:\n\n- a **TypeScript error** that `next build` treats as fatal;\n- a missing `NEXT_PUBLIC_*` variable the build reads at compile time.\n\nI can redeploy with `CI=false` to surface the exact error, or you can run `npm run build` locally first. Which do you prefer?", + type: "text", + }, + ]), + user( + "l7", + "Redeploy, and set NEXT_PUBLIC_API_URL to https://api.example.com." + ), + assistant("l8", [ + { type: "step-start" }, + { + approval: { approved: true, id: "approval-env-1" }, + input: { + intention: "set the public API URL the build reads", + kind: "AP", + name: "web-app", + operation: "patch", + patch: { env: { NEXT_PUBLIC_API_URL: "https://api.example.com" } }, + }, + output: { applied: true }, + state: "output-available", + toolCallId: "call-write-env", + type: "tool-writeProductResource", + }, + { + input: { retriedFromTaskId: "mock-task-running" }, + output: { status: "running", taskId: "mock-task-succeeded" }, + state: "output-available", + toolCallId: "call-deploy-2", + type: "tool-startDeploymentTask", + }, + { type: "step-start" }, + { + text: "Environment updated and the redeploy is running. This time the build has the variable it needs.", + type: "text", + }, + ]), + user("l9", "Is it up?"), + assistant("l10", [ + { type: "step-start" }, + { + input: { taskId: "mock-task-succeeded" }, + output: { + resultUrl: "https://web-app.mock.sealos.run", + status: "completed", + }, + state: "output-available", + toolCallId: "call-task-2", + type: "tool-readDeploymentTask", + }, + { + input: { namespace: "ns-mock" }, + output: { scheduled: true }, + state: "output-available", + toolCallId: "call-refresh-1", + type: "tool-refreshFrontendSwrCaches", + }, + { type: "step-start" }, + { + text: "Yes — `web-app` is live at https://web-app.mock.sealos.run with 1/1 replicas ready, connected to `pg-main`. The canvas has been refreshed to show both resources.", + type: "text", + }, + ]), + user("l11", "Scale it to 3 replicas and show me the resulting spec."), + assistant("l12", [ + { type: "step-start" }, + { + approval: { id: "approval-scale-1" }, + input: { + intention: "scale web-app after showing the patch", + kind: "AP", + name: "web-app", + operation: "patch", + patch: { spec: { replicas: 3 } }, + }, + state: "approval-requested", + toolCallId: "call-scale-1", + type: "tool-writeProductResource", + }, + { type: "step-start" }, + { + text: "Here is the patch I'd apply:\n\n```yaml\nspec:\n replicas: 3\n```\n\nApprove it and I'll scale `web-app` right away.", + type: "text", + }, + ]), +]; + +const TRANSCRIPTS: Record = { + [THREAD_IDS.empty]: [], + [THREAD_IDS.long]: LONG_TRANSCRIPT, + [THREAD_IDS.short]: SHORT_TRANSCRIPT, +}; + +function threads(namespace: string): AssistantThreadDTO[] { + return [ + { + id: THREAD_IDS.long, + namespace, + title: "Deploy acme/web-app from GitHub", + updatedAt: at(12 * MINUTE_MS), + }, + { + id: THREAD_IDS.short, + namespace, + title: "Custom domain setup", + updatedAt: at(3 * HOUR_MS), + }, + { + id: THREAD_IDS.empty, + namespace, + title: "New chat", + updatedAt: at(26 * HOUR_MS), + }, + ]; +} + +/** Every scenario shows the same three threads; the scenario picks which one is open. */ +function scenarioSession( + scenario: ChatDevScenario, + namespace: string, + freeTier: FreeTierState +): AssistantSessionPayload { + const chatId = THREAD_IDS[scenario]; + return { + chatId, + freeTier, + messages: TRANSCRIPTS[chatId] ?? [], + threads: scenario === "empty" ? [] : threads(namespace), + }; +} + +/** No allowance to speak of; the real handler's posture replaces it when it answers. */ +const NEUTRAL_FREE_TIER: FreeTierState = { + billing: "user", + limit: 0, + remaining: 0, +}; + +export type ChatDevMockHandler = "messages" | "session" | "threads"; + +export type ChatDevMockNext = (request: Request) => Promise; + +function namespaceOf(request: Request): string { + return ( + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock" + ); +} + +async function freeTierBelow( + request: Request, + next: ChatDevMockNext +): Promise { + const below = await next(request).catch(() => null); + if (below == null || !below.ok) { + return NEUTRAL_FREE_TIER; + } + const payload: unknown = await below.json().catch(() => null); + const freeTier = + typeof payload === "object" && payload != null + ? (payload as { freeTier?: unknown }).freeTier + : null; + return typeof freeTier === "object" && freeTier != null + ? (freeTier as FreeTierState) + : NEUTRAL_FREE_TIER; +} + +/** + * Answers a conversation persistence route from fixtures while the mock is + * on; `next` is the real handler. The session keeps the real handler's + * Chat Billing Mode when it can answer (so the billing mock still drives + * it) and falls back to a neutral posture otherwise. Off hands the request + * down untouched. + */ +export async function chatDevMockResponse( + handler: ChatDevMockHandler, + request: Request, + next: ChatDevMockNext +): Promise { + const resolution = resolveDevMock(chatDevMockCookie, request, "chat"); + if (resolution.kind === "off") { + return next(request); + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const { scenario } = resolution; + const namespace = namespaceOf(request); + switch (handler) { + case "session": + return Response.json( + scenarioSession(scenario, namespace, await freeTierBelow(request, next)) + ); + case "threads": + return Response.json({ + threads: scenario === "empty" ? [] : threads(namespace), + }); + case "messages": { + const chatId = new URL(request.url).searchParams.get("chatId") ?? ""; + const messages = TRANSCRIPTS[chatId]; + return messages == null ? next(request) : Response.json({ messages }); + } + default: + return handler satisfies never; + } +} diff --git a/apps/ui/src/features/chat/dev-mock-cookie.ts b/apps/ui/src/features/chat/dev-mock-cookie.ts new file mode 100644 index 00000000..f5d09736 --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock-cookie.ts @@ -0,0 +1,20 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Conversation Dev Mock's cookie: the assistant transcript (session + * bootstrap, thread list, thread messages) served from fixtures. Sending a + * message still goes to the real `POST /api/chat`; Chat Billing Mode still + * follows the billing mock. + */ + +export const CHAT_DEV_SCENARIOS = ["empty", "short", "long"] as const; + +export type ChatDevScenario = (typeof CHAT_DEV_SCENARIOS)[number]; + +export const DEFAULT_CHAT_DEV_SCENARIO: ChatDevScenario = "long"; + +export const chatDevMockCookie = defineDevMockCookie({ + defaultScenario: DEFAULT_CHAT_DEV_SCENARIO, + name: "sealai-chat-dev-mock", + scenarios: CHAT_DEV_SCENARIOS, +}); diff --git a/apps/ui/src/features/chat/dev-mock-gate.tsx b/apps/ui/src/features/chat/dev-mock-gate.tsx new file mode 100644 index 00000000..92aaefee --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock-gate.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Same inlined gate as dev-tweaks.tsx: a real production build statically +// drops the dynamic import and tree-shakes the mock module away; +// `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. +const ChatDevMockTweaks = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" + ? dynamic(() => import("./dev-mock").then((mod) => mod.ChatDevMockTweaks)) + : null; + +/** Mounts the chat mock's dev-tweaks registration on /project. */ +export function ChatDevMockGate() { + if (!ChatDevMockTweaks) { + return null; + } + return ; +} diff --git a/apps/ui/src/features/chat/dev-mock.tsx b/apps/ui/src/features/chat/dev-mock.tsx new file mode 100644 index 00000000..3ca666d7 --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + CHAT_DEV_SCENARIOS, + chatDevMockCookie, + DEFAULT_CHAT_DEV_SCENARIO, +} from "./dev-mock-cookie"; + +export const CHAT_DEV_MOCK_KEY = "chat-mock"; + +// The chat pane bootstraps its session once per credential set, outside +// SWR; a reload is the one honest way to re-bootstrap it from (or off) the +// fixtures. +const chatDevMockSource = createDevMockCookieSource(chatDevMockCookie, { + revalidate: () => window.location.reload(), +}); + +/** Registers the mock while a Project Canvas is mounted; renders nothing. */ +export function ChatDevMockTweaks() { + useDevTweaksMock(CHAT_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_CHAT_DEV_SCENARIO, + note: "Serves the assistant transcript from fixtures; sending still hits the model; toggling reloads the page", + scenarios: CHAT_DEV_SCENARIOS, + source: chatDevMockSource, + title: "Chat mock", + }); + return null; +} diff --git a/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts b/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts index 1be3957e..f3ce3af7 100644 --- a/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts +++ b/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts @@ -1,5 +1,6 @@ import "server-only"; +import type { ChatDevMockHandler } from "../dev-fixtures"; import { getFreeTierSnapshot, resolveFreeTierPosture, @@ -12,18 +13,49 @@ import { } from "../persistence/service"; import { createAssistantConversationHandlers } from "./conversation-handlers"; -export const assistantConversationRouteHandlers = - createAssistantConversationHandlers({ - adoptLegacyConversations: adoptLegacyAssistantConversationsForActor, - bootstrap: bootstrapAssistantSession, - freeTurnsUsage: getFreeTierSnapshot, - list: listThreadsForOwner, - read: (owner, chatId) => loadMessagesForOwner(chatId, owner), - resolveFreeTier: ({ actor, cookieHeader }) => - resolveFreeTierPosture({ - accountUserId: actor.accountUserId ?? null, - cookieHeader, - namespace: actor.owner.namespace, - userUid: actor.owner.userUid, - }), - }); +type ConversationRouteHandler = (request: Request) => Promise; + +/** + * Lets the Conversation Dev Mock answer the persistence routes first in dev + * and demo builds (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image); a real + * production build statically drops the dynamic import, so fixtures never + * reach production bundles — the same gate as the /api/billing routes. + */ +function withChatDevMock( + handler: ChatDevMockHandler, + real: ConversationRouteHandler +): ConversationRouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return real; + } + return async (request) => { + const { chatDevMockResponse } = await import("../dev-fixtures"); + return chatDevMockResponse(handler, request, real); + }; +} + +const handlers = createAssistantConversationHandlers({ + adoptLegacyConversations: adoptLegacyAssistantConversationsForActor, + bootstrap: bootstrapAssistantSession, + freeTurnsUsage: getFreeTierSnapshot, + list: listThreadsForOwner, + read: (owner, chatId) => loadMessagesForOwner(chatId, owner), + resolveFreeTier: ({ actor, cookieHeader }) => + resolveFreeTierPosture({ + accountUserId: actor.accountUserId ?? null, + cookieHeader, + namespace: actor.owner.namespace, + userUid: actor.owner.userUid, + }), +}); + +/** Production wiring for the conversation routes, dev mock first. */ +export const assistantConversationRouteHandlers = { + ...handlers, + messagesGet: withChatDevMock("messages", handlers.messagesGet), + session: withChatDevMock("session", handlers.session), + threads: withChatDevMock("threads", handlers.threads), +};