From ae477306bae5bbdd3a1776f4cc3cd4c280e18f4b Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 8 Jun 2026 14:43:06 -0400 Subject: [PATCH 01/20] feat: mirror control-plane product surfaces in status bar Replace the waste/quality/compliance/sync tabs with product-surface tabs that mirror the webapp navigation, grouped Control Plane (Policies, Issues, Checks) and Data Plane (Services, Log events, Edge instances). - add internal/app/statusbar/surfaces with a shared non-interactive Model - keep syncStatus wired into the statusbar lifecycle though it is no longer a drawer tab (sync dot + sync-error toasts) - remove the superseded waste/quality/compliance/policytab packages - update docs/domains/statusbar.md --- docs/domains/statusbar.md | 39 +- .../app/statusbar/compliance/compliance.go | 444 --------------- .../statusbar/compliance/compliance_test.go | 24 - internal/app/statusbar/compliance/detail.go | 307 ---------- internal/app/statusbar/policytab/base.go | 86 --- internal/app/statusbar/policytab/base_test.go | 90 --- internal/app/statusbar/quality/detail.go | 124 ----- internal/app/statusbar/quality/quality.go | 441 --------------- .../app/statusbar/quality/quality_test.go | 24 - internal/app/statusbar/statusbar.go | 59 +- internal/app/statusbar/statusbar_data.go | 10 +- internal/app/statusbar/statusbar_test.go | 45 ++ internal/app/statusbar/statusbar_view.go | 50 +- internal/app/statusbar/surfaces/surfaces.go | 437 +++++++++++++++ .../app/statusbar/surfaces/surfaces_test.go | 98 ++++ internal/app/statusbar/tabs.go | 22 +- internal/app/statusbar/waste/detail.go | 138 ----- internal/app/statusbar/waste/waste.go | 527 ------------------ internal/app/statusbar/waste/waste_test.go | 24 - 19 files changed, 684 insertions(+), 2305 deletions(-) delete mode 100644 internal/app/statusbar/compliance/compliance.go delete mode 100644 internal/app/statusbar/compliance/compliance_test.go delete mode 100644 internal/app/statusbar/compliance/detail.go delete mode 100644 internal/app/statusbar/policytab/base.go delete mode 100644 internal/app/statusbar/policytab/base_test.go delete mode 100644 internal/app/statusbar/quality/detail.go delete mode 100644 internal/app/statusbar/quality/quality.go delete mode 100644 internal/app/statusbar/quality/quality_test.go create mode 100644 internal/app/statusbar/surfaces/surfaces.go create mode 100644 internal/app/statusbar/surfaces/surfaces_test.go delete mode 100644 internal/app/statusbar/waste/detail.go delete mode 100644 internal/app/statusbar/waste/waste.go delete mode 100644 internal/app/statusbar/waste/waste_test.go diff --git a/docs/domains/statusbar.md b/docs/domains/statusbar.md index be060f06..01a221e3 100644 --- a/docs/domains/statusbar.md +++ b/docs/domains/statusbar.md @@ -12,8 +12,11 @@ confusing stale state. ## What the status bar owns -The status bar owns presentation state and interaction for five tabs: -waste, quality, compliance, services, and sync. +The status bar owns presentation state and interaction for product-surface tabs +that mirror the webapp navigation: + +- Control Plane: policies, issues, checks. +- Data Plane: services, log events, edge instances. It does not own business truth. Facts still come from synced SQLite state and sync runtime signals. @@ -32,9 +35,10 @@ Think of the status bar as a shell plus tab plugins. interaction. - Shared poll lifecycle (`tabpoll/`): typed `PollMsg` -> async fetch -> typed `DataMsg` cycle. -- Shared policy-tab behavior (`policytab/`): - reusable base for waste/quality/compliance polling, change detection, and - list/detail cursor lifecycle. +- Product surfaces (`surfaces/`): + non-interactive summary tabs (policies, issues, checks, log events, edge + instances) that poll a snapshot, gate on "has data", and render compact + + drawer views. - Shared list/detail mechanics (`listdetail/`): keyboard navigation and detail-view enter/exit semantics. @@ -50,14 +54,15 @@ into tab packages. `internal/domain` ownership. 4. Drawer interactions are tab-owned; the shell routes keys but does not micromanage tab internals. -5. Shared polling/list-detail behavior belongs in `tabpoll`, `policytab`, and - `listdetail`, not duplicated per tab. +5. Shared polling/list-detail behavior belongs in `tabpoll` and `listdetail`, + not duplicated per tab. ## Data and ownership boundaries Status bar tabs read from local runtime state: -- policy/service counts from SQLite query surfaces, +- policy, issue, check, service, and log-event counts from SQLite query + surfaces, - sync health from the syncer integration model. They should not call remote APIs directly from tab update/render paths. @@ -65,20 +70,20 @@ Onboarding handles API-first bootstrap; status bar is runtime projection UI. ## Why the current split exists -Waste, quality, and compliance look similar because they solve the same shape of -problem: poll summary + categories, render compact signal, then offer a -list/detail drawer for category inspection. +Some product surfaces look similar because they solve the same shape of problem: +poll summary state, render compact signals where useful, then offer a drawer +summary for inspection. -The shared `policytab.Base` exists to remove boilerplate that was previously +The shared `surfaces.Model` exists to remove boilerplate that was previously easy to drift: - poll lifecycle bookkeeping, - "has data" gating, -- cursor clamp and state-change checks, -- standard list/detail navigation wiring. +- snapshot change detection, +- standard compact/drawer rendering. -The remaining logic in each tab should be domain-specific rendering and query -selection only. +The remaining logic in each surface should be domain-specific query selection +and snapshot shaping only (the per-surface `fetch...` functions). ## Naming contract @@ -89,7 +94,7 @@ To keep tabs consistent, use these names: - rendering helpers: `render...` for tab-local view composition. When two tabs need the same lifecycle behavior, move it to `tabpoll`, -`policytab`, or `viewkit` instead of introducing one-off names in each tab. +`surfaces`, or `viewkit` instead of introducing one-off names in each tab. ## Practical change checklist diff --git a/internal/app/statusbar/compliance/compliance.go b/internal/app/statusbar/compliance/compliance.go deleted file mode 100644 index 8d84ad7a..00000000 --- a/internal/app/statusbar/compliance/compliance.go +++ /dev/null @@ -1,444 +0,0 @@ -// Package compliance renders the compliance indicator in the status bar. -package compliance - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "compliance" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// complianceDetailLoadedMsg carries the result of an async detail fetch. -type complianceDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.CompliancePolicy - err error -} - -// Model renders compliance status: 4 categories (PII, Secrets, PHI, Payment Data). -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new compliance status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("compliance"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case complianceDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - } - - return nil -} - -// fetchData returns a Cmd that queries compliance data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - - categories, err := db.LogEventPolicyCategoryStatuses().ListComplianceCategoryStatuses(ctx) - if err != nil { - scope.Error("list compliance category statuses", "err", err) - categories = nil - } - - // Merge observed (leaking) counts into category statuses. - observed, err := db.LogEventPolicyCategoryStatuses().CountObservedByComplianceCategory(ctx) - if err != nil { - scope.Error("count observed by compliance category", "err", err) - } else { - for i := range categories { - categories[i].ObservedCount = observed[categories[i].Category] - } - } - - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.CompliancePolicy, error) { - policies, err := db.CompliancePolicies().ListPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list pending policies by category", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.CompliancePolicy, err error) tea.Msg { - if err != nil { - return complianceDetailLoadedMsg{err: err} - } - return complianceDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(summary domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := complianceStateKey{ - Summary: complianceSummaryKey{ - EventCount: summary.EventCount, - AnalyzedCount: summary.AnalyzedCount, - }, - Categories: make([]complianceCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, complianceCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - ObservedCount: c.ObservedCount, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type complianceStateKey struct { - Summary complianceSummaryKey `json:"summary"` - Categories []complianceCategoryKey `json:"categories"` -} - -type complianceSummaryKey struct { - EventCount int64 `json:"event_count"` - AnalyzedCount int64 `json:"analyzed_count"` -} - -type complianceCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - ObservedCount int64 `json:"observed_count"` -} - -// HasData returns true when compliance policy data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the compliance indicator for the collapsed statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var segments []string - - leaking := totalObserved(m.categories) - pending := totalPending(m.categories) - atRisk := pending - leaking - approved := totalApproved(m.categories) - - if leaking > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d leaking", leaking))) - } - - if atRisk > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d at risk", atRisk))) - } - - if approved > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - segments = append(segments, ok.Render(fmt.Sprintf("%d fixed", approved))) - } - - if len(segments) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("compliant") - } - - return strings.Join(segments, sep) -} - -// ExpandedView renders the detailed compliance status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start compliance scanning.", - "No compliance issues detected.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderHeadline renders the compliance summary: pending/approved counts and analysis progress. -func (m *Model) renderHeadline() string { - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - leaking := totalObserved(m.categories) - pending := totalPending(m.categories) - atRisk := pending - leaking - approved := totalApproved(m.categories) - - if leaking > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("●") - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d leaking", leaking))) - } - - if atRisk > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d at risk", atRisk))) - } - - if approved > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, ok.Render(fmt.Sprintf("%d fixed", approved))) - } - - // Analysis progress when not yet ready. - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.analysisBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("No compliance issues detected.") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders all compliance categories in a single table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(30)) - tbl.Headers("Category", "Leaking", "At Risk", "Approved") - tbl.SetWidth(width) - - errStyle := lipgloss.NewStyle().Foreground(m.theme.Error).Background(m.theme.Bg) - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - if c.IsLeaking() { - dot = errStyle.Render("●") - } else { - dot = warn.Render("●") - } - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - atRisk := c.PendingCount - c.ObservedCount - - leaking := "—" - if c.ObservedCount > 0 { - leaking = errStyle.Render(format.Count(c.ObservedCount)) - } - - risk := "—" - if atRisk > 0 { - risk = warn.Render(format.Count(atRisk)) - } - - tbl.Row( - name, - leaking, - risk, - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// analysisBar creates a small progress bar for inline use in the headline. -func (m *Model) analysisBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -func totalObserved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ObservedCount - } - return n -} - -func totalPending(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.PendingCount - } - return n -} - -func totalApproved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ApprovedCount - } - return n -} diff --git a/internal/app/statusbar/compliance/compliance_test.go b/internal/app/statusbar/compliance/compliance_test.go deleted file mode 100644 index c8dce646..00000000 --- a/internal/app/statusbar/compliance/compliance_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package compliance - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} diff --git a/internal/app/statusbar/compliance/detail.go b/internal/app/statusbar/compliance/detail.go deleted file mode 100644 index b6af3955..00000000 --- a/internal/app/statusbar/compliance/detail.go +++ /dev/null @@ -1,307 +0,0 @@ -package compliance - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the pending policies for a single compliance category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.CompliancePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.CompliancePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Tell me about the %s compliance issue for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - dotColor := colors.Warning - if d.category.IsLeaking() { - dotColor = colors.Error - } - sev := lipgloss.NewStyle().Foreground(dotColor).Background(colors.Bg) - parts = append(parts, sev.Render("●")+" "+sev.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.ApprovedCount > 0 { - ok := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, ok.Render(fmt.Sprintf("%d fixed", d.category.ApprovedCount))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(40)) - tbl.Headers("Log Event", "Service", "Volume", "Status") - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = d.observedDot(p.AnyObserved) + " " + name - } - - vol := "—" - if p.VolumePerHour != nil { - vol = format.Volume(*p.VolumePerHour) + " evt/hr" - } - - status := d.formatSensitiveTypes(p.Fields, 3) - - tbl.Row( - name, - p.ServiceName, - vol, - status, - ) - } - - return tbl.View() -} - -// observedDot returns a colored dot based on whether sensitive data was observed. -// Red for observed (leaking), orange for at-risk. -func (d *detail) observedDot(observed bool) string { - if observed { - return lipgloss.NewStyle().Foreground(d.theme.Error).Background(d.theme.Bg).Render("●") - } - return lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") -} - -// formatSensitiveTypes returns deduplicated type labels from all fields, -// showing at most maxShow before truncating with "+N". -func (d *detail) formatSensitiveTypes(fields []domain.SensitiveField, maxShow int) string { - if len(fields) == 0 { - return "—" - } - - // Flatten all types across fields, deduplicate, preserve first-seen order. - seen := make(map[string]struct{}) - var types []string - for _, f := range fields { - for _, t := range f.Types { - label := displaySensitiveType(d.category.Category, t) - if _, ok := seen[label]; !ok { - seen[label] = struct{}{} - types = append(types, label) - } - } - } - - if len(types) == 0 { - return "—" - } - - visible := types - remaining := 0 - if len(types) > maxShow { - visible = types[:maxShow] - remaining = len(types) - maxShow - } - - result := strings.Join(visible, ", ") - if remaining > 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - result += muted.Render(fmt.Sprintf(", +%d", remaining)) - } - return result -} - -// displaySensitiveType returns a human-readable label for a sensitive type value. -func displaySensitiveType(category domain.PolicyCategory, t string) string { - // Category-specific type labels - switch category { - case domain.CategoryPIILeakage: - return displayPIIType(t) - case domain.CategorySecretsLeakage: - return displaySecretType(t) - case domain.CategoryPHILeakage: - return displayPHIType(t) - case domain.CategoryPaymentDataLeakage: - return displayPaymentType(t) - default: - return t - } -} - -// displayPIIType returns a human-readable label for a PII type. -func displayPIIType(t string) string { - switch t { - case domain.PIITypeEmail: - return "email" - case domain.PIITypeName: - return "name" - case domain.PIITypePhone: - return "phone" - case domain.PIITypeAddress: - return "address" - case domain.PIITypeSSN: - return "SSN" - case domain.PIITypeNationalID: - return "national ID" - case domain.PIITypeIPAddress: - return "IP address" - case domain.PIITypeDateOfBirth: - return "date of birth" - case domain.PIITypeDriverLicense: - return "driver license" - default: - return t - } -} - -// displaySecretType returns a human-readable label for a secret type. -func displaySecretType(t string) string { - switch t { - case domain.SecretTypeAPIKey: - return "API key" - case domain.SecretTypeBearerToken: - return "bearer token" - case domain.SecretTypeOAuthToken: - return "OAuth token" - case domain.SecretTypePassword: - return "password" - case domain.SecretTypePasswordHash: - return "password hash" - case domain.SecretTypeDatabaseCredential: - return "database credential" - case domain.SecretTypeConnectionString: - return "connection string" - case domain.SecretTypePrivateKey: - return "private key" - case domain.SecretTypeCertificate: - return "certificate" - case domain.SecretTypeEncryptionKey: - return "encryption key" - case domain.SecretTypeSigningKey: - return "signing key" - case domain.SecretTypeWebhookSecret: - return "webhook secret" - case domain.SecretTypeSessionToken: - return "session token" - default: - return t - } -} - -// displayPHIType returns a human-readable label for a PHI type. -func displayPHIType(t string) string { - switch t { - case domain.PHITypeDiagnosisCode: - return "diagnosis code" - case domain.PHITypeProcedureCode: - return "procedure code" - case domain.PHITypePrescription: - return "prescription" - case domain.PHITypeLabResult: - return "lab result" - case domain.PHITypeMedicalRecordNumber: - return "medical record number" - case domain.PHITypePatientIdentifier: - return "patient identifier" - case domain.PHITypeHealthInsuranceID: - return "health insurance ID" - case domain.PHITypeBiometric: - return "biometric" - case domain.PHITypeGeneticData: - return "genetic data" - default: - return t - } -} - -// displayPaymentType returns a human-readable label for a payment data type. -func displayPaymentType(t string) string { - switch t { - case domain.PaymentTypeCreditCard: - return "credit card" - case domain.PaymentTypeCVV: - return "CVV" - case domain.PaymentTypePIN: - return "PIN" - case domain.PaymentTypeBankAccount: - return "bank account" - case domain.PaymentTypeRoutingNumber: - return "routing number" - case domain.PaymentTypePaymentToken: - return "payment token" - case domain.PaymentTypeMagneticStripe: - return "magnetic stripe" - default: - return t - } -} diff --git a/internal/app/statusbar/policytab/base.go b/internal/app/statusbar/policytab/base.go deleted file mode 100644 index 8895742b..00000000 --- a/internal/app/statusbar/policytab/base.go +++ /dev/null @@ -1,86 +0,0 @@ -package policytab - -import ( - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/sqlite" -) - -const defaultPollInterval = 2 * time.Second - -// Base holds shared state/lifecycle behavior for policy-like status bar tabs. -type Base struct { - db sqlite.DB - source string - hasData bool - lastState string - fetching bool - cursor int -} - -func New(source string) Base { - return Base{source: source} -} - -func (b *Base) SetDB(db sqlite.DB) tea.Cmd { - b.db = db - return b.poll() -} - -func (b *Base) Init() tea.Cmd { - if b.db == nil { - return nil - } - return b.poll() -} - -func (b *Base) DB() sqlite.DB { return b.db } -func (b *Base) HasData() bool { return b.hasData } -func (b *Base) Cursor() int { return b.cursor } -func (b *Base) SetCursor(v int) { b.cursor = v } -func (b *Base) SetHasData(v bool) { b.hasData = v } -func (b *Base) HasList(length int) bool { return b.hasData && length > 0 } - -func (b *Base) poll() tea.Cmd { - return tabpoll.Tick(b.source, defaultPollInterval) -} - -// UpdatePoll handles the shared PollMsg/DataMsg cycle. -func UpdatePoll[T any](b *Base, msg tea.Msg, fetchData tea.Cmd, applyData func(data T)) (tea.Cmd, bool) { - return tabpoll.UpdatePollCycle( - msg, - b.source, - b.db != nil, - &b.fetching, - fetchData, - b.poll(), - applyData, - ) -} - -// ApplyIfChanged applies state updates on key changes and clamps cursor. -func (b *Base) ApplyIfChanged(nextState string, listLen int, apply func()) bool { - return tabpoll.ApplyIfChanged(&b.lastState, nextState, &b.cursor, listLen, apply) -} - -// NavController returns standard list/detail drawer navigation wiring. -func (b *Base) NavController( - listLen func() int, - onListSelect func(index int) tea.Cmd, - getDetail func() listdetail.Detail, - clearDetail func(), -) listdetail.Controller { - return listdetail.New( - func() bool { return b.HasList(listLen()) }, - func() int { return b.cursor }, - func(v int) { b.cursor = v }, - listLen, - onListSelect, - getDetail, - clearDetail, - ) -} diff --git a/internal/app/statusbar/policytab/base_test.go b/internal/app/statusbar/policytab/base_test.go deleted file mode 100644 index 3529f562..00000000 --- a/internal/app/statusbar/policytab/base_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package policytab - -import ( - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" -) - -type testDetail struct { - cursor int - len int -} - -func (d *testDetail) Len() int { return d.len } -func (d *testDetail) Cursor() int { return d.cursor } -func (d *testDetail) SetCursor(v int) { d.cursor = v } -func (d *testDetail) Prompt() tea.Cmd { return func() tea.Msg { return "prompt" } } - -func TestBase_ApplyIfChangedAndCursorClamp(t *testing.T) { - t.Parallel() - - b := New("x") - b.SetCursor(5) - applied := b.ApplyIfChanged("a", 2, func() {}) - if !applied { - t.Fatalf("expected applied=true") - } - if b.Cursor() != 1 { - t.Fatalf("expected cursor clamped to 1, got %d", b.Cursor()) - } - - applied = b.ApplyIfChanged("a", 2, func() { t.Fatal("should not reapply") }) - if applied { - t.Fatalf("expected applied=false on same key") - } -} - -func TestBase_HasList(t *testing.T) { - t.Parallel() - - b := New("x") - if b.HasList(1) { - t.Fatalf("expected false without data") - } - b.SetHasData(true) - if !b.HasList(1) { - t.Fatalf("expected true with data and items") - } - if b.HasList(0) { - t.Fatalf("expected false with zero items") - } -} - -func TestBase_NavController(t *testing.T) { - t.Parallel() - - b := New("x") - b.SetHasData(true) - detail := &testDetail{len: 2} - - ctrl := b.NavController( - func() int { return 3 }, - func(index int) tea.Cmd { - return func() tea.Msg { return index } - }, - func() listdetail.Detail { return detail }, - func() { detail = nil }, - ) - - // Enter detail, then back should clear it. - _ = ctrl.HandleKeyPress(tea.KeyPressMsg{}) -} - -func TestUpdatePoll_ForwardsToTabpollCycle(t *testing.T) { - t.Parallel() - - b := New("test") - b.SetHasData(true) - - // Poll without DB should be handled and no command. - cmd, handled := UpdatePoll[int](&b, tabpoll.PollMsg{Source: "test"}, nil, func(int) {}) - if !handled { - t.Fatalf("expected handled") - } - if cmd != nil { - t.Fatalf("expected nil cmd when db missing") - } -} diff --git a/internal/app/statusbar/quality/detail.go b/internal/app/statusbar/quality/detail.go deleted file mode 100644 index 20ca6030..00000000 --- a/internal/app/statusbar/quality/detail.go +++ /dev/null @@ -1,124 +0,0 @@ -package quality - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the top pending policies for a single quality category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.WastePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.WastePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Pull up the %q quality policy for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - parts = append(parts, warn.Render("●")+" "+warn.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.EstimatedCostPerHour != nil && *d.category.EstimatedCostPerHour > 0 { - parts = append(parts, muted.Render(format.YearlyCostPtr(d.category.EstimatedCostPerHour))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -// Quality policies trim fields, so we show bytes (not volume). -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(30)) - tbl.Headers("Log Event", "Service", "Bytes", "Est. Impact") - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - dot := lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - bytes := "—" - if p.BytesPerHour != nil { - bytes = format.Bytes(*p.BytesPerHour) + "/hr" - } - - tbl.Row(name, p.ServiceName, bytes, format.YearlyCostPtr(p.EstimatedCostPerHour)) - } - - return tbl.View() -} diff --git a/internal/app/statusbar/quality/quality.go b/internal/app/statusbar/quality/quality.go deleted file mode 100644 index fd7c2bdd..00000000 --- a/internal/app/statusbar/quality/quality.go +++ /dev/null @@ -1,441 +0,0 @@ -// Package quality renders the quality indicator in the status bar. -package quality - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "quality" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// qualityDetailLoadedMsg carries the result of an async detail fetch. -type qualityDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.WastePolicy - err error -} - -// Model renders the quality policy status: pending count, estimated savings. -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new quality status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("quality"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case qualityDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - - default: - _ = msg - } - - return nil -} - -// fetchData returns a Cmd that queries quality data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - categories, err := db.LogEventPolicyCategoryStatuses().ListQualityCategoryStatuses(ctx) - if err != nil { - scope.Error("list quality category statuses", "err", err) - return fetchedData{}, err - } - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.WastePolicy, error) { - policies, err := db.LogEventPolicyStatuses().ListTopPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list top pending policies", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.WastePolicy, err error) tea.Msg { - if err != nil { - return qualityDetailLoadedMsg{err: err} - } - return qualityDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(summary domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := qualityStateKey{ - Summary: qualitySummaryKey{ - ServiceCount: summary.ServiceCount, - ActiveServices: summary.ActiveServices, - }, - Categories: make([]qualityCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, qualityCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - EstimatedVolumePerHour: c.EstimatedVolumePerHour, - EstimatedBytesPerHour: c.EstimatedBytesPerHour, - EstimatedCostPerHour: c.EstimatedCostPerHour, - EventsWithVolumes: c.EventsWithVolumes, - TotalEvents: c.TotalEvents, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type qualityStateKey struct { - Summary qualitySummaryKey `json:"summary"` - Categories []qualityCategoryKey `json:"categories"` -} - -type qualitySummaryKey struct { - ServiceCount int64 `json:"service_count"` - ActiveServices int64 `json:"active_services"` -} - -type qualityCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EventsWithVolumes int64 `json:"events_with_volumes"` - TotalEvents int64 `json:"total_events"` -} - -// HasData returns true when quality data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the quality status for the statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - pending := totalPending(m.categories) - if pending > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - return dot + " " + muted.Render(fmt.Sprintf("%d quality", pending)) - } - - return "" -} - -// ExpandedView renders the detailed quality status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start detecting quality issues.", - "No quality issues detected.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderHeadline renders the quality summary. -func (m *Model) renderHeadline() string { - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - pending := totalPending(m.categories) - approved := totalApproved(m.categories) - - if pending > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d pending", pending))) - } - - if approved > 0 { - parts = append(parts, muted.Render(fmt.Sprintf("%d approved", approved))) - } - - // Analysis progress when not yet ready. - s := m.summary - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.discoveryBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - return muted.Render("All quality policies reviewed") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders quality categories in a table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(35)) - tbl.Headers("Category", "Pending", "Impact", "Approved") - tbl.SetWidth(width) - - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) - bar := m.discoveryBar() - - totalCost := totalEstimatedCost(m.categories) - - // Find widest pending count for alignment. - maxPendingW := 1 - for _, c := range m.categories { - if w := len(format.Count(c.PendingCount)); w > maxPendingW { - maxPendingW = w - } - } - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - dot = warn.Render("●") - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - // Pending count with optional discovery progress bar. - pending := fmt.Sprintf("%-*s", maxPendingW, format.Count(c.PendingCount)) - if c.TotalEvents > 0 { - pct := int(c.EventsWithVolumes * 100 / c.TotalEvents) - if pct < 80 { - pending += " " + bar.ViewAs(float64(pct)/100) + " " + muted.Render(fmt.Sprintf("%d%%", pct)) - } - } - - tbl.Row( - name, - pending, - formatCategoryCost(c, totalCost, ok, muted), - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// discoveryBar creates a small progress bar for inline use in table cells. -func (m *Model) discoveryBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -func totalPending(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.PendingCount - } - return n -} - -func totalApproved(cats []domain.PolicyCategoryStatus) int64 { - var n int64 - for _, c := range cats { - n += c.ApprovedCount - } - return n -} - -func totalEstimatedCost(cats []domain.PolicyCategoryStatus) float64 { - var total float64 - for _, c := range cats { - if c.EstimatedCostPerHour != nil { - total += *c.EstimatedCostPerHour - } - } - return total -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -// formatCategoryCost returns estimated yearly cost for a category, with its -// share of total estimated savings. -func formatCategoryCost(c domain.PolicyCategoryStatus, totalCostPerHour float64, success, muted lipgloss.Style) string { - if c.EstimatedCostPerHour == nil || *c.EstimatedCostPerHour <= 0 { - return format.YearlyCostPtr(c.EstimatedCostPerHour) - } - - if totalCostPerHour > 0 { - pct := int(math.Round(*c.EstimatedCostPerHour / totalCostPerHour * 100)) - if pct <= 1 { - return muted.Render("≤1%") - } - cost := success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) - if pct < 100 { - cost += " " + muted.Render(fmt.Sprintf("(%d%%)", pct)) - } - return cost - } - - return success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) -} diff --git a/internal/app/statusbar/quality/quality_test.go b/internal/app/statusbar/quality/quality_test.go deleted file mode 100644 index 8bb4c066..00000000 --- a/internal/app/statusbar/quality/quality_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package quality - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} diff --git a/internal/app/statusbar/statusbar.go b/internal/app/statusbar/statusbar.go index 063b15da..743f94a2 100644 --- a/internal/app/statusbar/statusbar.go +++ b/internal/app/statusbar/statusbar.go @@ -4,11 +4,9 @@ package statusbar import ( "time" - "github.com/usetero/cli/internal/app/statusbar/compliance" - "github.com/usetero/cli/internal/app/statusbar/quality" "github.com/usetero/cli/internal/app/statusbar/services" + "github.com/usetero/cli/internal/app/statusbar/surfaces" "github.com/usetero/cli/internal/app/statusbar/syncstatus" - "github.com/usetero/cli/internal/app/statusbar/waste" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/styles" @@ -24,29 +22,32 @@ type workspaceCountLoadedMsg struct { // Tab indices for the drawer. const ( - TabWaste = 0 - TabQuality = 1 - TabCompliance = 2 - TabServices = 3 - TabSync = 4 - tabCount = 5 + TabPolicies = 0 + TabIssues = 1 + TabChecks = 2 + TabServices = 3 + TabLogEvents = 4 + TabEdgeInstances = 5 + tabCount = 6 ) // Tab labels. -var tabLabels = [tabCount]string{"Waste", "Quality", "Compliance", "Services", "Sync"} +var tabLabels = [tabCount]string{"Policies", "Issues", "Checks", "Services", "Log events", "Edge instances"} // Model renders the app status bar. type Model struct { - theme styles.Theme - scope log.Scope - env string - tabs []drawerTab - syncStatus *syncstatus.Model - servicesStatus *services.Model - wasteStatus *waste.Model - qualityStatus *quality.Model - complianceStatus *compliance.Model - width int + theme styles.Theme + scope log.Scope + env string + tabs []drawerTab + syncStatus *syncstatus.Model + policiesStatus *surfaces.Model + issuesStatus *surfaces.Model + checksStatus *surfaces.Model + servicesStatus *services.Model + logEventsStatus *surfaces.Model + edgeStatus *surfaces.Model + width int // Account context org string @@ -68,14 +69,16 @@ type Model struct { func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, host string, env string) *Model { scope = scope.Child("statusbar") m := &Model{ - theme: theme, - scope: scope, - env: env, - syncStatus: syncstatus.New(theme, scope, syncer, host), - servicesStatus: services.New(theme, scope), - wasteStatus: waste.New(theme, scope), - qualityStatus: quality.New(theme, scope), - complianceStatus: compliance.New(theme, scope), + theme: theme, + scope: scope, + env: env, + syncStatus: syncstatus.New(theme, scope, syncer, host), + policiesStatus: surfaces.NewPolicies(theme, scope), + issuesStatus: surfaces.NewIssues(theme, scope), + checksStatus: surfaces.NewChecks(theme, scope), + servicesStatus: services.New(theme, scope), + logEventsStatus: surfaces.NewLogEvents(theme, scope), + edgeStatus: surfaces.NewEdgeInstances(theme, scope), } m.tabs = m.buildTabs() return m diff --git a/internal/app/statusbar/statusbar_data.go b/internal/app/statusbar/statusbar_data.go index 12ea09b4..81e2b624 100644 --- a/internal/app/statusbar/statusbar_data.go +++ b/internal/app/statusbar/statusbar_data.go @@ -10,8 +10,12 @@ import ( ) // SetDB sets the database for status polling. +// +// syncStatus is fed alongside the drawer tabs even though it is no longer a +// drawer tab itself: its compact sync dot lives in the brand segment and its +// pending-upload count needs the runtime database. func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - cmds := []tea.Cmd{m.fetchWorkspaceCount(db)} + cmds := []tea.Cmd{m.fetchWorkspaceCount(db), m.syncStatus.SetDB(db)} for _, tab := range m.tabs { cmds = append(cmds, tab.SetDB(db)) } @@ -20,7 +24,7 @@ func (m *Model) SetDB(db sqlite.DB) tea.Cmd { // Init initializes child models. func (m *Model) Init() tea.Cmd { - var cmds []tea.Cmd + cmds := []tea.Cmd{m.syncStatus.Init()} for _, tab := range m.tabs { cmds = append(cmds, tab.Init()) } @@ -31,7 +35,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) tea.Cmd { m.ingestStatusMessages(msg) - var cmds []tea.Cmd + cmds := []tea.Cmd{m.syncStatus.Update(msg)} for _, tab := range m.tabs { cmds = append(cmds, tab.Update(msg)) } diff --git a/internal/app/statusbar/statusbar_test.go b/internal/app/statusbar/statusbar_test.go index 1a138c55..3bc20231 100644 --- a/internal/app/statusbar/statusbar_test.go +++ b/internal/app/statusbar/statusbar_test.go @@ -58,3 +58,48 @@ func TestFetchWorkspaceCountReturnsErrorWhenTableMissing(t *testing.T) { t.Fatalf("expected error when workspaces table is missing") } } + +func TestSyncStatusStaysWiredOutsideDrawerTabs(t *testing.T) { + m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + + // syncStatus renders the brand sync dot but is no longer a drawer tab, so + // the lifecycle must reach it independently of m.tabs. Clearing the tabs + // isolates that wiring: with the syncer present, Init must still start the + // sync poll loop. + m.tabs = nil + if m.Init() == nil { + t.Fatal("Init() must start syncStatus polling even with no drawer tabs") + } +} + +func TestBuildTabsMirrorsProductSurfaces(t *testing.T) { + m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + + want := []struct { + group string + label string + }{ + {group: "Control Plane", label: "Policies"}, + {group: "Control Plane", label: "Issues"}, + {group: "Control Plane", label: "Checks"}, + {group: "Data Plane", label: "Services"}, + {group: "Data Plane", label: "Log events"}, + {group: "Data Plane", label: "Edge instances"}, + } + + if len(m.tabs) != len(want) { + t.Fatalf("tab count = %d, want %d", len(m.tabs), len(want)) + } + for i, tab := range m.tabs { + if tab.Label() != want[i].label { + t.Fatalf("tab %d label = %q, want %q", i, tab.Label(), want[i].label) + } + grouped, ok := tab.(groupedDrawerTab) + if !ok { + t.Fatalf("tab %d does not expose a group", i) + } + if grouped.GroupLabel() != want[i].group { + t.Fatalf("tab %d group = %q, want %q", i, grouped.GroupLabel(), want[i].group) + } + } +} diff --git a/internal/app/statusbar/statusbar_view.go b/internal/app/statusbar/statusbar_view.go index 045fbc7d..db8fab93 100644 --- a/internal/app/statusbar/statusbar_view.go +++ b/internal/app/statusbar/statusbar_view.go @@ -32,28 +32,21 @@ func (m *Model) View() string { // 1. Brand + sync dot + org context (always shown) segments = append(segments, m.renderBrand()) - // 2. Services health (dot + service count + discovery) + // 2. Issues and services mirror the primary product surfaces without + // flooding the compact bar with every drawer tab. + issuesView := m.issuesStatus.CompactView() + if issuesView != "" { + segments = append(segments, issuesView) + } + servicesView := m.servicesStatus.CompactView() if servicesView != "" { segments = append(segments, servicesView) } - // 3. Waste status (pending count, estimated/observed savings) - wasteView := m.wasteStatus.CompactView() - if wasteView != "" { - segments = append(segments, wasteView) - } - - // 4. Quality status (field-level improvements) - qualityView := m.qualityStatus.CompactView() - if qualityView != "" { - segments = append(segments, qualityView) - } - - // 5. Compliance status (leaking/at-risk counts across PII, Secrets, PHI, Payment Data) - complianceView := m.complianceStatus.CompactView() - if complianceView != "" { - segments = append(segments, complianceView) + logEventsView := m.logEventsStatus.CompactView() + if logEventsView != "" { + segments = append(segments, logEventsView) } // Build right-aligned segment first so we know how much space is left. @@ -151,19 +144,34 @@ func (m *Model) renderTabBar(width int) string { colors := m.theme activeStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg).Bold(true) inactiveStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + groupStyle := lipgloss.NewStyle().Foreground(colors.TextSubtle).Background(colors.Bg).Bold(true) sepStyle := lipgloss.NewStyle().Foreground(colors.TextSubtle).Background(colors.Bg) - var tabs []string + var parts []string + lastGroup := "" for i, tab := range m.tabs { + group := "" + if grouped, ok := tab.(groupedDrawerTab); ok { + group = grouped.GroupLabel() + } + if group != "" && group != lastGroup { + parts = append(parts, groupStyle.Render(strings.ToUpper(group))) + lastGroup = group + } + label := tab.Label() if i == m.activeTab { - tabs = append(tabs, activeStyle.Render(label)) + parts = append(parts, activeStyle.Render(label)) } else { - tabs = append(tabs, inactiveStyle.Render(label)) + parts = append(parts, inactiveStyle.Render(label)) } } - return strings.Join(tabs, sepStyle.Render(" ")) + rendered := strings.Join(parts, sepStyle.Render(" ")) + if width > 0 && lipgloss.Width(rendered) > width { + return lipgloss.NewStyle().MaxWidth(width).Render(rendered) + } + return rendered } // renderDrawerHint renders the "ctrl+d open/close" hint. diff --git a/internal/app/statusbar/surfaces/surfaces.go b/internal/app/statusbar/surfaces/surfaces.go new file mode 100644 index 00000000..68cb8649 --- /dev/null +++ b/internal/app/statusbar/surfaces/surfaces.go @@ -0,0 +1,437 @@ +// Package surfaces renders high-level product surfaces in the status drawer. +package surfaces + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/statusbar/tabpoll" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/format" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" + "github.com/usetero/cli/internal/tea/components/table" +) + +const ( + pollInterval = 2 * time.Second + dbTimeout = 2 * time.Second +) + +type fetchFunc func(context.Context, sqlite.DB) (Snapshot, error) + +// Metric is one line of supporting state for a product surface. +type Metric struct { + Label string + Value string + Tone string +} + +// Snapshot is the presentation state for one product surface. +type Snapshot struct { + Title string + Description string + Primary Metric + Metrics []Metric + Rows [][]string + Loaded bool +} + +// Model renders a non-interactive product surface tab. +type Model struct { + theme styles.Theme + scope log.Scope + db sqlite.DB + + source string + fetch fetchFunc + + snapshot Snapshot + hasData bool + fetching bool + lastState string +} + +// NewPolicies creates the policies surface. +func NewPolicies(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "policies", fetchPolicies) +} + +// NewIssues creates the issues surface. +func NewIssues(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "issues", fetchIssues) +} + +// NewChecks creates the checks surface. +func NewChecks(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "checks", fetchChecks) +} + +// NewLogEvents creates the log events surface. +func NewLogEvents(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "log-events", fetchLogEvents) +} + +// NewEdgeInstances creates the edge instances surface. +func NewEdgeInstances(theme styles.Theme, scope log.Scope) *Model { + return newModel(theme, scope, "edge-instances", fetchEdgeInstances) +} + +func newModel(theme styles.Theme, scope log.Scope, source string, fetch fetchFunc) *Model { + return &Model{ + theme: theme, + scope: scope.Child(source), + source: source, + fetch: fetch, + } +} + +// SetDB sets the database and starts polling. +func (m *Model) SetDB(db sqlite.DB) tea.Cmd { + m.db = db + return m.poll() +} + +// Init starts polling when the runtime database is available. +func (m *Model) Init() tea.Cmd { + if m.db == nil { + return nil + } + return m.poll() +} + +func (m *Model) poll() tea.Cmd { + return tabpoll.Tick(m.source, pollInterval) +} + +// Update handles polling messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + if cmd, handled := tabpoll.UpdatePollCycle( + msg, + m.source, + m.db != nil, + &m.fetching, + m.fetchData(), + m.poll(), + func(snapshot Snapshot) { + key := snapshotKey(snapshot) + tabpoll.ApplyIfChanged(&m.lastState, key, nil, 0, func() { + m.snapshot = snapshot + m.hasData = snapshot.Loaded + }) + }, + ); handled { + return cmd + } + return nil +} + +func (m *Model) fetchData() tea.Cmd { + db := m.db + fetch := m.fetch + scope := m.scope + return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (Snapshot, error) { + snapshot, err := fetch(ctx, db) + if err != nil { + scope.Error("fetch surface", "err", err) + return Snapshot{}, err + } + return snapshot, nil + }) +} + +// HasData returns true when the tab has loaded a runtime snapshot. +func (m *Model) HasData() bool { + return m.hasData +} + +// CompactView renders the surface's compact status bar signal. +func (m *Model) CompactView() string { + if !m.hasData || m.snapshot.Primary.Value == "" || m.snapshot.Primary.Value == "0" { + return "" + } + + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) + return muted.Render(m.snapshot.Primary.Value + " " + strings.ToLower(m.snapshot.Title)) +} + +// ExpandedView renders the surface drawer body. +func (m *Model) ExpandedView(width, _ int) string { + colors := m.theme + if !m.hasData { + return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg).Render("Waiting for synced data...") + } + + titleStyle := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg).Bold(true) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + lines := []string{titleStyle.Render(m.snapshot.Title)} + if m.snapshot.Description != "" { + lines = append(lines, muted.Render(m.snapshot.Description)) + } + if len(m.snapshot.Metrics) > 0 { + lines = append(lines, "", m.renderMetrics()) + } + if len(m.snapshot.Rows) > 0 { + lines = append(lines, "", m.renderRows(width)) + } + return strings.Join(lines, "\n") +} + +func (m *Model) renderMetrics() string { + parts := make([]string, 0, len(m.snapshot.Metrics)+1) + if m.snapshot.Primary.Label != "" { + parts = append(parts, m.renderMetric(m.snapshot.Primary)) + } + for _, metric := range m.snapshot.Metrics { + parts = append(parts, m.renderMetric(metric)) + } + return strings.Join(parts, lipgloss.NewStyle().Foreground(m.theme.TextSubtle).Background(m.theme.Bg).Render(" ")) +} + +func (m *Model) renderMetric(metric Metric) string { + label := lipgloss.NewStyle().Foreground(m.theme.TextSubtle).Background(m.theme.Bg).Render(metric.Label) + valueStyle := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) + switch metric.Tone { + case "danger": + valueStyle = valueStyle.Foreground(m.theme.Error).Bold(true) + case "warning": + valueStyle = valueStyle.Foreground(m.theme.Warning).Bold(true) + case "success": + valueStyle = valueStyle.Foreground(m.theme.Success).Bold(true) + } + return valueStyle.Render(metric.Value) + " " + label +} + +func (m *Model) renderRows(width int) string { + tbl := table.New(m.theme, table.WithMaxValueWidth(36)) + tbl.Headers("Area", "State", "Signal") + tbl.SetWidth(width) + for _, row := range m.snapshot.Rows { + tbl.Row(row...) + } + return tbl.View() +} + +func fetchPolicies(ctx context.Context, db sqlite.DB) (Snapshot, error) { + summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + if err != nil { + return Snapshot{}, err + } + total, err := db.LogEventPolicies().Count(ctx) + if err != nil { + total = summary.PendingPolicyCount + summary.ApprovedPolicyCount + summary.DismissedPolicyCount + } + + return Snapshot{ + Title: "Policies", + Description: "Reviewable policy state synced from the control plane.", + Primary: Metric{Label: "total", Value: count(total)}, + Metrics: []Metric{ + {Label: "pending", Value: count(summary.PendingPolicyCount), Tone: pendingTone(summary.PendingPolicyCount)}, + {Label: "approved", Value: count(summary.ApprovedPolicyCount), Tone: "success"}, + {Label: "dismissed", Value: count(summary.DismissedPolicyCount)}, + }, + Rows: [][]string{ + {"Awaiting review", count(summary.PendingPolicyCount), highSignal(summary)}, + {"Approved", count(summary.ApprovedPolicyCount), "running policy decisions"}, + {"Dismissed", count(summary.DismissedPolicyCount), "reviewed and set aside"}, + }, + Loaded: true, + }, nil +} + +func fetchIssues(ctx context.Context, db sqlite.DB) (Snapshot, error) { + summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + if err != nil { + return Snapshot{}, err + } + high := summary.PolicyPendingCriticalCount + summary.PolicyPendingHighCount + return Snapshot{ + Title: "Issues", + Description: "Policy-remediable issues currently awaiting operator judgment.", + Primary: Metric{Label: "open", Value: count(summary.PendingPolicyCount), Tone: pendingTone(summary.PendingPolicyCount)}, + Metrics: []Metric{ + {Label: "high", Value: count(high), Tone: highTone(high)}, + {Label: "medium", Value: count(summary.PolicyPendingMediumCount), Tone: pendingTone(summary.PolicyPendingMediumCount)}, + {Label: "low", Value: count(summary.PolicyPendingLowCount)}, + }, + Rows: [][]string{ + {"Critical", count(summary.PolicyPendingCriticalCount), "needs immediate review"}, + {"High", count(summary.PolicyPendingHighCount), "high-priority review queue"}, + {"Medium", count(summary.PolicyPendingMediumCount), "normal review queue"}, + {"Low", count(summary.PolicyPendingLowCount), "background cleanup"}, + }, + Loaded: true, + }, nil +} + +func fetchChecks(ctx context.Context, db sqlite.DB) (Snapshot, error) { + statuses := db.LogEventPolicyCategoryStatuses() + waste, err := statuses.ListWasteCategoryStatuses(ctx) + if err != nil { + return Snapshot{}, err + } + quality, err := statuses.ListQualityCategoryStatuses(ctx) + if err != nil { + return Snapshot{}, err + } + compliance, err := statuses.ListComplianceCategoryStatuses(ctx) + if err != nil { + return Snapshot{}, err + } + + return Snapshot{ + Title: "Checks", + Description: "Control-plane check categories represented in the local projection.", + Primary: Metric{Label: "categories", Value: count(int64(len(waste) + len(quality) + len(compliance)))}, + Metrics: []Metric{ + {Label: "cost", Value: count(int64(len(waste)))}, + {Label: "quality", Value: count(int64(len(quality)))}, + {Label: "compliance", Value: count(int64(len(compliance)))}, + }, + Rows: [][]string{ + {"Cost", categorySummary(waste), pendingCategorySignal(waste)}, + {"Data quality", categorySummary(quality), pendingCategorySignal(quality)}, + {"Compliance", categorySummary(compliance), pendingCategorySignal(compliance)}, + }, + Loaded: true, + }, nil +} + +func fetchLogEvents(ctx context.Context, db sqlite.DB) (Snapshot, error) { + summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + if err != nil { + return Snapshot{}, err + } + total, err := db.LogEvents().Count(ctx) + if err != nil { + total = summary.EventCount + } + coverage := "not analyzed" + if total > 0 { + coverage = fmt.Sprintf("%d%% analyzed", int(float64(summary.AnalyzedCount)/float64(total)*100)) + } + volume := "waiting for volume" + if summary.TotalVolumePerHour != nil { + volume = format.Volume(*summary.TotalVolumePerHour) + " evt/hr" + } + + return Snapshot{ + Title: "Log events", + Description: "Discovered log-event catalog and analysis coverage.", + Primary: Metric{Label: "events", Value: count(total)}, + Metrics: []Metric{ + {Label: "analyzed", Value: count(summary.AnalyzedCount), Tone: analyzedTone(summary, total)}, + {Label: "volume", Value: volume}, + }, + Rows: [][]string{ + {"Catalog", count(total), "events discovered"}, + {"Analysis", count(summary.AnalyzedCount), coverage}, + {"Runtime", volume, "current observed throughput"}, + }, + Loaded: true, + }, nil +} + +func fetchEdgeInstances(context.Context, sqlite.DB) (Snapshot, error) { + return Snapshot{ + Title: "Edge instances", + Description: "Edge runtime projection is not synced into this CLI yet.", + Primary: Metric{Label: "instances", Value: "0"}, + Metrics: []Metric{ + {Label: "connected", Value: "0"}, + {Label: "projection", Value: "pending"}, + }, + Rows: [][]string{ + {"Runtime", "pending", "waiting for edge instance sync"}, + {"Control plane", "available in webapp", "CLI surface reserved"}, + }, + Loaded: true, + }, nil +} + +func snapshotKey(snapshot Snapshot) string { + var b strings.Builder + b.WriteString(snapshot.Title) + b.WriteString(snapshot.Description) + b.WriteString(snapshot.Primary.Label) + b.WriteString(snapshot.Primary.Value) + for _, metric := range snapshot.Metrics { + b.WriteString(metric.Label) + b.WriteString(metric.Value) + b.WriteString(metric.Tone) + } + for _, row := range snapshot.Rows { + b.WriteString(strings.Join(row, "\x00")) + } + return b.String() +} + +func count(n int64) string { + return fmt.Sprintf("%d", n) +} + +func pendingTone(n int64) string { + if n > 0 { + return "warning" + } + return "success" +} + +func highTone(n int64) string { + if n > 0 { + return "danger" + } + return "success" +} + +func highSignal(summary domain.AccountSummary) string { + high := summary.PolicyPendingCriticalCount + summary.PolicyPendingHighCount + if high > 0 { + return fmt.Sprintf("%d high priority", high) + } + return "no high-priority issues" +} + +func analyzedTone(summary domain.AccountSummary, total int64) string { + if total == 0 { + return "" + } + if summary.AnalysisReady() { + return "success" + } + return "warning" +} + +func categorySummary(categories []domain.PolicyCategoryStatus) string { + var pending int64 + for _, category := range categories { + pending += category.PendingCount + } + return fmt.Sprintf("%d categories, %d pending", len(categories), pending) +} + +func pendingCategorySignal(categories []domain.PolicyCategoryStatus) string { + var high int64 + var estimatedCost float64 + for _, category := range categories { + high += category.PolicyPendingCriticalCount + category.PolicyPendingHighCount + if category.EstimatedCostPerHour != nil { + estimatedCost += *category.EstimatedCostPerHour + } + } + if high > 0 { + return fmt.Sprintf("%d high-priority policies", high) + } + if estimatedCost > 0 { + return format.YearlyCost(estimatedCost) + } + return "no high-priority policies" +} diff --git a/internal/app/statusbar/surfaces/surfaces_test.go b/internal/app/statusbar/surfaces/surfaces_test.go new file mode 100644 index 00000000..d173f8f1 --- /dev/null +++ b/internal/app/statusbar/surfaces/surfaces_test.go @@ -0,0 +1,98 @@ +package surfaces + +import ( + "strings" + "testing" + + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log/logtest" + "github.com/usetero/cli/internal/styles" +) + +func TestCompactViewHidesEmptyAndZeroPrimary(t *testing.T) { + m := NewIssues(styles.NewTheme(true), logtest.NewScope(t)) + m.hasData = true + + cases := []struct { + name string + primary Metric + want string + }{ + {name: "no data loaded", primary: Metric{Value: "3"}, want: ""}, + {name: "zero value", primary: Metric{Value: "0"}, want: ""}, + {name: "empty value", primary: Metric{Value: ""}, want: ""}, + {name: "real value", primary: Metric{Value: "3"}, want: "issues"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m.hasData = tc.name != "no data loaded" + m.snapshot = Snapshot{Title: "Issues", Primary: tc.primary} + + got := m.CompactView() + if tc.want == "" { + if got != "" { + t.Fatalf("CompactView() = %q, want empty", got) + } + return + } + if !strings.Contains(got, tc.primary.Value) || !strings.Contains(got, tc.want) { + t.Fatalf("CompactView() = %q, want it to contain %q and %q", got, tc.primary.Value, tc.want) + } + }) + } +} + +func TestPendingCategorySignalBranches(t *testing.T) { + cost := 4.0 + + highPriority := []domain.PolicyCategoryStatus{{PolicyPendingHighCount: 2, EstimatedCostPerHour: &cost}} + if got := pendingCategorySignal(highPriority); !strings.Contains(got, "high-priority") { + t.Fatalf("expected high-priority signal to win, got %q", got) + } + + costOnly := []domain.PolicyCategoryStatus{{EstimatedCostPerHour: &cost}} + if got := pendingCategorySignal(costOnly); strings.Contains(got, "high-priority") || got == "" { + t.Fatalf("expected a cost signal with no high-priority work, got %q", got) + } + + idle := []domain.PolicyCategoryStatus{{}} + if got := pendingCategorySignal(idle); got != "no high-priority policies" { + t.Fatalf("expected idle signal, got %q", got) + } +} + +func TestAnalyzedToneBranches(t *testing.T) { + if got := analyzedTone(domain.AccountSummary{}, 0); got != "" { + t.Fatalf("expected no tone with zero total, got %q", got) + } + + ready := domain.AccountSummary{EventCount: 100, AnalyzedCount: 100} + if got := analyzedTone(ready, 100); got != "success" { + t.Fatalf("expected success tone when analysis ready, got %q", got) + } + + lagging := domain.AccountSummary{EventCount: 100, AnalyzedCount: 1} + if got := analyzedTone(lagging, 100); got != "warning" { + t.Fatalf("expected warning tone when analysis lagging, got %q", got) + } +} + +func TestSnapshotKeyDetectsContentChange(t *testing.T) { + base := Snapshot{ + Title: "Issues", + Primary: Metric{Label: "open", Value: "3"}, + Metrics: []Metric{{Label: "high", Value: "1", Tone: "danger"}}, + Rows: [][]string{{"Critical", "1", "needs review"}}, + } + + if snapshotKey(base) != snapshotKey(base) { + t.Fatal("snapshotKey must be stable for identical snapshots") + } + + changed := base + changed.Primary.Value = "4" + if snapshotKey(base) == snapshotKey(changed) { + t.Fatal("snapshotKey must change when a metric value changes") + } +} diff --git a/internal/app/statusbar/tabs.go b/internal/app/statusbar/tabs.go index fd5d5d28..89365937 100644 --- a/internal/app/statusbar/tabs.go +++ b/internal/app/statusbar/tabs.go @@ -38,17 +38,24 @@ type drawerTab interface { HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd } +type groupedDrawerTab interface { + GroupLabel() string +} + type tab struct { + group string label string model TabModel } -func newTab(label string, model TabModel) drawerTab { - return tab{label: label, model: model} +func newTab(group, label string, model TabModel) drawerTab { + return tab{group: group, label: label, model: model} } func (t tab) Label() string { return t.label } +func (t tab) GroupLabel() string { return t.group } + func (t tab) SetDB(db sqlite.DB) tea.Cmd { return t.model.SetDB(db) } func (t tab) Init() tea.Cmd { return t.model.Init() } @@ -90,10 +97,11 @@ func (t tab) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) buildTabs() []drawerTab { return []drawerTab{ - newTab(tabLabels[TabWaste], m.wasteStatus), - newTab(tabLabels[TabQuality], m.qualityStatus), - newTab(tabLabels[TabCompliance], m.complianceStatus), - newTab(tabLabels[TabServices], m.servicesStatus), - newTab(tabLabels[TabSync], m.syncStatus), + newTab("Control Plane", tabLabels[TabPolicies], m.policiesStatus), + newTab("Control Plane", tabLabels[TabIssues], m.issuesStatus), + newTab("Control Plane", tabLabels[TabChecks], m.checksStatus), + newTab("Data Plane", tabLabels[TabServices], m.servicesStatus), + newTab("Data Plane", tabLabels[TabLogEvents], m.logEventsStatus), + newTab("Data Plane", tabLabels[TabEdgeInstances], m.edgeStatus), } } diff --git a/internal/app/statusbar/waste/detail.go b/internal/app/statusbar/waste/detail.go deleted file mode 100644 index 40e16da6..00000000 --- a/internal/app/statusbar/waste/detail.go +++ /dev/null @@ -1,138 +0,0 @@ -package waste - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// detail renders the top pending policies for a single waste category. -type detail struct { - theme styles.Theme - category domain.PolicyCategoryStatus - policies []domain.WastePolicy - cursor int -} - -func (d *detail) Len() int { return len(d.policies) } -func (d *detail) Cursor() int { return d.cursor } -func (d *detail) SetCursor(v int) { d.cursor = v } - -// newDetail creates a detail view for the given category and pre-fetched policies. -func newDetail(theme styles.Theme, category domain.PolicyCategoryStatus, policies []domain.WastePolicy) *detail { - return &detail{ - theme: theme, - category: category, - policies: policies, - } -} - -// Prompt returns a tea.Cmd that emits a DrawerPromptRequested event for the selected policy. -func (d *detail) Prompt() tea.Cmd { - if len(d.policies) == 0 { - return nil - } - p := d.policies[d.cursor] - text := fmt.Sprintf( - "Pull up the %q policy for the %q log event in the %s service.", - d.category.Name(), p.LogEventName, p.ServiceName, - ) - return appevents.RequestDrawerPromptCmd(text) -} - -// View renders the detail: a header with category summary, then a policy table. -func (d *detail) View(width int) string { - var lines []string - lines = append(lines, d.renderHeader()) - if d.category.Principle != "" { - lines = append(lines, "") - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render(d.category.Principle)) - } - lines = append(lines, "") - - if len(d.policies) == 0 { - muted := lipgloss.NewStyle().Foreground(d.theme.TextMuted).Background(d.theme.Bg) - lines = append(lines, muted.Render("No pending policies in this category.")) - } else { - lines = append(lines, d.renderTable(width)) - } - - return strings.Join(lines, "\n") -} - -// renderHeader renders the back hint + category name + summary. -func (d *detail) renderHeader() string { - colors := d.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - sep := muted.Render(" · ") - - back := muted.Render("esc ◀") - name := text.Bold(true).Render(d.category.Name()) - - var parts []string - parts = append(parts, back+" "+name) - - if d.category.PendingCount > 0 { - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - parts = append(parts, warn.Render("●")+" "+warn.Render(fmt.Sprintf("%d pending", d.category.PendingCount))) - } - - if d.category.EstimatedCostPerHour != nil && *d.category.EstimatedCostPerHour > 0 { - parts = append(parts, muted.Render(format.YearlyCostPtr(d.category.EstimatedCostPerHour))) - } - - return strings.Join(parts, sep) -} - -// renderTable renders the per-policy table. -func (d *detail) renderTable(width int) string { - tbl := table.New(d.theme, table.WithMaxValueWidth(30)) - - showVolume := d.category.ReducesVolume() - if showVolume { - tbl.Headers("Log Event", "Service", "Volume", "Bytes", "Est. Impact") - } else { - tbl.Headers("Log Event", "Service", "Bytes", "Est. Impact") - } - tbl.SetWidth(width) - - accent := lipgloss.NewStyle().Foreground(d.theme.Accent).Background(d.theme.Bg) - dot := lipgloss.NewStyle().Foreground(d.theme.Warning).Background(d.theme.Bg).Render("●") - - for i, p := range d.policies { - name := p.LogEventName - if i == d.cursor { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - bytes := "—" - if p.BytesPerHour != nil { - bytes = format.Bytes(*p.BytesPerHour) + "/hr" - } - savings := format.YearlyCostPtr(p.EstimatedCostPerHour) - - if showVolume { - vol := "—" - if p.VolumePerHour != nil { - vol = format.Volume(*p.VolumePerHour) + " evt/hr" - } - tbl.Row(name, p.ServiceName, vol, bytes, savings) - } else { - tbl.Row(name, p.ServiceName, bytes, savings) - } - } - - return tbl.View() -} diff --git a/internal/app/statusbar/waste/waste.go b/internal/app/statusbar/waste/waste.go deleted file mode 100644 index 6eb2272e..00000000 --- a/internal/app/statusbar/waste/waste.go +++ /dev/null @@ -1,527 +0,0 @@ -// Package waste renders the waste indicator in the status bar. -package waste - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/app/statusbar/policytab" - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/app/statusbar/viewkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -const ( - dbTimeout = 2 * time.Second - pollSource = "waste" -) - -type fetchedData struct { - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus -} - -// wasteDetailLoadedMsg carries the result of an async detail fetch. -type wasteDetailLoadedMsg struct { - cat domain.PolicyCategoryStatus - policies []domain.WastePolicy - err error -} - -// Model renders the policy status: pending count, estimated savings, observed savings. -type Model struct { - theme styles.Theme - scope log.Scope - core policytab.Base - - summary domain.AccountSummary - categories []domain.PolicyCategoryStatus - - // Drawer navigation - detail *detail // non-nil when viewing a single category's policies -} - -// New creates a new policy status model. -func New(theme styles.Theme, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("waste"), - core: policytab.New(pollSource), - } -} - -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return m.core.SetDB(db) -} - -// Init starts polling. -func (m *Model) Init() tea.Cmd { - return m.core.Init() -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - if cmd, handled := policytab.UpdatePoll(&m.core, - msg, - m.fetchData(), - func(data fetchedData) { - key := m.buildStateKey(data.summary, data.categories) - m.core.ApplyIfChanged(key, len(data.categories), func() { - m.summary = data.summary - m.categories = data.categories - m.core.SetHasData(len(data.categories) > 0 || data.summary.PendingPolicyCount+data.summary.ApprovedPolicyCount+data.summary.DismissedPolicyCount > 0) - }) - }, - ); handled { - return cmd - } - - switch msg := msg.(type) { - case wasteDetailLoadedMsg: - if msg.err == nil { - m.detail = newDetail(m.theme, msg.cat, msg.policies) - } - - default: - _ = msg - } - - return nil -} - -// fetchData returns a Cmd that queries waste data off the event loop. -func (m *Model) fetchData() tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - scope.Error("get summary", "err", err) - return fetchedData{}, err - } - - categories, err := db.LogEventPolicyCategoryStatuses().ListWasteCategoryStatuses(ctx) - if err != nil { - scope.Error("list waste category statuses", "err", err) - categories = nil - } - - return fetchedData{summary: summary, categories: categories}, nil - }) -} - -// fetchDetail returns a Cmd that queries category detail off the event loop. -func (m *Model) fetchDetail(cat domain.PolicyCategoryStatus) tea.Cmd { - db := m.core.DB() - scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.WastePolicy, error) { - policies, err := db.LogEventPolicyStatuses().ListTopPendingPoliciesByCategory(ctx, cat.Category, 25) - if err != nil { - scope.Error("list top pending policies", "category", cat.Category, "err", err) - return nil, err - } - return policies, nil - }, func(policies []domain.WastePolicy, err error) tea.Msg { - if err != nil { - return wasteDetailLoadedMsg{err: err} - } - return wasteDetailLoadedMsg{cat: cat, policies: policies} - }) -} - -// buildStateKey builds a string key for change detection. -func (m *Model) buildStateKey(s domain.AccountSummary, cats []domain.PolicyCategoryStatus) string { - key := wasteStateKey{ - Summary: wasteSummaryKey{ - ReadyForUse: s.ReadyForUse, - EventCount: s.EventCount, - AnalyzedCount: s.AnalyzedCount, - PendingPolicyCount: s.PendingPolicyCount, - ApprovedPolicyCount: s.ApprovedPolicyCount, - DismissedPolicyCount: s.DismissedPolicyCount, - EstimatedCostPerHour: s.EstimatedCostPerHour, - EstimatedCostPerHourB: s.EstimatedCostPerHourBytes, - EstimatedCostPerHourVol: s.EstimatedCostPerHourVolume, - EstimatedVolumePerHour: s.EstimatedVolumePerHour, - EstimatedBytesPerHour: s.EstimatedBytesPerHour, - ObservedCostBefore: s.ObservedCostBefore, - ObservedCostAfter: s.ObservedCostAfter, - ObservedVolumeBefore: s.ObservedVolumeBefore, - ObservedVolumeAfter: s.ObservedVolumeAfter, - TotalCostPerHour: s.TotalCostPerHour, - TotalVolumePerHour: s.TotalVolumePerHour, - TotalBytesPerHour: s.TotalBytesPerHour, - }, - Categories: make([]wasteCategoryKey, 0, len(cats)), - } - for _, c := range cats { - key.Categories = append(key.Categories, wasteCategoryKey{ - Category: string(c.Category), - PendingCount: c.PendingCount, - ApprovedCount: c.ApprovedCount, - DismissedCount: c.DismissedCount, - EstimatedVolumePerHour: c.EstimatedVolumePerHour, - EstimatedBytesPerHour: c.EstimatedBytesPerHour, - EstimatedCostPerHour: c.EstimatedCostPerHour, - EventsWithVolumes: c.EventsWithVolumes, - TotalEvents: c.TotalEvents, - }) - } - data, err := json.Marshal(key) - if err != nil { - return "" - } - return string(data) -} - -type wasteStateKey struct { - Summary wasteSummaryKey `json:"summary"` - Categories []wasteCategoryKey `json:"categories"` -} - -type wasteSummaryKey struct { - ReadyForUse bool `json:"ready_for_use"` - EventCount int64 `json:"event_count"` - AnalyzedCount int64 `json:"analyzed_count"` - PendingPolicyCount int64 `json:"pending_policy_count"` - ApprovedPolicyCount int64 `json:"approved_policy_count"` - DismissedPolicyCount int64 `json:"dismissed_policy_count"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EstimatedCostPerHourB *float64 `json:"estimated_cost_per_hour_bytes"` - EstimatedCostPerHourVol *float64 `json:"estimated_cost_per_hour_volume"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - ObservedCostBefore *float64 `json:"observed_cost_before"` - ObservedCostAfter *float64 `json:"observed_cost_after"` - ObservedVolumeBefore *float64 `json:"observed_volume_before"` - ObservedVolumeAfter *float64 `json:"observed_volume_after"` - TotalCostPerHour *float64 `json:"total_cost_per_hour"` - TotalVolumePerHour *float64 `json:"total_volume_per_hour"` - TotalBytesPerHour *float64 `json:"total_bytes_per_hour"` -} - -type wasteCategoryKey struct { - Category string `json:"category"` - PendingCount int64 `json:"pending_count"` - ApprovedCount int64 `json:"approved_count"` - DismissedCount int64 `json:"dismissed_count"` - EstimatedVolumePerHour *float64 `json:"estimated_volume_per_hour"` - EstimatedBytesPerHour *float64 `json:"estimated_bytes_per_hour"` - EstimatedCostPerHour *float64 `json:"estimated_cost_per_hour"` - EventsWithVolumes int64 `json:"events_with_volumes"` - TotalEvents int64 `json:"total_events"` -} - -// HasData returns true when policy data has been loaded. -func (m *Model) HasData() bool { - return m.core.HasData() -} - -// HandleKeyPress handles keyboard navigation in the expanded drawer view. -func (m *Model) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { - return m.navController().HandleKeyPress(msg) -} - -// InDetail returns true when the detail sub-view is active. -func (m *Model) InDetail() bool { - return m.detail != nil -} - -// CloseDetail exits the detail sub-view, returning to the category list. -func (m *Model) CloseDetail() { - m.detail = nil -} - -func (m *Model) navController() listdetail.Controller { - return m.core.NavController( - func() int { return len(m.categories) }, - func(index int) tea.Cmd { - cat := m.categories[index] - if cat.PendingCount == 0 { - return nil - } - return m.fetchDetail(cat) - }, - func() listdetail.Detail { return m.detail }, - func() { m.detail = nil }, - ) -} - -// CompactView renders the policy status for the statusbar. -func (m *Model) CompactView() string { - if !m.core.HasData() { - return "" - } - - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var segments []string - - // Observed savings from approved policies (always shown — these are measured). - if saving := formatObservedSaving(s); saving != "" { - savingStyle := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - segments = append(segments, savingStyle.Render("saving "+saving)) - } - - if s.AnalysisReady() { - // Waste percentage with pending count. - if wp := wastePercent(s); wp > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - waste := fmt.Sprintf("%d%% waste", wp) - if s.PendingPolicyCount > 0 { - waste += fmt.Sprintf(" (%d)", s.PendingPolicyCount) - } - segments = append(segments, dot+" "+muted.Render(waste)) - } else if s.PendingPolicyCount > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - segments = append(segments, dot+" "+muted.Render(fmt.Sprintf("%d policies", s.PendingPolicyCount))) - } - } else if s.EventCount > 0 { - // Analysis still in progress — show progress instead of waste %. - segments = append(segments, muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(segments) == 0 { - dot := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg).Render("●") - return dot + " " + muted.Render("healthy") - } - - return strings.Join(segments, sep) -} - -// wastePercent computes the estimated waste as a percentage of total bytes. -func wastePercent(s domain.AccountSummary) int { - if s.TotalBytesPerHour != nil && *s.TotalBytesPerHour > 0 && - s.EstimatedBytesPerHour != nil && *s.EstimatedBytesPerHour > 0 { - return int(math.Round(*s.EstimatedBytesPerHour / *s.TotalBytesPerHour * 100)) - } - return 0 -} - -// ExpandedView renders the detailed policy status for the drawer. -func (m *Model) ExpandedView(width, height int) string { - if !m.core.HasData() { - return viewkit.RenderPolicyEmptyState( - m.theme, - m.core.DB() != nil, - m.summary, - "Enable services to start detecting waste.", - "No waste detected. Your logs look clean.", - ) - } - - // Detail sub-view for a single category. - if m.detail != nil { - return m.detail.View(width) - } - - return viewkit.ComposeSummaryTableView( - m.theme, - m.renderWasteHeadline(), - m.renderCategoryTable(width), - m.cursorPrinciple(), - ) -} - -// renderWasteHeadline renders the waste summary: waste % and pending count. -func (m *Model) renderWasteHeadline() string { - s := m.summary - colors := m.theme - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sep := muted.Render(" · ") - - var parts []string - - // Observed savings from approved policies (always shown — these are measured). - if saving := formatObservedSaving(s); saving != "" { - savingStyle := lipgloss.NewStyle().Foreground(colors.Success).Background(colors.Bg) - parts = append(parts, savingStyle.Render("saving "+saving)) - } - - // Pending count + waste %. Count first for consistency with other tabs. - if s.PendingPolicyCount > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - pending := dot + " " + text.Render(fmt.Sprintf("%d policies", s.PendingPolicyCount)) - if wp := wastePercent(s); wp > 0 { - pending += sep + text.Render(fmt.Sprintf("%d%% waste", wp)) - } - parts = append(parts, pending) - } else if wp := wastePercent(s); wp > 0 { - dot := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg).Render("●") - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - parts = append(parts, dot+" "+text.Render(fmt.Sprintf("%d%% waste", wp))) - } - - // Analysis progress when not yet ready. - if s.EventCount > 0 && !s.AnalysisReady() { - pct := float64(s.AnalyzedCount) / float64(s.EventCount) - bar := m.analysisBar() - parts = append(parts, bar.ViewAs(pct)+" "+muted.Render(fmt.Sprintf("%d/%d analyzed", s.AnalyzedCount, s.EventCount))) - } - - if len(parts) == 0 { - return muted.Render("All policies reviewed") - } - - return strings.Join(parts, sep) -} - -// renderCategoryTable renders all waste categories in a single table with cursor highlighting. -func (m *Model) renderCategoryTable(width int) string { - if len(m.categories) == 0 { - return "" - } - - tbl := table.New(m.theme, table.WithMaxValueWidth(35)) - tbl.Headers("Category", "Pending", "Impact", "Approved") - tbl.SetWidth(width) - - warn := lipgloss.NewStyle().Foreground(m.theme.Warning).Background(m.theme.Bg) - ok := lipgloss.NewStyle().Foreground(m.theme.Success).Background(m.theme.Bg) - accent := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.Bg) - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg) - bar := m.discoveryBar() - - var totalCost float64 - if m.summary.EstimatedCostPerHour != nil { - totalCost = *m.summary.EstimatedCostPerHour - } - - // Find widest pending count for alignment. - maxPendingW := 1 - for _, c := range m.categories { - if w := len(format.Count(c.PendingCount)); w > maxPendingW { - maxPendingW = w - } - } - - for i, c := range m.categories { - dot := ok.Render("●") - if c.PendingCount > 0 { - dot = warn.Render("●") - } - - name := c.Name() - if i == m.core.Cursor() { - name = accent.Render("▶ " + name) - } else { - name = dot + " " + name - } - - // Clean categories: single checkmark row. - if c.PendingCount == 0 && c.ApprovedCount == 0 { - tbl.Row(name, ok.Render("✓"), "—", "—") - continue - } - - // Pending count with optional discovery progress bar. - pending := fmt.Sprintf("%-*s", maxPendingW, format.Count(c.PendingCount)) - if c.TotalEvents > 0 { - pct := int(c.EventsWithVolumes * 100 / c.TotalEvents) - if pct < 80 { - pending += " " + bar.ViewAs(float64(pct)/100) + " " + muted.Render(fmt.Sprintf("%d%%", pct)) - } - } - - tbl.Row( - name, - pending, - formatCategoryCost(c, totalCost, ok, muted), - format.Count(c.ApprovedCount), - ) - } - - return tbl.View() -} - -// analysisBar creates a small progress bar for inline use in the headline. -func (m *Model) analysisBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// discoveryBar creates a small progress bar for inline use in table cells. -func (m *Model) discoveryBar() progress.Model { - bar := progress.New( - progress.WithColors(m.theme.GradientStart, m.theme.GradientEnd), - progress.WithWidth(10), - progress.WithFillCharacters('█', '░'), - ) - bar.ShowPercentage = false - bar.EmptyColor = m.theme.TextMuted - return bar -} - -// formatCategoryCost returns estimated yearly cost for a category, with its -// share of total estimated waste. Shows dollar amount + percentage for -// categories ≥1% of total waste, just "<1%" for tiny categories. -func formatCategoryCost(c domain.PolicyCategoryStatus, totalCostPerHour float64, success, muted lipgloss.Style) string { - if c.EstimatedCostPerHour == nil || *c.EstimatedCostPerHour <= 0 { - return format.YearlyCostPtr(c.EstimatedCostPerHour) - } - - if totalCostPerHour > 0 { - pct := int(math.Round(*c.EstimatedCostPerHour / totalCostPerHour * 100)) - if pct <= 1 { - return muted.Render("≤1%") - } - cost := success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) - if pct < 100 { - cost += " " + muted.Render(fmt.Sprintf("(%d%%)", pct)) - } - return cost - } - - return success.Render(format.YearlyCostPtr(c.EstimatedCostPerHour)) -} - -// cursorPrinciple returns the principle text for the currently selected category. -func (m *Model) cursorPrinciple() string { - if m.core.Cursor() < len(m.categories) { - return m.categories[m.core.Cursor()].Principle - } - return "" -} - -// formatObservedSaving returns the observed savings from approved policies. -func formatObservedSaving(s domain.AccountSummary) string { - if s.ObservedCostBefore != nil && s.ObservedCostAfter != nil { - diff := *s.ObservedCostBefore - *s.ObservedCostAfter - if diff > 0 { - return format.YearlyCost(diff) - } - return "" - } - if s.ObservedVolumeBefore != nil && s.ObservedVolumeAfter != nil { - diff := *s.ObservedVolumeBefore - *s.ObservedVolumeAfter - if diff > 0 { - return format.Volume(diff) + " evt/hr" - } - } - return "" -} diff --git a/internal/app/statusbar/waste/waste_test.go b/internal/app/statusbar/waste/waste_test.go deleted file mode 100644 index faf8340d..00000000 --- a/internal/app/statusbar/waste/waste_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package waste - -import ( - "testing" - - "github.com/usetero/cli/internal/app/statusbar/tabpoll" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdatePollSourceFiltering(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { - t.Fatalf("expected foreign poll source to be ignored") - } - - if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { - t.Fatalf("expected own poll source to schedule fetch") - } -} From 6e60a3d9515dee50641b0011654081571d0516e2 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 8 Jun 2026 14:43:17 -0400 Subject: [PATCH 02/20] docs: scope CLI PowerSync removal Plan to drop PowerSync and make the CLI a thin GraphQL client (reads via direct GraphQL queries, writes via inline mutations). Control plane has moved off PowerSync; CLI synced schema is stale. Option A (stateless) confirmed. --- docs/plans/drop-powersync.md | 203 +++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/plans/drop-powersync.md diff --git a/docs/plans/drop-powersync.md b/docs/plans/drop-powersync.md new file mode 100644 index 00000000..fb1825be --- /dev/null +++ b/docs/plans/drop-powersync.md @@ -0,0 +1,203 @@ +# Plan: Drop PowerSync from the CLI + +Status: **proposal / scoping** (no code yet). Target: dedicated branch, separate +from `clay-mirror-new-ui-surfaces`. + +## Why + +The control plane has moved off PowerSync. Migration +`20260424084500_remove_status_cache_tables.sql` (control-plane, 2026-04-24) +drops `datadog_account_statuses_cache`, `service_statuses_cache`, +`log_event_statuses_cache`, and `issue_statuses_cache` — described in the +migration itself as *"the former PowerSync materialization tables"* — and routes +status reads through canonical Postgres views instead. + +The CLI's synced schema (`internal/sqlite/schema.sql`, +`internal/powersync/extension/schema.json`) was last regenerated **2026-02-25** +and is still built entirely around those dropped `*_cache` tables. So the CLI is +syncing a schema the control plane no longer maintains. PowerSync is effectively +dead weight for reads today. + +This dovetails with the original task (adopt the latest control-plane APIs): +the new first-class entities — `issues`, `checks`, `edgeInstances` — are exposed +over **GraphQL**, not PowerSync. Moving the CLI's reads to direct GraphQL both +removes the stale sync engine and picks up the new APIs in one motion. + +Aligns with `CLAUDE.md`: *"CLI is presentation only,"* local data is +*"queried (CLI)"*. The traditional CLI commands already query GraphQL directly; +this brings the TUI in line. + +## What PowerSync actually does in the CLI today + +The local `sqlite.DB` **is** a PowerSync-extension-loaded SQLite connection +(`internal/sqlite/database.go` loads `sqlite3_powersync_init`). PowerSync plays +three roles through that connection: + +1. **Local store / schema** — the synced tables and views are PowerSync-managed. +2. **Download (read path)** — replicates control plane → local SQLite. The + status bar, onboarding, and chat history all read these tables. +3. **Upload outbox (write path)** — local writes through PowerSync views are + captured into the `ps_crud` queue. The `internal/upload` uploader drains + `ps_crud` and translates each entry into a **GraphQL mutation** + (`conversationHandler`→`graphql.Conversations`, `messageHandler`→message + mutations, `policyHandler`→`graphql.Policies`, `serviceHandler`→ + `graphql.Services`). + +Key insight: the upload **transport is already GraphQL**. PowerSync is only the +local outbox queue in front of it. The read path, by contrast, depends wholly on +PowerSync replication. + +## The one decision that shapes everything: local store or stateless? + +Because `sqlite.DB` is the PowerSync extension, removing PowerSync forces a +choice about the local store: + +- **Option A — Stateless (direct GraphQL, no local DB).** TUI reads issue + GraphQL queries on demand; chat renders from in-memory state and persists via + GraphQL mutations. Delete the local SQLite store entirely. Simplest end state, + best alignment with "CLI is presentation only," but changes chat's optimistic + persistence model and gives up offline/local cache. +- **Option B — Plain SQLite cache + custom outbox.** Keep a local SQLite (no + PowerSync extension) as a read cache and write outbox; populate it from + GraphQL queries and drain a hand-rolled outbox to GraphQL mutations. Preserves + offline behavior but re-implements a meaningful slice of PowerSync. + +**Decision: Option A (confirmed 2026-06-08).** The control plane is the source +of truth, the CLI is presentation, and the GraphQL client + mutations already +exist. Resolved gating questions: + +- **Offline: not required.** No local cache needed; safe to delete the local DB. +- **Chat history: available over GraphQL.** Dropping local SQLite loses no + history — conversations/messages are served by the control plane. +- **Edge instances: in scope.** Wire `edgeInstances` GraphQL in phase 1 + alongside issues/checks (no longer a deferred stub). + +Optimistic chat UI is preserved with in-memory state reconciled on mutation +success/failure. Option B is off the table. + +The rest of this plan assumes **Option A**. + +## Consumer inventory (18 non-test importers) → replacement + +| Area | Files | Uses PowerSync for | Replacement under Option A | +|---|---|---|---| +| Lifecycle wiring | `internal/cmd/root.go`, `internal/cmd/internal_powersync.go` | creates `Syncer`, wires uploader | delete syncer/uploader wiring; keep GraphQL services | +| App shell | `internal/app/app.go` | holds `syncer`, injects into statusbar/onboarding | remove `syncer` field + injections | +| Status bar | `internal/app/statusbar/statusbar.go`, `syncstatus/*` | sync dot + sync-error toasts | remove `syncstatus` entirely (no sync to show) | +| Sync events | `internal/app/events/sync.go` | `SyncStateChanged` etc. | remove | +| Onboarding | `internal/app/onboarding/onboarding.go`, `onboarding/sync/{model,update,view}.go` | "waiting for first sync" gate (`IsReady`) | replace with an initial GraphQL fetch / readiness check, or drop the gate | +| Uploader | `internal/upload/*` (uploader, handlers) | drains `ps_crud` → GraphQL | replace queue-drain with **synchronous GraphQL mutations** at write sites; keep handler→mutation mapping logic | +| Local DB | `internal/sqlite/*` | PowerSync-backed SQLite + generated read/write surfaces | reads → GraphQL queries; remove write surfaces + extension load | +| Schema gen | `internal/sqlite/generate/main.go`, `internal/powersync/extension/generate/main.go` | reflect PowerSync schema | delete | +| Boundary | `internal/boundary/powersync/*`, `internal/powersync/**` | the whole engine | delete | +| Test helpers | `powersynctest`, `messagelisttest` | mock syncer | delete / simplify | + +## Read-path migration (CP → GraphQL queries) + +Map each synced read surface to a GraphQL query. Existing services +(`internal/boundary/graphql/*_service.go`) cover some; the rich status/summary +reads need **new** operations against the control plane's current schema. + +| Today (synced SQLite) | GraphQL replacement | Exists? | +|---|---|---| +| `Conversations()`, `Messages()` | `conversation_service.go`, `message_service.go` | ✅ mostly | +| `Services()` | `service_service.go` | ✅ | +| `LogEvents()`, `LogEventPolicies()` | `policy_service.go` (extend) | ⚠️ partial | +| `DatadogAccountStatuses().GetSummary()` | `account` query (canonical view) | ❌ new op | +| `ServiceStatuses()`, `LogEventStatuses()` | `services`/`logEvents` status fields | ❌ new op | +| `LogEventPolicyCategoryStatuses()` (the "Checks" surface) | `checks` query (`Check`, `CheckConnection`) | ❌ new op | +| *(new)* Issues surface | `issues` query (`Issue`, `IssueConnection`) | ❌ new op | +| *(new)* Edge instances surface | `edgeInstances` query | ❌ new op | + +Action: regenerate the genqlient schema mirror +(`internal/boundary/graphql/gen/schema.graphql`) against the current control +plane — it is missing `Issue`, `Check`, `IssueConnection` — then add the queries +above. The status-bar `surfaces.go` `fetch*` functions get re-pointed from +`db.*` to the GraphQL services (with the CLAUDE.md caveat that tab update/render +paths stay non-blocking — fetch in `tea.Cmd`, which `tabpoll` already does). + +## Write-path migration (ps_crud outbox → synchronous mutations) + +Today: `m.db.Conversations().Create()` / `Messages().Create*()` write through +PowerSync views → captured in `ps_crud` → uploader → GraphQL mutation. + +Under Option A: write sites call the GraphQL mutation directly (in a `tea.Cmd`), +keep optimistic in-memory rendering, and reconcile on success/failure. The +handler→mutation logic in `internal/upload/*_handler.go` is reusable — it just +moves from "queue consumer" to "called inline." Write sites to convert: + +- `internal/app/chat/input_flow.go` (conversation + user message create) +- `internal/app/chat/usecase/{tool_loop,assistant_persistence}.go` (assistant / + tool-result messages) +- `internal/app/chattools/setserviceenabled.go` (service patch) +- policy approve/dismiss flows (`policyHandler` equivalent) + +Risk: lose the automatic retry/queue durability PowerSync provided. Mitigate +with explicit retry + error toasts (the uploader already had a retry policy we +can lift). + +## Deletion inventory (once reads/writes are migrated) + +- `internal/powersync/**` (engine, syncer, stream capture, crud queue) +- `internal/boundary/powersync/**` (admin API client) +- `internal/upload/**` (replaced by inline mutations) +- `internal/app/statusbar/syncstatus/**`, `internal/app/events/sync.go` +- `internal/app/onboarding/sync/**` (or repurpose as a generic loading gate) +- PowerSync-specific SQLite: extension load in `database.go`, `schema.sql`, + `internal/powersync/extension/**`, `internal/sqlite/generate/**` +- `*_cache` read surfaces in `internal/sqlite/` and their `gen/` SQL +- Config: `PowerSyncEndpoint`, `POWERSYNC_API_TOKEN`, embedded extension binary + +## Sequencing (phased, each phase shippable) + +1. **Schema refresh + new queries.** Regenerate genqlient against current CP; add + `issues`/`checks`/`account-summary`/`edgeInstances` queries + domain types. + No deletions yet. (This is also the original "adopt new APIs" work.) +2. **Reads → GraphQL.** Re-point `surfaces.go` + status bar + onboarding reads to + the new GraphQL services. PowerSync still running but reads no longer depend + on synced tables. Verify parity. +3. **Writes → mutations.** Convert chat/policy/service write sites to inline + GraphQL mutations with optimistic UI + retry. Stop relying on `ps_crud`. +4. **Remove the engine.** Delete `internal/powersync`, `internal/upload`, + `boundary/powersync`, syncstatus, sync onboarding, PowerSync SQLite substrate, + config, embedded extension. Drop the local DB (or swap to plain SQLite if we + reverse the Option A decision). +5. **Cleanup.** Docs (`architecture/data-flow.md`, `domains/statusbar.md`), + `CLAUDE.md` code-location table, dead test helpers. + +## Principle: aggregation is always server-side + +**Always read aggregates from the control-plane GraphQL APIs; the CLI must not +compute them client-side.** Confirmed 2026-06-08, and the control plane supports +this. This follows `CLAUDE.md` ("intelligence lives in the control plane", +"CLI is presentation only") and removes drift between CLI and webapp numbers. + +Concrete implication for the current mirror work: `surfaces.go` today computes +several aggregates locally and these must be replaced by server-provided fields +in phase 2 — + +- `fetchIssues`: `high = PolicyPendingCriticalCount + PolicyPendingHighCount` + → read a server `high`/severity rollup. +- `fetchChecks`: `len(waste)+len(quality)+len(compliance)`, `categorySummary` + pending sums, `pendingCategorySignal` high + cost sums → read `checks` + aggregate fields. +- `fetchLogEvents`: coverage percentage `analyzed/total` → read server coverage. +- `fetchPolicies`: total fallback summing pending/approved/dismissed → read the + server total. + +## Open questions + +Resolved (2026-06-08): offline **not required**; chat history **available over +GraphQL**; edge instances **in scope** for phase 1; **aggregation is always +server-side** (see principle above) — so phase 2 is a straight re-point to +server aggregates, no client-side aggregation. + +None remaining that block starting phase 1. + +## Testing + +- Phase 2/3: snapshot/parity tests comparing GraphQL-sourced view state against + the previous synced-SQLite values where both exist. +- Keep behavior-first tests (`operations/testing.md`); mock the GraphQL services, + not a DB. +- Manual `/verify` of chat compose/persist + status bar after each phase. From 41c0704b9a1ade034a20db666ff4cad4cecfe527 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 8 Jun 2026 15:13:38 -0400 Subject: [PATCH 03/20] chore: refresh GraphQL schema mirror; record CLI API migration findings Regenerate gen/schema.graphql against the running control plane (was ~2 months stale). Surfaces the breaking changes driving the PowerSync removal: - chat (conversations/messages) is gone from the control-plane GraphQL; chat is now ephemeral/in-memory - policy approve/dismiss moved to the Issue model (ignoreIssue, createLogEventPolicy) - updateService -> setServiceEnabled; workspaces query removed - issues/checks/edgeInstances now first-class GraphQL entities NOTE: generated.go is intentionally NOT regenerated yet. genqlient is all-or-nothing across operations, so the client regen is done together with the operation + consumer migration (tracked) to keep the tree green. Re-run 'task generate:client' as part of that step (control plane must be up). --- docs/plans/drop-powersync.md | 25 + internal/boundary/graphql/gen/schema.graphql | 12629 ++++++++++------- 2 files changed, 7543 insertions(+), 5111 deletions(-) diff --git a/docs/plans/drop-powersync.md b/docs/plans/drop-powersync.md index fb1825be..847da15f 100644 --- a/docs/plans/drop-powersync.md +++ b/docs/plans/drop-powersync.md @@ -77,6 +77,31 @@ success/failure. Option B is off the table. The rest of this plan assumes **Option A**. +## Schema bump findings (2026-06-08, regenerated mirror) + +Regenerating `gen/schema.graphql` against the live control plane (it was ~2 +months stale) surfaced breaking changes — the current operations no longer +validate: + +| Existing CLI op | Status in current control-plane schema | +|---|---| +| `createConversation` / `updateConversation` / `deleteConversation` | **removed** — chat is not a control-plane GraphQL concern | +| `createMessage` | **removed** | +| `approveLogEventPolicy` / `dismissLogEventPolicy` | **removed** → policy lifecycle moved to the Issue model (`ignoreIssue`, `createLogEventPolicy`) | +| `updateService` | renamed → `setServiceEnabled` | +| `workspaces` (query) | **removed** | + +Consequence: the **current uploader is already broken** against the live +control plane (pushes `createConversation`/`createMessage` mutations that no +longer exist). Rebuilding the data layer is not optional cleanup. + +**Chat is ephemeral (decided 2026-06-08).** Conversation/message history is no +longer persisted across sessions — in-memory during a session is enough. So: +delete all chat persistence (conversation/message GraphQL ops, uploader +handlers, sqlite conversations/messages tables and query surfaces); chat state +lives in-memory and streams via `internal/boundary/chat`. This removes the last +thing that needed a persistent local store and locks **Option A**. + ## Consumer inventory (18 non-test importers) → replacement | Area | Files | Uses PowerSync for | Replacement under Option A | diff --git a/internal/boundary/graphql/gen/schema.graphql b/internal/boundary/graphql/gen/schema.graphql index 809b1854..be415946 100644 --- a/internal/boundary/graphql/gen/schema.graphql +++ b/internal/boundary/graphql/gen/schema.graphql @@ -24,25 +24,20 @@ directive @specifiedBy( url: String! ) on SCALAR -type Account implements Node { +type Account { """Unique identifier of the account""" id: ID! """Parent organization this account belongs to""" organizationID: ID! - """ - Denormalized for PowerSync. Auto-set via trigger from organization.workos_organization_id. - """ - workosOrganizationID: String! - """Human-readable name within the organization""" name: String! """ - Multiplier applied to volume data via trigger. 1 = real data, >1 = scaled for demos. + Short account key used in display IDs. Auto-set from name when omitted. """ - demoScaleFactor: Int! + displayKey: String """When the account was created""" createdAt: Time! @@ -53,777 +48,905 @@ type Account implements Node { """Organization this account belongs to""" organization: Organization! - """Services that produce telemetry""" - services: [Service!] - - """Purpose-aligned workspaces for telemetry evaluation""" - workspaces: [Workspace!] - """Datadog integration configuration""" datadogAccount: DatadogAccount + """Services that produce telemetry""" + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! + """Edge instances that sync policies from this account""" - edgeInstances: [EdgeInstance!] + edgeInstances(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeInstanceOrder, where: EdgeInstanceWhereInput): EdgeInstanceConnection! """API keys for edge instance authentication""" - edgeAPIKeys: [EdgeApiKey!] + edgeAPIKeys(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeApiKeyOrder, where: EdgeApiKeyWhereInput): EdgeApiKeyConnection! } -"""A connection to a list of items.""" type AccountConnection { - """A list of edges.""" - edges: [AccountEdge] - - """Information to aid in pagination.""" + edges: [AccountEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type AccountEdge { - """The item at the end of the edge.""" - node: Account +"""Account creation input.""" +input AccountCreateInput { + """Parent organization this account belongs to""" + organizationID: ID! + + """Human-readable name within the organization""" + name: String! + + """ + Short account key used in display IDs. Auto-set from name when omitted. + """ + displayKey: String +} - """A cursor for use in pagination.""" +type AccountEdge { + node: Account! cursor: Cursor! } -"""Ordering options for Account connections""" input AccountOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order Accounts.""" field: AccountOrderField! } -"""Properties by which Account connections can be ordered.""" enum AccountOrderField { + ID NAME + DISPLAY_KEY CREATED_AT UPDATED_AT } -""" -AccountWhereInput is used for filtering Account objects. -Input was generated by ent. -""" +"""Account update input.""" +input AccountUpdateInput { + """Human-readable name within the organization""" + name: String + + """Clear displayKey.""" + clearDisplayKey: Boolean + + """ + Short account key used in display IDs. Auto-set from name when omitted. + """ + displayKey: String +} + input AccountWhereInput { not: AccountWhereInput and: [AccountWhereInput!] or: [AccountWhereInput!] - """id field predicates""" + """"organization" edge predicates.""" + organization: OrganizationWhereInput + + """Whether the "organization" edge has at least one related row.""" + hasOrganization: Boolean + + """"datadog_account" edge predicates.""" + datadogAccount: DatadogAccountWhereInput + + """Whether the "datadog_account" edge has at least one related row.""" + hasDatadogAccount: Boolean + + """"services" edge predicates.""" + services: ServiceWhereInput + + """Whether the "services" edge has at least one related row.""" + hasServices: Boolean + + """"edge_instances" edge predicates.""" + edgeInstances: EdgeInstanceWhereInput + + """Whether the "edge_instances" edge has at least one related row.""" + hasEdgeInstances: Boolean + + """"edge_api_keys" edge predicates.""" + edgeAPIKeys: EdgeApiKeyWhereInput + + """Whether the "edge_api_keys" edge has at least one related row.""" + hasEdgeAPIKeys: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """organization_id field predicates""" + """"organization_id" field predicates.""" organizationID: ID + + """"organization_id" field predicates.""" organizationIDNEQ: ID + + """"organization_id" field predicates.""" organizationIDIn: [ID!] + + """"organization_id" field predicates.""" organizationIDNotIn: [ID!] - """workos_organization_id field predicates""" - workosOrganizationID: String - workosOrganizationIDNEQ: String - workosOrganizationIDIn: [String!] - workosOrganizationIDNotIn: [String!] - workosOrganizationIDGT: String - workosOrganizationIDGTE: String - workosOrganizationIDLT: String - workosOrganizationIDLTE: String - workosOrganizationIDContains: String - workosOrganizationIDHasPrefix: String - workosOrganizationIDHasSuffix: String - workosOrganizationIDEqualFold: String - workosOrganizationIDContainsFold: String - - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """demo_scale_factor field predicates""" - demoScaleFactor: Int - demoScaleFactorNEQ: Int - demoScaleFactorIn: [Int!] - demoScaleFactorNotIn: [Int!] - demoScaleFactorGT: Int - demoScaleFactorGTE: Int - demoScaleFactorLT: Int - demoScaleFactorLTE: Int - - """created_at field predicates""" + """"display_key" field predicates.""" + displayKey: String + + """"display_key" field predicates.""" + displayKeyNEQ: String + + """"display_key" field predicates.""" + displayKeyIn: [String!] + + """"display_key" field predicates.""" + displayKeyNotIn: [String!] + + """"display_key" field predicates.""" + displayKeyGT: String + + """"display_key" field predicates.""" + displayKeyGTE: String + + """"display_key" field predicates.""" + displayKeyLT: String + + """"display_key" field predicates.""" + displayKeyLTE: String + + """"display_key" field predicates.""" + displayKeyContains: String + + """"display_key" field predicates.""" + displayKeyHasPrefix: String + + """"display_key" field predicates.""" + displayKeyHasSuffix: String + + """"display_key" field predicates.""" + displayKeyIsNil: Boolean + + """"display_key" field predicates.""" + displayKeyNotNil: Boolean + + """"display_key" field predicates.""" + displayKeyEqualFold: String + + """"display_key" field predicates.""" + displayKeyContainsFold: String + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time +} - """organization edge predicates""" - hasOrganization: Boolean - hasOrganizationWith: [OrganizationWhereInput!] +""" +Captures a clearly exposed separate peer materially involved in a recurring log event. +""" +type AttributionProfile { + """How the durable peer label is known, when a label is clear.""" + peerLabel: AttributionProfilePeerLabel - """services edge predicates""" - hasServices: Boolean - hasServicesWith: [ServiceWhereInput!] + """The coarse operational role of the peer.""" + peerRole: PeerRole - """workspaces edge predicates""" - hasWorkspaces: Boolean - hasWorkspacesWith: [WorkspaceWhereInput!] + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKind: String - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] + """The coarse side of the peer relative to the current service.""" + boundarySide: BoundarySide +} - """edge_instances edge predicates""" - hasEdgeInstances: Boolean - hasEdgeInstancesWith: [EdgeInstanceWhereInput!] +"""How the durable peer label is known, when a label is clear.""" +type AttributionProfilePeerLabel { + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + exact: String - """edge_api_keys edge predicates""" - hasEdgeAPIKeys: Boolean - hasEdgeAPIKeysWith: [EdgeApiKeyWhereInput!] + """ + The observed field path whose whole value is the peer label when the label may vary by record. + """ + path: AttributionProfilePeerLabelPath } """ -A content block in a message. Exactly one of the typed fields is set based on type. +The observed field path whose whole value is the peer label when the label may vary by record. """ -type ContentBlock { - type: ContentBlockType! - text: TextBlock - thinking: ThinkingBlock - toolUse: ToolUse - toolResult: ToolResult +type AttributionProfilePeerLabelPath { + """The exact observed field path.""" + path: [String!]! + + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } """ -A content block in a message. Exactly one of the typed fields should be set. +Captures a clearly exposed separate peer materially involved in a recurring log event. """ -input ContentBlockInput { - type: ContentBlockType! - text: TextBlockInput - thinking: ThinkingBlockInput - toolUse: ToolUseInput - toolResult: ToolResultInput -} +input AttributionProfileWhereInput { + """Negated predicates.""" + not: AttributionProfileWhereInput -"""The type of content block.""" -enum ContentBlockType { - text - thinking - tool_use - tool_result -} + """Predicates that must all match.""" + and: [AttributionProfileWhereInput!] -type Conversation implements Node { - """Unique identifier""" - id: ID! + """Predicates where at least one must match.""" + or: [AttributionProfileWhereInput!] + + """Whether the attribution_profile JSONB value is present.""" + present: Boolean """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + A normalized durable peer-family label when the event family is intrinsically about one stable peer. """ - accountID: UUID! + peerLabelExact: String - """Workspace this conversation belongs to""" - workspaceID: ID! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactNEQ: String - """If set, this conversation is for iterating on a specific view""" - viewID: ID + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactIn: [String!] - """WorkOS user ID who owns this conversation""" - userID: String! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactNotIn: [String!] - """AI-generated title, set after first exchange""" - title: String + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactContains: String - """When the conversation was created""" - createdAt: Time! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactHasPrefix: String - """When the conversation was last updated""" - updatedAt: Time! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactHasSuffix: String + + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactEqualFold: String - """Workspace this conversation belongs to""" - workspace: Workspace! + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + peerLabelExactContainsFold: String - """Messages in this conversation""" - messages: [Message!] + """ + A normalized durable peer-family label when the event family is intrinsically about one stable peer. + """ + hasPeerLabelExact: Boolean - """Active entity context set""" - conversationContexts: [ConversationContext!] + """The coarse operational role of the peer.""" + peerRole: PeerRole - """Views created in this conversation""" - views: [View!] + """The coarse operational role of the peer.""" + peerRoleNEQ: PeerRole - """View being iterated on, if this is a view iteration conversation""" - view: View -} + """The coarse operational role of the peer.""" + peerRoleIn: [PeerRole!] -"""A connection to a list of items.""" -type ConversationConnection { - """A list of edges.""" - edges: [ConversationEdge] + """The coarse operational role of the peer.""" + peerRoleNotIn: [PeerRole!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKind: String - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindNEQ: String -type ConversationContext implements Node { - """Unique identifier""" - id: ID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindIn: [String!] """ - Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - accountID: UUID! + peerKindNotIn: [String!] + + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindContains: String - """Conversation this context belongs to""" - conversationID: ID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindHasPrefix: String """ - Type of the context entity. service: an application producing logs, log_event: a specific event pattern. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - entityType: ConversationContextEntityType! + peerKindHasSuffix: String - """ID of the context entity""" - entityID: UUID! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + peerKindEqualFold: String """ - Who added this entity to context. user: added via @-reference, assistant: added by AI during chat. + Sparse lower_snake_case operational kind for the peer when directly clear and useful. """ - addedBy: ConversationContextAddedBy! + peerKindContainsFold: String - """When the entity was added to context""" - createdAt: Time! + """ + Sparse lower_snake_case operational kind for the peer when directly clear and useful. + """ + hasPeerKind: Boolean + + """The coarse side of the peer relative to the current service.""" + boundarySide: BoundarySide + + """The coarse side of the peer relative to the current service.""" + boundarySideNEQ: BoundarySide + + """The coarse side of the peer relative to the current service.""" + boundarySideIn: [BoundarySide!] - """Conversation this context belongs to""" - conversation: Conversation! + """The coarse side of the peer relative to the current service.""" + boundarySideNotIn: [BoundarySide!] } -"""ConversationContextAddedBy is enum for the field added_by""" -enum ConversationContextAddedBy { - user - assistant +enum BoundarySide { + """The peer is another same-estate service or internal boundary.""" + internal + + """The peer initiates or sends work into the emitting service.""" + upstream + + """ + The emitting service calls, queries, publishes to, or depends on the peer. + """ + downstream } -"""A connection to a list of items.""" -type ConversationContextConnection { - """A list of edges.""" - edges: [ConversationContextEdge] +"""One code-defined standing product question and its account posture.""" +type Check { + id: ID! + domain: FindingCheckDomain! + type: FindingCheckType! + name: String! + description: String! + posture: CheckPosture! +} - """Information to aid in pagination.""" +type CheckConnection { + edges: [CheckEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! + facets: CheckFacets! +} + +type CheckDomainFacet { + buckets: [CheckDomainFacetBucket!]! } -"""An edge in a connection.""" -type ConversationContextEdge { - """The item at the end of the edge.""" - node: ConversationContext +type CheckDomainFacetBucket { + value: FindingCheckDomain! + count: Int! +} - """A cursor for use in pagination.""" +type CheckEdge { + node: Check! cursor: Cursor! } -"""ConversationContextEntityType is enum for the field entity_type""" -enum ConversationContextEntityType { - service - log_event +type CheckFacets { + domains(limit: Int): CheckDomainFacet! } -"""Ordering options for ConversationContext connections""" -input ConversationContextOrder { - """The ordering direction.""" +"""Ordering for the checks connection.""" +input CheckOrder { + field: CheckOrderField! = NAME direction: OrderDirection! = ASC - - """The field by which to order ConversationContexts.""" - field: ConversationContextOrderField! } -"""Properties by which ConversationContext connections can be ordered.""" -enum ConversationContextOrderField { - CREATED_AT +"""Fields supported when ordering checks.""" +enum CheckOrderField { + NAME + DOMAIN + OPEN_FINDING_COUNT + ACTIVE_ISSUE_COUNT + AFFECTED_SERVICE_COUNT + LATEST_OBSERVED_AT + CURRENT_COST } """ -ConversationContextWhereInput is used for filtering ConversationContext objects. -Input was generated by ent. +Account-scoped posture for one check, computed from findings and issues. """ -input ConversationContextWhereInput { - not: ConversationContextWhereInput - and: [ConversationContextWhereInput!] - or: [ConversationContextWhereInput!] - - """id field predicates""" +type CheckPosture { + openFindingCount: Int! + pendingFindingCount: Int! + escalatedFindingCount: Int! + activeIssueCount: Int! + affectedServiceCount: Int! + latestObservedAt: Time + current: StatusMeasurementTotals! + previewSavings: StatusMeasurementTotals! +} + +"""Filters applied to the code-defined check catalog.""" +input CheckWhereInput { + not: CheckWhereInput + and: [CheckWhereInput!] + or: [CheckWhereInput!] id: ID - idNEQ: ID idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + domain: FindingCheckDomain + type: FindingCheckType + search: String +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +type ComplianceCountMeasurement { + value: Int! + asOf: Time! + delta: MeasurementDelta +} - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """entity_type field predicates""" - entityType: ConversationContextEntityType - entityTypeNEQ: ConversationContextEntityType - entityTypeIn: [ConversationContextEntityType!] - entityTypeNotIn: [ConversationContextEntityType!] - - """entity_id field predicates""" - entityID: UUID - entityIDNEQ: UUID - entityIDIn: [UUID!] - entityIDNotIn: [UUID!] - entityIDGT: UUID - entityIDGTE: UUID - entityIDLT: UUID - entityIDLTE: UUID - - """added_by field predicates""" - addedBy: ConversationContextAddedBy - addedByNEQ: ConversationContextAddedBy - addedByIn: [ConversationContextAddedBy!] - addedByNotIn: [ConversationContextAddedBy!] - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +type ComplianceLeakHighlightRange { + startOffset: Int! + endOffset: Int! +} - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] +type ComplianceLeakPreview { + logEventID: ID! + logEventName: String! + exposureKind: FindingCheckType! + element: String! + path: [String!]! + pathFamily: [String!]! + emissionTemplate: String! + displayValue: String + highlightRanges: [ComplianceLeakHighlightRange!]! } -"""An edge in a connection.""" -type ConversationEdge { - """The item at the end of the edge.""" - node: Conversation +"""Finding detail payload for payment-data-exposure findings.""" +type CompliancePaymentDataExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """A cursor for use in pagination.""" - cursor: Cursor! + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String + + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePaymentDataExposureExposures!]! } -"""Ordering options for Conversation connections""" -input ConversationOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePaymentDataExposureExposures { + """Specific payment data element exposed at this path.""" + element: String! - """The field by which to order Conversations.""" - field: ConversationOrderField! + """The exact exposed event path.""" + path: CompliancePaymentDataExposureExposuresPath! } -"""Properties by which Conversation connections can be ordered.""" -enum ConversationOrderField { - CREATED_AT - UPDATED_AT +"""The exact exposed event path.""" +type CompliancePaymentDataExposureExposuresPath { + """The exact event field path.""" + path: [String!]! + + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -""" -ConversationWhereInput is used for filtering Conversation objects. -Input was generated by ent. -""" -input ConversationWhereInput { - not: ConversationWhereInput - and: [ConversationWhereInput!] - or: [ConversationWhereInput!] +"""Finding detail payload for PHI-exposure findings.""" +type CompliancePHIExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePHIExposureExposures!]! +} - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] - - """view_id field predicates""" - viewID: ID - viewIDNEQ: ID - viewIDIn: [ID!] - viewIDNotIn: [ID!] - viewIDIsNil: Boolean - viewIDNotNil: Boolean - - """user_id field predicates""" - userID: String - userIDNEQ: String - userIDIn: [String!] - userIDNotIn: [String!] - userIDGT: String - userIDGTE: String - userIDLT: String - userIDLTE: String - userIDContains: String - userIDHasPrefix: String - userIDHasSuffix: String - userIDEqualFold: String - userIDContainsFold: String - - """title field predicates""" - title: String - titleNEQ: String - titleIn: [String!] - titleNotIn: [String!] - titleGT: String - titleGTE: String - titleLT: String - titleLTE: String - titleContains: String - titleHasPrefix: String - titleHasSuffix: String - titleIsNil: Boolean - titleNotNil: Boolean - titleEqualFold: String - titleContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePHIExposureExposures { + """Specific PHI element exposed at this path.""" + element: String! - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """The exact exposed event path.""" + path: CompliancePHIExposureExposuresPath! +} - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] +"""The exact exposed event path.""" +type CompliancePHIExposureExposuresPath { + """The exact event field path.""" + path: [String!]! - """messages edge predicates""" - hasMessages: Boolean - hasMessagesWith: [MessageWhereInput!] + """The grouping path for sibling fields.""" + pathFamily: [String!]! +} - """conversation_contexts edge predicates""" - hasConversationContexts: Boolean - hasConversationContextsWith: [ConversationContextWhereInput!] +"""Finding detail payload for PII-exposure findings.""" +type CompliancePIIExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! - """views edge predicates""" - hasViews: Boolean - hasViewsWith: [ViewWhereInput!] + """ + Stable emission template when this finding is grouped around the raw body payload. + """ + emissionTemplate: String - """view edge predicates""" - hasView: Boolean - hasViewWith: [ViewWhereInput!] + """Exact exposed paths and their sensitive data elements.""" + exposures: [CompliancePIIExposureExposures!]! } -""" -CreateAccountInput is used for create Account object. -Input was generated by ent. -""" -input CreateAccountInput { - """Human-readable name within the organization""" - name: String! - organizationID: ID! - datadogAccountID: ID +"""Exact exposed paths and their sensitive data elements.""" +type CompliancePIIExposureExposures { + """Specific PII element exposed at this path.""" + element: String! - """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. - """ - id: ID + """The exact exposed event path.""" + path: CompliancePIIExposureExposuresPath! } -""" -CreateConversationInput is used for create Conversation object. -Input was generated by ent. -""" -input CreateConversationInput { - """AI-generated title, set after first exchange""" - title: String - workspaceID: ID! - viewID: ID +"""The exact exposed event path.""" +type CompliancePIIExposureExposuresPath { + """The exact event field path.""" + path: [String!]! - """ - Optional client-provided UUID for offline-first sync. - If provided and a conversation with this ID exists, returns the existing record. - """ - id: ID + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -""" -CreateDatadogAccountInput is used for create DatadogAccount object. -Input was generated by ent. -""" -input CreateDatadogAccountInput { - """Display name for this Datadog account""" - name: String! +type ComplianceReport { + summary: ComplianceReportSummary! +} +input ComplianceReportFilterInput { """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Restrict the report to these services. Applies to every summary measurement. """ - site: DatadogAccountSite! + serviceIDs: [UUID!] """ - Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - ($0.10/GB). Set to override with actual contract rate. + Restrict the report to services owned by these teams. Applies to every summary measurement. """ - costPerGBIngested: Float + teamIDs: [UUID!] + + """Restrict the report to these compliance finding check types.""" + issueCheckTypes: [FindingCheckType!] +} + +input ComplianceReportInput { + filter: ComplianceReportFilterInput +} + +type ComplianceReportSummary { + """Services with unresolved compliance issues.""" + affectedServices: ComplianceCountMeasurement! """ - Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - 80%). Leaves headroom for the customer's own API usage. + Exposed sensitive or regulated fields across unresolved compliance issues. """ - rateLimitUtilization: Float - accountID: ID! + fieldsExposed: ComplianceCountMeasurement! + + """Log events affected by unresolved compliance issues.""" + affectedEvents: ComplianceCountMeasurement! +} + +"""Finding detail payload for secret-exposure findings.""" +type ComplianceSecretExposure { + """Path family this finding is grouped around.""" + pathFamily: [String!]! """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + Stable emission template when this finding is grouped around the raw body payload. """ - id: ID + emissionTemplate: String + + """Exact exposed paths and their sensitive data elements.""" + exposures: [ComplianceSecretExposureExposures!]! } -input CreateDatadogAccountWithCredentialsInput { - attributes: CreateDatadogAccountInput! - credentials: CreateDatadogCredentialsInput! +"""Exact exposed paths and their sensitive data elements.""" +type ComplianceSecretExposureExposures { + """Specific secret element exposed at this path.""" + element: String! + + """The exact exposed event path.""" + path: ComplianceSecretExposureExposuresPath! } -input CreateDatadogCredentialsInput { - apiKey: String! - appKey: String! +"""The exact exposed event path.""" +type ComplianceSecretExposureExposuresPath { + """The exact event field path.""" + path: [String!]! + + """The grouping path for sibling fields.""" + pathFamily: [String!]! } -input CreateEdgeApiKeyInput { - """ - Optional client-provided ID for idempotent creates. - If provided and already exists, returns the existing key (without plain key). - """ - id: ID +type CostAnnualizedMoneyMeasurement { + amount: MoneyAmount! + asOf: Time! + delta: MeasurementDelta +} - """The account this API key authenticates to""" - accountID: ID! +type CostAnnualizedOpportunityMoneyMeasurement { + amount: MoneyAmount! + percentOfSpend: PercentAmount! + asOf: Time! + delta: MeasurementDelta +} - """User-provided name for this key (e.g., 'Production Collector')""" - name: String! +"""Annualized money amount for items observed during a concrete period.""" +type CostAnnualizedPeriodMoneyMeasurement { + amount: MoneyAmount! + period: TimeRange! + delta: MeasurementDelta } -type CreateEdgeApiKeyResult { - """The created API key record (without the secret)""" - edgeApiKey: EdgeApiKey! +"""Finding detail payload for background-noise findings.""" +type CostBackgroundNoise { + """Named peer label involved in the noisy interaction when one is known.""" + peerLabel: String + + """Coarse role of the peer involved in the noisy interaction.""" + peerRole: String! """ - The plain API key - only returned once at creation time. - Store this securely, it cannot be retrieved again. + Coarse kind of peer involved in the noisy interaction when one is known. """ - plainKey: String! + peerKind: String } -""" -CreateMessageInput is used for create Message object. -Input was generated by ent. -""" -input CreateMessageInput { - """ - Who sent this message. user: human-originated, assistant: AI-originated. - """ - role: MessageRole! +"""Finding detail payload for commodity-traffic findings.""" +type CostCommodityTraffic { + """Log event name identified as commodity traffic.""" + eventName: String! +} - """ - Why the assistant stopped generating. end_turn: completed response, tool_use: - paused to call a tool. Null for user messages. - """ - stopReason: MessageStopReason +"""Finding detail payload for configuration-noise findings.""" +type CostConfigurationNoise { + """Log event name identified as repeated configuration noise.""" + eventName: String! - """AI model that produced this message. Null for user messages.""" - model: String - conversationID: ID! + """Configuration-related failure kind associated with the event.""" + failureKind: String! +} - """ - Optional client-provided UUID for offline-first sync. - If provided and a message with this ID exists, returns the existing record. - """ - id: ID +"""Finding detail payload for dead-weight findings.""" +type CostDeadWeight { + """Log event name identified as dead weight.""" + eventName: String! +} - """Array of typed content blocks.""" - content: [ContentBlockInput!]! +"""Finding detail payload for debug-artifact findings.""" +type CostDebugArtifacts { + """Log event name identified as a debug artifact.""" + eventName: String! } -""" -CreateOrganizationInput is used for create Organization object. -Input was generated by ent. -""" -input CreateOrganizationInput { - """Human-readable name, unique across the system""" - name: String! +"""Finding detail payload for debug-marker findings.""" +type CostDebugMarker { + """Log event name identified as a debug marker.""" + eventName: String! +} - """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. - """ - id: ID +"""Finding detail payload for debug-noise findings.""" +type CostDebugNoise { + """Service that owns the debug-noise log event.""" + serviceName: String! + + """Debug-noise log event name.""" + eventName: String! } -""" -CreateTeamInput is used for create Team object. -Input was generated by ent. -""" -input CreateTeamInput { - """Human-readable name within the workspace""" - name: String! - workspaceID: ID! +"""Finding detail payload for hot-path findings.""" +type CostHotPath { + """Stable emission template associated with the hot path event.""" + emissionTemplate: String! +} + +"""Finding detail payload for reactive-flood findings.""" +type CostReactiveFlood { + """Stable operation associated with the reactive flood behavior.""" + operation: String! + + """Durable outcome associated with the reactive flood behavior.""" + outcome: String! """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + Named peer label involved in the reactive interaction when one is known. """ - id: ID + peerLabel: String } -""" -CreateViewFavoriteInput is used for create ViewFavorite object. -Input was generated by ent. -""" -input CreateViewFavoriteInput { - viewID: ID! +type CostReport { + summary: CostReportSummary! +} +input CostReportFilterInput { """ - Optional client-provided UUID for offline-first sync. - If provided and a favorite with this ID exists, returns the existing record. + Restrict the report to these services. Applies to every summary measurement. """ - id: ID -} + serviceIDs: [UUID!] -""" -CreateViewInput is used for create View object. -Input was generated by ent. -""" -input CreateViewInput { """ - Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. + Restrict the report to services owned by these teams. Applies to every summary measurement. """ - entityType: ViewEntityType! - - """Raw SQL query executed against the client's local SQLite database""" - query: String! - messageID: ID! - conversationID: ID! - forkedFromID: ID - forkIDs: [ID!] + teamIDs: [UUID!] """ - Optional client-provided UUID for offline-first sync. - If provided and a view with this ID exists, returns the existing record. + Restrict issue-derived measurements to these finding check types. + Does not restrict annualized service spend. """ - id: ID + issueCheckTypes: [FindingCheckType!] } -""" -CreateWorkspaceInput is used for create Workspace object. -Input was generated by ent. -""" -input CreateWorkspaceInput { - """Human-readable name within the account""" - name: String! +input CostReportInput { + filter: CostReportFilterInput +} + +type CostReportSummary { + """Current service spend projected over one year.""" + annualizedSpend: CostAnnualizedMoneyMeasurement! """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. + Annualized savings from cost issues resolved during the last 12 months. """ - purpose: WorkspacePurpose + savedLast12Months: CostAnnualizedPeriodMoneyMeasurement! + + """Annualized savings available from unresolved cost issues.""" + potentialSavings: CostAnnualizedOpportunityMoneyMeasurement! +} + +"""Finding detail payload for routine-system-chatter findings.""" +type CostRoutineSystemChatter { + """Service that owns the routine chatter log event.""" + serviceName: String! + + """Routine chatter log event name.""" + eventName: String! +} + +input CreateEdgeApiKeyInput { + """The account this API key authenticates to""" accountID: ID! + """User-provided name for this key (e.g., 'Production Collector')""" + name: String! +} + +type CreateEdgeApiKeyResult { + """The created API key record (without the secret)""" + edgeApiKey: EdgeApiKey! + """ - Optional client-provided UUID for offline-first sync. - If provided and a record with this ID exists, returns the existing record. + The plain API key - only returned once at creation time. + Store this securely, it cannot be retrieved again. """ - id: ID + plainKey: String! } -""" -Define a Relay Cursor type: -https://relay.dev/graphql/connections.htm#sec-Cursor -""" scalar Cursor -type DatadogAccount implements Node { +type DatadogAccount { """Unique identifier of the Datadog configuration""" id: ID! @@ -834,9 +957,9 @@ type DatadogAccount implements Node { name: String! """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. """ site: DatadogAccountSite! @@ -861,57 +984,143 @@ type DatadogAccount implements Node { """Account this Datadog configuration belongs to""" account: Account! - """Discovered log indexes in this Datadog account""" - logIndexes: [DatadogLogIndex!] - """ - Status of this Datadog account in the discovery pipeline. + Status of this Datadog account in the catalog pipeline. Derived from the status of all services discovered from this account. - Returns null if cache has not been populated yet. + Returns null if the status view has no row yet. + """ + status: DatadogAccountStatus + + """ + Bucketed Datadog account log usage split into indexed and non-indexed + volume, with estimated savings for blocking non-indexed logs at the edge. """ - status: DatadogAccountStatusCache + logUsage(input: DatadogAccountLogUsageInput!): DatadogAccountLogUsageSeries! } -"""A connection to a list of items.""" type DatadogAccountConnection { - """A list of edges.""" - edges: [DatadogAccountEdge] - - """Information to aid in pagination.""" + edges: [DatadogAccountEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type DatadogAccountEdge { - """The item at the end of the edge.""" - node: DatadogAccount +"""DatadogAccount creation input.""" +input DatadogAccountCreateInput { + """Parent account this configuration belongs to""" + accountID: ID! + + """Display name for this Datadog account""" + name: String! + + """ + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + """ + site: DatadogAccountSite! + + """Datadog API credentials. Stored in the secret store, not Postgres.""" + credentials: DatadogAccountCredentialsInput! +} - """A cursor for use in pagination.""" +"""Datadog credentials for account creation.""" +input DatadogAccountCredentialsInput { + apiKey: String! + appKey: String! +} + +type DatadogAccountCurrentStatus { + services: StatusServiceTotals! + totals: StatusMeasurementTotals! +} + +type DatadogAccountEdge { + node: DatadogAccount! cursor: Cursor! } -"""Ordering options for DatadogAccount connections""" +"""Usage attributed to one Datadog index during the requested range.""" +type DatadogAccountLogUsageIndexTotal { + datadogIndex: String! + events: Float! + bytes: Float! + shareOfTotalEvents: Float! + shareOfTotalBytes: Float! +} + +input DatadogAccountLogUsageInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! +} + +"""One Datadog account usage point.""" +type DatadogAccountLogUsagePoint { + start: Time! + end: Time! + totalEvents: Float! + totalBytes: Float! + indexedEvents: Float! + indexedBytes: Float! + nonIndexedEvents: Float! + nonIndexedBytes: Float! + excludedEvents: Float! + estimatedEdgeSavingsBytes: Float! + estimatedEdgeSavingsUsd: Float! + quality: MeasurementQuality! +} + +""" +Datadog account log usage summarized for cost and policy migration views. +""" +type DatadogAccountLogUsageSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [DatadogAccountLogUsagePoint!]! + totals: DatadogAccountLogUsageTotals! + indexTotals: [DatadogAccountLogUsageIndexTotal!]! + costPerGBIngested: Float! +} + +""" +Datadog account log usage totals over a requested range. +Non-indexed bytes are inferred from excluded events and the hourly average bytes +per ingested event, because Datadog does not expose excluded bytes directly. +""" +type DatadogAccountLogUsageTotals { + totalEvents: Float! + totalBytes: Float! + indexedEvents: Float! + indexedBytes: Float! + nonIndexedEvents: Float! + nonIndexedBytes: Float! + excludedEvents: Float! + estimatedEdgeSavingsBytes: Float! + estimatedEdgeSavingsUsd: Float! + projectedMonthlyEdgeSavingsBytes: Float! + projectedMonthlyEdgeSavingsUsd: Float! +} + input DatadogAccountOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order DatadogAccounts.""" field: DatadogAccountOrderField! } -"""Properties by which DatadogAccount connections can be ordered.""" enum DatadogAccountOrderField { + ID NAME SITE CREATED_AT UPDATED_AT COST_PER_GB_INGESTED + LOG_INDEX_INVENTORY_REFRESHED_AT + LOG_USAGE_REFRESHED_AT } -"""DatadogAccountSite is enum for the field site""" enum DatadogAccountSite { US1 US3 @@ -922,16 +1131,16 @@ enum DatadogAccountSite { AP2 } -type DatadogAccountStatusCache implements Node { - id: ID! - datadogAccountID: ID! - accountID: UUID! +type DatadogAccountStatus { + health: StatusHealth! + readiness: StatusReadiness! + coverage: DatadogAccountStatusCoverage! + current: DatadogAccountCurrentStatus! + preview: StatusScenario! + effective: StatusScenario! +} - """ - Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - """ - health: DatadogAccountStatusCacheHealth! - readyForUse: Boolean! +type DatadogAccountStatusCoverage { logEventAnalyzedCount: Int! logServiceCount: Int! logActiveServices: Int! @@ -939,1111 +1148,4336 @@ type DatadogAccountStatusCache implements Node { inactiveServices: Int! okServices: Int! logEventCount: Int! - policyPendingCount: Int! - policyApprovedCount: Int! - policyDismissedCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - serviceVolumePerHour: Float - serviceCostPerHourVolumeUsd: Float - logEventVolumePerHour: Float - logEventBytesPerHour: Float - logEventCostPerHourBytesUsd: Float - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourUsd: Float - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! + previewLogEventCount: Int! + effectiveLogEventCount: Int! } -"""DatadogAccountStatusCacheHealth is enum for the field health""" -enum DatadogAccountStatusCacheHealth { - DISABLED - INACTIVE - ERROR - OK +"""DatadogAccount update input.""" +input DatadogAccountUpdateInput { + """Display name for this Datadog account""" + name: String + + """ + Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + """ + site: DatadogAccountSite + + """Clear costPerGBIngested.""" + clearCostPerGBIngested: Boolean + + """ + Cost per GB of log data ingested (USD). NULL = using Datadog's published rate + ($0.10/GB). Set to override with actual contract rate. + """ + costPerGBIngested: Float + + """Clear rateLimitUtilization.""" + clearRateLimitUtilization: Boolean + + """ + Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = + 80%). Leaves headroom for the customer's own API usage. + """ + rateLimitUtilization: Float } -""" -DatadogAccountStatusCacheWhereInput is used for filtering DatadogAccountStatusCache objects. -Input was generated by ent. -""" -input DatadogAccountStatusCacheWhereInput { - not: DatadogAccountStatusCacheWhereInput - and: [DatadogAccountStatusCacheWhereInput!] - or: [DatadogAccountStatusCacheWhereInput!] +input DatadogAccountWhereInput { + not: DatadogAccountWhereInput + and: [DatadogAccountWhereInput!] + or: [DatadogAccountWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """"account_id" field predicates.""" + accountID: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID - - """health field predicates""" - health: DatadogAccountStatusCacheHealth - healthNEQ: DatadogAccountStatusCacheHealth - healthIn: [DatadogAccountStatusCacheHealth!] - healthNotIn: [DatadogAccountStatusCacheHealth!] - - """ready_for_use field predicates""" - readyForUse: Boolean - readyForUseNEQ: Boolean - - """log_event_analyzed_count field predicates""" - logEventAnalyzedCount: Int - logEventAnalyzedCountNEQ: Int - logEventAnalyzedCountIn: [Int!] - logEventAnalyzedCountNotIn: [Int!] - logEventAnalyzedCountGT: Int - logEventAnalyzedCountGTE: Int - logEventAnalyzedCountLT: Int - logEventAnalyzedCountLTE: Int - - """log_service_count field predicates""" - logServiceCount: Int - logServiceCountNEQ: Int - logServiceCountIn: [Int!] - logServiceCountNotIn: [Int!] - logServiceCountGT: Int - logServiceCountGTE: Int - logServiceCountLT: Int - logServiceCountLTE: Int - - """log_active_services field predicates""" - logActiveServices: Int - logActiveServicesNEQ: Int - logActiveServicesIn: [Int!] - logActiveServicesNotIn: [Int!] - logActiveServicesGT: Int - logActiveServicesGTE: Int - logActiveServicesLT: Int - logActiveServicesLTE: Int - - """disabled_services field predicates""" - disabledServices: Int - disabledServicesNEQ: Int - disabledServicesIn: [Int!] - disabledServicesNotIn: [Int!] - disabledServicesGT: Int - disabledServicesGTE: Int - disabledServicesLT: Int - disabledServicesLTE: Int - - """inactive_services field predicates""" - inactiveServices: Int - inactiveServicesNEQ: Int - inactiveServicesIn: [Int!] - inactiveServicesNotIn: [Int!] - inactiveServicesGT: Int - inactiveServicesGTE: Int - inactiveServicesLT: Int - inactiveServicesLTE: Int - - """ok_services field predicates""" - okServices: Int - okServicesNEQ: Int - okServicesIn: [Int!] - okServicesNotIn: [Int!] - okServicesGT: Int - okServicesGTE: Int - okServicesLT: Int - okServicesLTE: Int - - """log_event_count field predicates""" - logEventCount: Int - logEventCountNEQ: Int - logEventCountIn: [Int!] - logEventCountNotIn: [Int!] - logEventCountGT: Int - logEventCountGTE: Int - logEventCountLT: Int - logEventCountLTE: Int - - """policy_pending_count field predicates""" - policyPendingCount: Int - policyPendingCountNEQ: Int - policyPendingCountIn: [Int!] - policyPendingCountNotIn: [Int!] - policyPendingCountGT: Int - policyPendingCountGTE: Int - policyPendingCountLT: Int - policyPendingCountLTE: Int - - """policy_approved_count field predicates""" - policyApprovedCount: Int - policyApprovedCountNEQ: Int - policyApprovedCountIn: [Int!] - policyApprovedCountNotIn: [Int!] - policyApprovedCountGT: Int - policyApprovedCountGTE: Int - policyApprovedCountLT: Int - policyApprovedCountLTE: Int - - """policy_dismissed_count field predicates""" - policyDismissedCount: Int - policyDismissedCountNEQ: Int - policyDismissedCountIn: [Int!] - policyDismissedCountNotIn: [Int!] - policyDismissedCountGT: Int - policyDismissedCountGTE: Int - policyDismissedCountLT: Int - policyDismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """service_volume_per_hour field predicates""" - serviceVolumePerHour: Float - serviceVolumePerHourNEQ: Float - serviceVolumePerHourIn: [Float!] - serviceVolumePerHourNotIn: [Float!] - serviceVolumePerHourGT: Float - serviceVolumePerHourGTE: Float - serviceVolumePerHourLT: Float - serviceVolumePerHourLTE: Float - serviceVolumePerHourIsNil: Boolean - serviceVolumePerHourNotNil: Boolean - - """service_cost_per_hour_volume_usd field predicates""" - serviceCostPerHourVolumeUsd: Float - serviceCostPerHourVolumeUsdNEQ: Float - serviceCostPerHourVolumeUsdIn: [Float!] - serviceCostPerHourVolumeUsdNotIn: [Float!] - serviceCostPerHourVolumeUsdGT: Float - serviceCostPerHourVolumeUsdGTE: Float - serviceCostPerHourVolumeUsdLT: Float - serviceCostPerHourVolumeUsdLTE: Float - serviceCostPerHourVolumeUsdIsNil: Boolean - serviceCostPerHourVolumeUsdNotNil: Boolean - - """log_event_volume_per_hour field predicates""" - logEventVolumePerHour: Float - logEventVolumePerHourNEQ: Float - logEventVolumePerHourIn: [Float!] - logEventVolumePerHourNotIn: [Float!] - logEventVolumePerHourGT: Float - logEventVolumePerHourGTE: Float - logEventVolumePerHourLT: Float - logEventVolumePerHourLTE: Float - logEventVolumePerHourIsNil: Boolean - logEventVolumePerHourNotNil: Boolean - - """log_event_bytes_per_hour field predicates""" - logEventBytesPerHour: Float - logEventBytesPerHourNEQ: Float - logEventBytesPerHourIn: [Float!] - logEventBytesPerHourNotIn: [Float!] - logEventBytesPerHourGT: Float - logEventBytesPerHourGTE: Float - logEventBytesPerHourLT: Float - logEventBytesPerHourLTE: Float - logEventBytesPerHourIsNil: Boolean - logEventBytesPerHourNotNil: Boolean - - """log_event_cost_per_hour_bytes_usd field predicates""" - logEventCostPerHourBytesUsd: Float - logEventCostPerHourBytesUsdNEQ: Float - logEventCostPerHourBytesUsdIn: [Float!] - logEventCostPerHourBytesUsdNotIn: [Float!] - logEventCostPerHourBytesUsdGT: Float - logEventCostPerHourBytesUsdGTE: Float - logEventCostPerHourBytesUsdLT: Float - logEventCostPerHourBytesUsdLTE: Float - logEventCostPerHourBytesUsdIsNil: Boolean - logEventCostPerHourBytesUsdNotNil: Boolean - - """log_event_cost_per_hour_volume_usd field predicates""" - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourVolumeUsdNEQ: Float - logEventCostPerHourVolumeUsdIn: [Float!] - logEventCostPerHourVolumeUsdNotIn: [Float!] - logEventCostPerHourVolumeUsdGT: Float - logEventCostPerHourVolumeUsdGTE: Float - logEventCostPerHourVolumeUsdLT: Float - logEventCostPerHourVolumeUsdLTE: Float - logEventCostPerHourVolumeUsdIsNil: Boolean - logEventCostPerHourVolumeUsdNotNil: Boolean - - """log_event_cost_per_hour_usd field predicates""" - logEventCostPerHourUsd: Float - logEventCostPerHourUsdNEQ: Float - logEventCostPerHourUsdIn: [Float!] - logEventCostPerHourUsdNotIn: [Float!] - logEventCostPerHourUsdGT: Float - logEventCostPerHourUsdGTE: Float - logEventCostPerHourUsdLT: Float - logEventCostPerHourUsdLTE: Float - logEventCostPerHourUsdIsNil: Boolean - logEventCostPerHourUsdNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time -} - -""" -DatadogAccountWhereInput is used for filtering DatadogAccount objects. -Input was generated by ent. -""" -input DatadogAccountWhereInput { - not: DatadogAccountWhereInput - and: [DatadogAccountWhereInput!] - or: [DatadogAccountWhereInput!] - - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID - - """account_id field predicates""" - accountID: ID + """"account_id" field predicates.""" accountIDNEQ: ID + + """"account_id" field predicates.""" accountIDIn: [ID!] + + """"account_id" field predicates.""" accountIDNotIn: [ID!] - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """site field predicates""" + """"site" field predicates.""" site: DatadogAccountSite + + """"site" field predicates.""" siteNEQ: DatadogAccountSite + + """"site" field predicates.""" siteIn: [DatadogAccountSite!] + + """"site" field predicates.""" siteNotIn: [DatadogAccountSite!] - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time - """cost_per_gb_ingested field predicates""" - costPerGBIngested: Float - costPerGBIngestedNEQ: Float - costPerGBIngestedIn: [Float!] - costPerGBIngestedNotIn: [Float!] - costPerGBIngestedGT: Float - costPerGBIngestedGTE: Float - costPerGBIngestedLT: Float - costPerGBIngestedLTE: Float - costPerGBIngestedIsNil: Boolean - costPerGBIngestedNotNil: Boolean - - """rate_limit_utilization field predicates""" - rateLimitUtilization: Float - rateLimitUtilizationNEQ: Float - rateLimitUtilizationIn: [Float!] - rateLimitUtilizationNotIn: [Float!] - rateLimitUtilizationGT: Float - rateLimitUtilizationGTE: Float - rateLimitUtilizationLT: Float - rateLimitUtilizationLTE: Float - rateLimitUtilizationIsNil: Boolean - rateLimitUtilizationNotNil: Boolean - - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAt: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNEQ: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtIn: [Time!] - """log_indexes edge predicates""" - hasLogIndexes: Boolean - hasLogIndexesWith: [DatadogLogIndexWhereInput!] + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNotIn: [Time!] + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtGT: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtGTE: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtLT: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtLTE: Time + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtIsNil: Boolean + + """"services_auto_enabled_at" field predicates.""" + servicesAutoEnabledAtNotNil: Boolean + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAt: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNEQ: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtIn: [Time!] + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNotIn: [Time!] + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtGT: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtGTE: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtLT: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtLTE: Time + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtIsNil: Boolean + + """"log_index_inventory_refreshed_at" field predicates.""" + logIndexInventoryRefreshedAtNotNil: Boolean + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAt: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNEQ: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtIn: [Time!] + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNotIn: [Time!] + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtGT: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtGTE: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtLT: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtLTE: Time + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtIsNil: Boolean + + """"log_usage_refreshed_at" field predicates.""" + logUsageRefreshedAtNotNil: Boolean } -type DatadogLogIndex implements Node { - """Unique identifier for this index record""" +type DatadogLogExclusionFilter { + """Unique identifier""" id: ID! """ - Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. + Denormalized for tenant isolation. Auto-set via trigger from datadog_log_index.account_id. """ accountID: UUID! - """The Datadog account this index belongs to""" - datadogAccountID: ID! + """Issue this Datadog exclusion filter remediates.""" + issueID: ID + + """Log event this Datadog exclusion filter targets.""" + logEventID: ID + + """Datadog log index this exclusion filter applies to.""" + datadogLogIndexID: ID! """ - Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier + Ownership source for this Datadog log exclusion filter. Values: tero = Filter + is managed by Tero in Datadog.; customer = Filter is customer-managed in + Datadog and mirrored by Tero. """ - name: String! + source: DatadogLogExclusionFilterSource! """ - Cost per million events indexed in this index (USD). NULL = using Datadog's - published rate ($1.70/M). SIEM indexes cost more — set accordingly. + Datadog exclusion filter name. Datadog allows duplicate names within one log index. """ - costPerMillionEventsIndexed: Float + filterName: String! - """When this index was first discovered""" - createdAt: Time! + """Approved Datadog log query for this exclusion filter.""" + query: String! - """Last time we saw logs flowing to this index""" - lastSeenAt: Time! + """ + Fraction of matching logs excluded by Datadog. A value of 1 excludes all matching logs. + """ + sampleRate: Float! - """The Datadog account this index belongs to""" - datadogAccount: DatadogAccount! -} + """Whether the exclusion filter should be active in Datadog.""" + isEnabled: Boolean! -"""Ordering options for DatadogLogIndex connections""" -input DatadogLogIndexOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Zero-based position of this exclusion filter inside its Datadog index.""" + position: Int - """The field by which to order DatadogLogIndexes.""" - field: DatadogLogIndexOrderField! -} + """ + Lifecycle state for this Datadog log exclusion filter. Values: pending = + Queued for Datadog application.; applying = Claimed by a worker and being + applied to Datadog.; applied = Applied to the Datadog log index.; failed = + Datadog application failed. + """ + status: DatadogLogExclusionFilterStatus! -"""Properties by which DatadogLogIndex connections can be ordered.""" -enum DatadogLogIndexOrderField { - NAME - CREATED_AT - LAST_SEEN_AT -} + """First inventory refresh where Tero observed this filter in Datadog.""" + detectedAt: Time -""" -DatadogLogIndexWhereInput is used for filtering DatadogLogIndex objects. -Input was generated by ent. -""" -input DatadogLogIndexWhereInput { - not: DatadogLogIndexWhereInput - and: [DatadogLogIndexWhereInput!] - or: [DatadogLogIndexWhereInput!] + """ + Most recent inventory refresh where Tero observed this filter in Datadog. + """ + lastSeenAt: Time - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + First inventory refresh where this previously observed filter was absent from Datadog. + """ + removedAt: Time - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Stable hash of the remote Datadog filter configuration.""" + remoteConfigHash: String - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """ + Import workflow state for this Datadog log exclusion filter. Values: + not_evaluated = Filter has not been evaluated for Tero policy import.; + unsupported = Filter cannot currently be imported as Tero policies.; + proposal_ready = Filter has Tero policy proposals ready.; sample_key_required + = Filter needs a sample key before Tero policy import.; policy_active = + Imported Tero policies are active.; policy_verified = Imported Tero policies + have been verified.; datadog_filter_removal_pending = Original Datadog filter + removal or disablement is pending.; datadog_filter_removed = Original Datadog + filter has been disabled or removed.; rejected = Import was rejected by the + user.; failed = Import workflow failed. + """ + conversionStatus: DatadogLogExclusionFilterConversionStatus! - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """Last policy import conversion error, if conversion failed.""" + conversionError: String - """cost_per_million_events_indexed field predicates""" - costPerMillionEventsIndexed: Float - costPerMillionEventsIndexedNEQ: Float - costPerMillionEventsIndexedIn: [Float!] - costPerMillionEventsIndexedNotIn: [Float!] - costPerMillionEventsIndexedGT: Float - costPerMillionEventsIndexedGTE: Float - costPerMillionEventsIndexedLT: Float - costPerMillionEventsIndexedLTE: Float - costPerMillionEventsIndexedIsNil: Boolean - costPerMillionEventsIndexedNotNil: Boolean + """When imported Tero policies were created for this Datadog filter.""" + convertedAt: Time - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """When imported Tero policies were verified as operating.""" + verifiedAt: Time - """last_seen_at field predicates""" - lastSeenAt: Time - lastSeenAtNEQ: Time - lastSeenAtIn: [Time!] - lastSeenAtNotIn: [Time!] - lastSeenAtGT: Time - lastSeenAtGTE: Time - lastSeenAtLT: Time - lastSeenAtLTE: Time + """ + When the source Datadog filter was disabled or removed after policy verification. + """ + datadogFilterRemovedAt: Time - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] -} + """Last application error, if Datadog application failed.""" + error: String -type EdgeApiKey implements Node { - """Unique identifier of this API key""" - id: ID! + """When Tero last attempted to apply this filter to Datadog.""" + lastAttemptedAt: Time - """The account this API key authenticates to""" - accountID: ID! + """When Datadog last accepted this filter configuration.""" + appliedAt: Time - """User-provided name for this key (e.g., 'Production Collector')""" - name: String! + """When this filter row was created.""" + createdAt: Time! - """First characters of the key for identification (e.g., 'tero_sk_abc1')""" - keyPrefix: String! + """When this filter row was last updated.""" + updatedAt: Time! - """When this key was last used for authentication""" - lastUsedAt: Time + """Issue this Datadog exclusion filter remediates""" + issue: Issue - """When this key was revoked (null if active)""" - revokedAt: Time + """Log event this Datadog exclusion filter targets""" + logEvent: LogEvent - """When this key was created""" - createdAt: Time! + """Datadog log index this exclusion filter applies to""" + datadogLogIndex: DatadogLogIndex! - """The account this API key belongs to""" - account: Account! + """Tero policies imported from this Datadog exclusion filter""" + importedLogEventPolicies: [LogEventPolicy!]! + sampleKey: [String!]! + logEventPolicyProposalSet(input: DatadogLogExclusionFilterPolicyProposalInput): LogEventPolicyProposalSet! } -"""A connection to a list of items.""" -type EdgeApiKeyConnection { - """A list of edges.""" - edges: [EdgeApiKeyEdge] - - """Information to aid in pagination.""" +type DatadogLogExclusionFilterConnection { + edges: [DatadogLogExclusionFilterEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type EdgeApiKeyEdge { - """The item at the end of the edge.""" - node: EdgeApiKey +enum DatadogLogExclusionFilterConversionStatus { + not_evaluated + unsupported + proposal_ready + sample_key_required + policy_active + policy_verified + datadog_filter_removal_pending + datadog_filter_removed + rejected + failed +} - """A cursor for use in pagination.""" +"""DatadogLogExclusionFilter creation input.""" +input DatadogLogExclusionFilterCreateInput { + """Issue this Datadog exclusion filter remediates.""" + issueID: ID + + """Log event this Datadog exclusion filter targets.""" + logEventID: ID + + """Datadog log index this exclusion filter applies to.""" + datadogLogIndexID: ID! + + """Approved Datadog log query for this exclusion filter.""" + query: String! + + """ + Fraction of matching logs excluded by Datadog. A value of 1 excludes all matching logs. + """ + sampleRate: Float + + """Whether the exclusion filter should be active in Datadog.""" + isEnabled: Boolean +} + +type DatadogLogExclusionFilterDraft { + issueID: ID! + logEventID: ID! + datadogLogIndexID: ID! + query: String! + sampleRate: Float + isEnabled: Boolean +} + +type DatadogLogExclusionFilterEdge { + node: DatadogLogExclusionFilter! cursor: Cursor! } -"""Ordering options for EdgeApiKey connections""" -input EdgeApiKeyOrder { - """The ordering direction.""" +input DatadogLogExclusionFilterOrder { direction: OrderDirection! = ASC - - """The field by which to order EdgeApiKeys.""" - field: EdgeApiKeyOrderField! + field: DatadogLogExclusionFilterOrderField! } -"""Properties by which EdgeApiKey connections can be ordered.""" -enum EdgeApiKeyOrderField { - NAME - LAST_USED_AT - REVOKED_AT +enum DatadogLogExclusionFilterOrderField { + ID + SOURCE + POSITION + STATUS + DETECTED_AT + LAST_SEEN_AT + REMOVED_AT + CONVERSION_STATUS + CONVERTED_AT + VERIFIED_AT + DATADOG_FILTER_REMOVED_AT + LAST_ATTEMPTED_AT + APPLIED_AT CREATED_AT + UPDATED_AT } -""" -EdgeApiKeyWhereInput is used for filtering EdgeApiKey objects. -Input was generated by ent. -""" -input EdgeApiKeyWhereInput { - not: EdgeApiKeyWhereInput - and: [EdgeApiKeyWhereInput!] - or: [EdgeApiKeyWhereInput!] +input DatadogLogExclusionFilterPolicyImportInput { + sourceBranchIndexes: [Int!]! + sampleKey: [String!] +} - """id field predicates""" - id: ID +input DatadogLogExclusionFilterPolicyProposalInput { + sampleKey: [String!] +} + +enum DatadogLogExclusionFilterSource { + tero + customer +} + +enum DatadogLogExclusionFilterStatus { + pending + applying + applied + failed +} + +input DatadogLogExclusionFilterWhereInput { + not: DatadogLogExclusionFilterWhereInput + and: [DatadogLogExclusionFilterWhereInput!] + or: [DatadogLogExclusionFilterWhereInput!] + + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"log_event" edge predicates.""" + logEvent: LogEventWhereInput + + """Whether the "log_event" edge has at least one related row.""" + hasLogEvent: Boolean + + """"datadog_log_index" edge predicates.""" + datadogLogIndex: DatadogLogIndexWhereInput + + """Whether the "datadog_log_index" edge has at least one related row.""" + hasDatadogLogIndex: Boolean + + """"imported_log_event_policies" edge predicates.""" + importedLogEventPolicies: LogEventPolicyWhereInput + + """ + Whether the "imported_log_event_policies" edge has at least one related row. + """ + hasImportedLogEventPolicies: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"issue_id" field predicates.""" + issueID: ID - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """"issue_id" field predicates.""" + issueIDNEQ: ID - """key_prefix field predicates""" - keyPrefix: String - keyPrefixNEQ: String - keyPrefixIn: [String!] - keyPrefixNotIn: [String!] - keyPrefixGT: String - keyPrefixGTE: String - keyPrefixLT: String - keyPrefixLTE: String - keyPrefixContains: String - keyPrefixHasPrefix: String - keyPrefixHasSuffix: String - keyPrefixEqualFold: String - keyPrefixContainsFold: String + """"issue_id" field predicates.""" + issueIDIn: [ID!] - """last_used_at field predicates""" - lastUsedAt: Time - lastUsedAtNEQ: Time - lastUsedAtIn: [Time!] - lastUsedAtNotIn: [Time!] - lastUsedAtGT: Time - lastUsedAtGTE: Time - lastUsedAtLT: Time - lastUsedAtLTE: Time - lastUsedAtIsNil: Boolean - lastUsedAtNotNil: Boolean + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] - """revoked_at field predicates""" - revokedAt: Time - revokedAtNEQ: Time - revokedAtIn: [Time!] - revokedAtNotIn: [Time!] - revokedAtGT: Time - revokedAtGTE: Time - revokedAtLT: Time - revokedAtLTE: Time - revokedAtIsNil: Boolean - revokedAtNotNil: Boolean + """"issue_id" field predicates.""" + issueIDIsNil: Boolean - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"issue_id" field predicates.""" + issueIDNotNil: Boolean - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] -} + """"log_event_id" field predicates.""" + logEventID: ID -type EdgeInstance implements Node { - """Unique identifier of this edge instance""" - id: ID! + """"log_event_id" field predicates.""" + logEventIDNEQ: ID - """The account this edge instance belongs to""" - accountID: ID! + """"log_event_id" field predicates.""" + logEventIDIn: [ID!] - """The service.instance.id resource attribute identifying this instance""" - instanceID: String! + """"log_event_id" field predicates.""" + logEventIDNotIn: [ID!] - """The service.name resource attribute""" - serviceName: String! + """"log_event_id" field predicates.""" + logEventIDIsNil: Boolean - """The service.namespace resource attribute""" - serviceNamespace: String + """"log_event_id" field predicates.""" + logEventIDNotNil: Boolean - """The service.version resource attribute""" - serviceVersion: String + """"datadog_log_index_id" field predicates.""" + datadogLogIndexID: ID - """When this edge instance first synced""" - firstSeenAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNEQ: ID - """When this edge instance last synced""" - lastSyncAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDIn: [ID!] - """When this record was created""" - createdAt: Time! + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNotIn: [ID!] - """When this record was last updated""" - updatedAt: Time! + """"source" field predicates.""" + source: DatadogLogExclusionFilterSource - """The account this edge instance belongs to""" - account: Account! -} + """"source" field predicates.""" + sourceNEQ: DatadogLogExclusionFilterSource -"""A connection to a list of items.""" -type EdgeInstanceConnection { - """A list of edges.""" - edges: [EdgeInstanceEdge] + """"source" field predicates.""" + sourceIn: [DatadogLogExclusionFilterSource!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """"source" field predicates.""" + sourceNotIn: [DatadogLogExclusionFilterSource!] - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """"filter_name" field predicates.""" + filterName: String -"""An edge in a connection.""" -type EdgeInstanceEdge { - """The item at the end of the edge.""" - node: EdgeInstance + """"filter_name" field predicates.""" + filterNameNEQ: String - """A cursor for use in pagination.""" - cursor: Cursor! -} + """"filter_name" field predicates.""" + filterNameIn: [String!] -"""Ordering options for EdgeInstance connections""" -input EdgeInstanceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """"filter_name" field predicates.""" + filterNameNotIn: [String!] - """The field by which to order EdgeInstances.""" - field: EdgeInstanceOrderField! -} + """"filter_name" field predicates.""" + filterNameGT: String -"""Properties by which EdgeInstance connections can be ordered.""" -enum EdgeInstanceOrderField { - INSTANCE_ID - SERVICE_NAME - SERVICE_NAMESPACE - SERVICE_VERSION - FIRST_SEEN_AT - LAST_SYNC_AT - CREATED_AT - UPDATED_AT -} + """"filter_name" field predicates.""" + filterNameGTE: String -""" -EdgeInstanceWhereInput is used for filtering EdgeInstance objects. -Input was generated by ent. -""" -input EdgeInstanceWhereInput { - not: EdgeInstanceWhereInput - and: [EdgeInstanceWhereInput!] - or: [EdgeInstanceWhereInput!] + """"filter_name" field predicates.""" + filterNameLT: String - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """"filter_name" field predicates.""" + filterNameLTE: String - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"filter_name" field predicates.""" + filterNameContains: String - """instance_id field predicates""" - instanceID: String - instanceIDNEQ: String - instanceIDIn: [String!] - instanceIDNotIn: [String!] - instanceIDGT: String - instanceIDGTE: String - instanceIDLT: String - instanceIDLTE: String - instanceIDContains: String - instanceIDHasPrefix: String - instanceIDHasSuffix: String - instanceIDEqualFold: String - instanceIDContainsFold: String + """"filter_name" field predicates.""" + filterNameHasPrefix: String - """service_name field predicates""" - serviceName: String - serviceNameNEQ: String - serviceNameIn: [String!] - serviceNameNotIn: [String!] - serviceNameGT: String - serviceNameGTE: String - serviceNameLT: String - serviceNameLTE: String - serviceNameContains: String - serviceNameHasPrefix: String - serviceNameHasSuffix: String - serviceNameEqualFold: String - serviceNameContainsFold: String + """"filter_name" field predicates.""" + filterNameHasSuffix: String - """service_namespace field predicates""" - serviceNamespace: String - serviceNamespaceNEQ: String - serviceNamespaceIn: [String!] - serviceNamespaceNotIn: [String!] - serviceNamespaceGT: String - serviceNamespaceGTE: String - serviceNamespaceLT: String - serviceNamespaceLTE: String - serviceNamespaceContains: String - serviceNamespaceHasPrefix: String - serviceNamespaceHasSuffix: String - serviceNamespaceIsNil: Boolean - serviceNamespaceNotNil: Boolean - serviceNamespaceEqualFold: String - serviceNamespaceContainsFold: String + """"filter_name" field predicates.""" + filterNameEqualFold: String - """service_version field predicates""" - serviceVersion: String - serviceVersionNEQ: String - serviceVersionIn: [String!] - serviceVersionNotIn: [String!] - serviceVersionGT: String - serviceVersionGTE: String - serviceVersionLT: String - serviceVersionLTE: String - serviceVersionContains: String - serviceVersionHasPrefix: String - serviceVersionHasSuffix: String - serviceVersionIsNil: Boolean - serviceVersionNotNil: Boolean - serviceVersionEqualFold: String - serviceVersionContainsFold: String + """"filter_name" field predicates.""" + filterNameContainsFold: String - """first_seen_at field predicates""" - firstSeenAt: Time - firstSeenAtNEQ: Time - firstSeenAtIn: [Time!] - firstSeenAtNotIn: [Time!] - firstSeenAtGT: Time - firstSeenAtGTE: Time - firstSeenAtLT: Time - firstSeenAtLTE: Time + """"sample_rate" field predicates.""" + sampleRate: Float - """last_sync_at field predicates""" - lastSyncAt: Time - lastSyncAtNEQ: Time - lastSyncAtIn: [Time!] - lastSyncAtNotIn: [Time!] - lastSyncAtGT: Time - lastSyncAtGTE: Time - lastSyncAtLT: Time - lastSyncAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateNEQ: Float - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateIn: [Float!] - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """"sample_rate" field predicates.""" + sampleRateNotIn: [Float!] + + """"sample_rate" field predicates.""" + sampleRateGT: Float + + """"sample_rate" field predicates.""" + sampleRateGTE: Float + + """"sample_rate" field predicates.""" + sampleRateLT: Float + + """"sample_rate" field predicates.""" + sampleRateLTE: Float + + """"is_enabled" field predicates.""" + isEnabled: Boolean + + """"is_enabled" field predicates.""" + isEnabledNEQ: Boolean + + """"position" field predicates.""" + position: Int + + """"position" field predicates.""" + positionNEQ: Int + + """"position" field predicates.""" + positionIn: [Int!] + + """"position" field predicates.""" + positionNotIn: [Int!] + + """"position" field predicates.""" + positionGT: Int + + """"position" field predicates.""" + positionGTE: Int + + """"position" field predicates.""" + positionLT: Int + + """"position" field predicates.""" + positionLTE: Int + + """"position" field predicates.""" + positionIsNil: Boolean + + """"position" field predicates.""" + positionNotNil: Boolean + + """"status" field predicates.""" + status: DatadogLogExclusionFilterStatus + + """"status" field predicates.""" + statusNEQ: DatadogLogExclusionFilterStatus + + """"status" field predicates.""" + statusIn: [DatadogLogExclusionFilterStatus!] + + """"status" field predicates.""" + statusNotIn: [DatadogLogExclusionFilterStatus!] + + """"detected_at" field predicates.""" + detectedAt: Time + + """"detected_at" field predicates.""" + detectedAtNEQ: Time + + """"detected_at" field predicates.""" + detectedAtIn: [Time!] + + """"detected_at" field predicates.""" + detectedAtNotIn: [Time!] + + """"detected_at" field predicates.""" + detectedAtGT: Time + + """"detected_at" field predicates.""" + detectedAtGTE: Time + + """"detected_at" field predicates.""" + detectedAtLT: Time + + """"detected_at" field predicates.""" + detectedAtLTE: Time + + """"detected_at" field predicates.""" + detectedAtIsNil: Boolean + + """"detected_at" field predicates.""" + detectedAtNotNil: Boolean + + """"last_seen_at" field predicates.""" + lastSeenAt: Time + + """"last_seen_at" field predicates.""" + lastSeenAtNEQ: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtNotIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtGT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtGTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIsNil: Boolean + + """"last_seen_at" field predicates.""" + lastSeenAtNotNil: Boolean + + """"removed_at" field predicates.""" + removedAt: Time + + """"removed_at" field predicates.""" + removedAtNEQ: Time + + """"removed_at" field predicates.""" + removedAtIn: [Time!] + + """"removed_at" field predicates.""" + removedAtNotIn: [Time!] + + """"removed_at" field predicates.""" + removedAtGT: Time + + """"removed_at" field predicates.""" + removedAtGTE: Time + + """"removed_at" field predicates.""" + removedAtLT: Time + + """"removed_at" field predicates.""" + removedAtLTE: Time + + """"removed_at" field predicates.""" + removedAtIsNil: Boolean + + """"removed_at" field predicates.""" + removedAtNotNil: Boolean + + """"conversion_status" field predicates.""" + conversionStatus: DatadogLogExclusionFilterConversionStatus + + """"conversion_status" field predicates.""" + conversionStatusNEQ: DatadogLogExclusionFilterConversionStatus + + """"conversion_status" field predicates.""" + conversionStatusIn: [DatadogLogExclusionFilterConversionStatus!] + + """"conversion_status" field predicates.""" + conversionStatusNotIn: [DatadogLogExclusionFilterConversionStatus!] + + """"converted_at" field predicates.""" + convertedAt: Time + + """"converted_at" field predicates.""" + convertedAtNEQ: Time + + """"converted_at" field predicates.""" + convertedAtIn: [Time!] + + """"converted_at" field predicates.""" + convertedAtNotIn: [Time!] + + """"converted_at" field predicates.""" + convertedAtGT: Time + + """"converted_at" field predicates.""" + convertedAtGTE: Time + + """"converted_at" field predicates.""" + convertedAtLT: Time + + """"converted_at" field predicates.""" + convertedAtLTE: Time + + """"converted_at" field predicates.""" + convertedAtIsNil: Boolean + + """"converted_at" field predicates.""" + convertedAtNotNil: Boolean + + """"verified_at" field predicates.""" + verifiedAt: Time + + """"verified_at" field predicates.""" + verifiedAtNEQ: Time + + """"verified_at" field predicates.""" + verifiedAtIn: [Time!] + + """"verified_at" field predicates.""" + verifiedAtNotIn: [Time!] + + """"verified_at" field predicates.""" + verifiedAtGT: Time + + """"verified_at" field predicates.""" + verifiedAtGTE: Time + + """"verified_at" field predicates.""" + verifiedAtLT: Time + + """"verified_at" field predicates.""" + verifiedAtLTE: Time + + """"verified_at" field predicates.""" + verifiedAtIsNil: Boolean + + """"verified_at" field predicates.""" + verifiedAtNotNil: Boolean + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAt: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNEQ: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtIn: [Time!] + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNotIn: [Time!] + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtGT: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtGTE: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtLT: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtLTE: Time + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtIsNil: Boolean + + """"datadog_filter_removed_at" field predicates.""" + datadogFilterRemovedAtNotNil: Boolean + + """"last_attempted_at" field predicates.""" + lastAttemptedAt: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNEQ: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtIn: [Time!] + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNotIn: [Time!] + + """"last_attempted_at" field predicates.""" + lastAttemptedAtGT: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtGTE: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtLT: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtLTE: Time + + """"last_attempted_at" field predicates.""" + lastAttemptedAtIsNil: Boolean + + """"last_attempted_at" field predicates.""" + lastAttemptedAtNotNil: Boolean + + """"applied_at" field predicates.""" + appliedAt: Time + + """"applied_at" field predicates.""" + appliedAtNEQ: Time + + """"applied_at" field predicates.""" + appliedAtIn: [Time!] + + """"applied_at" field predicates.""" + appliedAtNotIn: [Time!] + + """"applied_at" field predicates.""" + appliedAtGT: Time + + """"applied_at" field predicates.""" + appliedAtGTE: Time + + """"applied_at" field predicates.""" + appliedAtLT: Time + + """"applied_at" field predicates.""" + appliedAtLTE: Time + + """"applied_at" field predicates.""" + appliedAtIsNil: Boolean + + """"applied_at" field predicates.""" + appliedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +type DatadogLogIndex { + """Unique identifier for this index record""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. + """ + accountID: UUID! + + """The Datadog account this index belongs to""" + datadogAccountID: ID! + + """ + Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier + """ + name: String! + + """ + Cost per million events indexed in this index (USD). NULL = using Datadog's + published rate ($1.70/M). SIEM indexes cost more — set accordingly. + """ + costPerMillionEventsIndexed: Float + + """When this index was first discovered""" + createdAt: Time! + + """Last time we saw logs flowing to this index""" + lastSeenAt: Time! + + """The Datadog account this index belongs to""" + datadogAccount: DatadogAccount! + + """Datadog exclusion filters applied to this index""" + datadogLogExclusionFilters: [DatadogLogExclusionFilter!]! + + """Current Datadog-owned settings for this log index""" + datadogSettings: DatadogLogIndexSettings +} + +type DatadogLogIndexConnection { + edges: [DatadogLogIndexEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type DatadogLogIndexEdge { + node: DatadogLogIndex! + cursor: Cursor! +} + +input DatadogLogIndexOrder { + direction: OrderDirection! = ASC + field: DatadogLogIndexOrderField! +} + +enum DatadogLogIndexOrderField { + ID +} + +type DatadogLogIndexSettings { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from datadog_log_index.account_id. + """ + accountID: UUID! + + """Datadog log index these settings belong to.""" + datadogLogIndexID: ID! + + """Datadog routing query for this index.""" + routingQuery: String + + """ + Zero-based routing order returned by Datadog. Lower order wins when routing filters overlap. + """ + routingOrder: Int + + """Datadog daily indexed-log limit for this index.""" + dailyLimit: Int + + """Datadog daily-limit reset time for this index.""" + dailyLimitResetTime: String + + """UTC offset used for Datadog daily-limit reset time.""" + dailyLimitResetUtcOffset: String + + """Datadog daily-limit warning threshold percentage.""" + dailyLimitWarningThresholdPercentage: Int + + """Whether Datadog reports this index as rate-limited.""" + isRateLimited: Boolean + + """Datadog retention days for this index.""" + numRetentionDays: Int + + """Datadog Flex Logs retention days for this index.""" + numFlexLogsRetentionDays: Int + + """ + Canonical raw Datadog index configuration captured during inventory refresh. + """ + rawConfig: Map! + + """Stable hash of the raw Datadog index configuration.""" + configHash: String! + + """When these settings were last refreshed from Datadog.""" + refreshedAt: Time! + + """When this settings row was last updated.""" + updatedAt: Time! + + """Datadog log index these settings belong to""" + datadogLogIndex: DatadogLogIndex! +} + +input DatadogLogIndexSettingsWhereInput { + not: DatadogLogIndexSettingsWhereInput + and: [DatadogLogIndexSettingsWhereInput!] + or: [DatadogLogIndexSettingsWhereInput!] + + """"datadog_log_index" edge predicates.""" + datadogLogIndex: DatadogLogIndexWhereInput + + """Whether the "datadog_log_index" edge has at least one related row.""" + hasDatadogLogIndex: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexID: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNEQ: ID + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDIn: [ID!] + + """"datadog_log_index_id" field predicates.""" + datadogLogIndexIDNotIn: [ID!] + + """"routing_order" field predicates.""" + routingOrder: Int + + """"routing_order" field predicates.""" + routingOrderNEQ: Int + + """"routing_order" field predicates.""" + routingOrderIn: [Int!] + + """"routing_order" field predicates.""" + routingOrderNotIn: [Int!] + + """"routing_order" field predicates.""" + routingOrderGT: Int + + """"routing_order" field predicates.""" + routingOrderGTE: Int + + """"routing_order" field predicates.""" + routingOrderLT: Int + + """"routing_order" field predicates.""" + routingOrderLTE: Int + + """"routing_order" field predicates.""" + routingOrderIsNil: Boolean + + """"routing_order" field predicates.""" + routingOrderNotNil: Boolean + + """"refreshed_at" field predicates.""" + refreshedAt: Time + + """"refreshed_at" field predicates.""" + refreshedAtNEQ: Time + + """"refreshed_at" field predicates.""" + refreshedAtIn: [Time!] + + """"refreshed_at" field predicates.""" + refreshedAtNotIn: [Time!] + + """"refreshed_at" field predicates.""" + refreshedAtGT: Time + + """"refreshed_at" field predicates.""" + refreshedAtGTE: Time + + """"refreshed_at" field predicates.""" + refreshedAtLT: Time + + """"refreshed_at" field predicates.""" + refreshedAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +input DatadogLogIndexWhereInput { + not: DatadogLogIndexWhereInput + and: [DatadogLogIndexWhereInput!] + or: [DatadogLogIndexWhereInput!] + + """"datadog_account" edge predicates.""" + datadogAccount: DatadogAccountWhereInput + + """Whether the "datadog_account" edge has at least one related row.""" + hasDatadogAccount: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"datadog_settings" edge predicates.""" + datadogSettings: DatadogLogIndexSettingsWhereInput + + """Whether the "datadog_settings" edge has at least one related row.""" + hasDatadogSettings: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: UUID + + """"account_id" field predicates.""" + accountIDNEQ: UUID + + """"account_id" field predicates.""" + accountIDIn: [UUID!] + + """"account_id" field predicates.""" + accountIDNotIn: [UUID!] + + """"account_id" field predicates.""" + accountIDGT: UUID + + """"account_id" field predicates.""" + accountIDGTE: UUID + + """"account_id" field predicates.""" + accountIDLT: UUID + + """"account_id" field predicates.""" + accountIDLTE: UUID + + """"datadog_account_id" field predicates.""" + datadogAccountID: ID + + """"datadog_account_id" field predicates.""" + datadogAccountIDNEQ: ID + + """"datadog_account_id" field predicates.""" + datadogAccountIDIn: [ID!] + + """"datadog_account_id" field predicates.""" + datadogAccountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexed: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNEQ: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedIn: [Float!] + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNotIn: [Float!] + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedGT: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedGTE: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedLT: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedLTE: Float + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedIsNil: Boolean + + """"cost_per_million_events_indexed" field predicates.""" + costPerMillionEventsIndexedNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAt: Time + + """"last_seen_at" field predicates.""" + lastSeenAtNEQ: Time + + """"last_seen_at" field predicates.""" + lastSeenAtIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtNotIn: [Time!] + + """"last_seen_at" field predicates.""" + lastSeenAtGT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtGTE: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLT: Time + + """"last_seen_at" field predicates.""" + lastSeenAtLTE: Time +} + +""" +Describes what this service does for the business, product, or platform. +""" +type DomainFunction { + """Short grounded summary of what this service does.""" + summary: String! + + """Short normalized function labels.""" + functions: [String!]! +} + +""" +Describes what this service does for the business, product, or platform. +""" +input DomainFunctionWhereInput { + """Negated predicates.""" + not: DomainFunctionWhereInput + + """Predicates that must all match.""" + and: [DomainFunctionWhereInput!] + + """Predicates where at least one must match.""" + or: [DomainFunctionWhereInput!] + + """Whether the domain_function JSONB value is present.""" + present: Boolean + + """Short grounded summary of what this service does.""" + summary: String + + """Short grounded summary of what this service does.""" + summaryNEQ: String + + """Short grounded summary of what this service does.""" + summaryIn: [String!] + + """Short grounded summary of what this service does.""" + summaryNotIn: [String!] + + """Short grounded summary of what this service does.""" + summaryContains: String + + """Short grounded summary of what this service does.""" + summaryHasPrefix: String + + """Short grounded summary of what this service does.""" + summaryHasSuffix: String + + """Short grounded summary of what this service does.""" + summaryEqualFold: String + + """Short grounded summary of what this service does.""" + summaryContainsFold: String + + """Short grounded summary of what this service does.""" + hasSummary: Boolean + + """Short normalized function labels.""" + hasFunctions: Boolean + + """Short normalized function labels.""" + functionsContainsAny: [String!] + + """Short normalized function labels.""" + functionsContainsAll: [String!] +} + +scalar Duration + +type EdgeApiKey { + """Unique identifier of this API key""" + id: ID! + + """The account this API key authenticates to""" + accountID: ID! + + """User-provided name for this key (e.g., 'Production Collector')""" + name: String! + + """First characters of the key for identification (e.g., 'tero_sk_abc1')""" + keyPrefix: String! + + """When this key was last used for authentication""" + lastUsedAt: Time + + """When this key was revoked (null if active)""" + revokedAt: Time + + """When this key was created""" + createdAt: Time! + + """The account this API key belongs to""" + account: Account! +} + +type EdgeApiKeyConnection { + edges: [EdgeApiKeyEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EdgeApiKeyEdge { + node: EdgeApiKey! + cursor: Cursor! +} + +input EdgeApiKeyOrder { + direction: OrderDirection! = ASC + field: EdgeApiKeyOrderField! +} + +enum EdgeApiKeyOrderField { + ID + NAME + LAST_USED_AT + REVOKED_AT + CREATED_AT +} + +input EdgeApiKeyWhereInput { + not: EdgeApiKeyWhereInput + and: [EdgeApiKeyWhereInput!] + or: [EdgeApiKeyWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"key_prefix" field predicates.""" + keyPrefix: String + + """"key_prefix" field predicates.""" + keyPrefixNEQ: String + + """"key_prefix" field predicates.""" + keyPrefixIn: [String!] + + """"key_prefix" field predicates.""" + keyPrefixNotIn: [String!] + + """"key_prefix" field predicates.""" + keyPrefixGT: String + + """"key_prefix" field predicates.""" + keyPrefixGTE: String + + """"key_prefix" field predicates.""" + keyPrefixLT: String + + """"key_prefix" field predicates.""" + keyPrefixLTE: String + + """"key_prefix" field predicates.""" + keyPrefixContains: String + + """"key_prefix" field predicates.""" + keyPrefixHasPrefix: String + + """"key_prefix" field predicates.""" + keyPrefixHasSuffix: String + + """"key_prefix" field predicates.""" + keyPrefixEqualFold: String + + """"key_prefix" field predicates.""" + keyPrefixContainsFold: String + + """"key_hash" field predicates.""" + keyHash: String + + """"key_hash" field predicates.""" + keyHashNEQ: String + + """"key_hash" field predicates.""" + keyHashIn: [String!] + + """"key_hash" field predicates.""" + keyHashNotIn: [String!] + + """"key_hash" field predicates.""" + keyHashGT: String + + """"key_hash" field predicates.""" + keyHashGTE: String + + """"key_hash" field predicates.""" + keyHashLT: String + + """"key_hash" field predicates.""" + keyHashLTE: String + + """"key_hash" field predicates.""" + keyHashContains: String + + """"key_hash" field predicates.""" + keyHashHasPrefix: String + + """"key_hash" field predicates.""" + keyHashHasSuffix: String + + """"key_hash" field predicates.""" + keyHashEqualFold: String + + """"key_hash" field predicates.""" + keyHashContainsFold: String + + """"last_used_at" field predicates.""" + lastUsedAt: Time + + """"last_used_at" field predicates.""" + lastUsedAtNEQ: Time + + """"last_used_at" field predicates.""" + lastUsedAtIn: [Time!] + + """"last_used_at" field predicates.""" + lastUsedAtNotIn: [Time!] + + """"last_used_at" field predicates.""" + lastUsedAtGT: Time + + """"last_used_at" field predicates.""" + lastUsedAtGTE: Time + + """"last_used_at" field predicates.""" + lastUsedAtLT: Time + + """"last_used_at" field predicates.""" + lastUsedAtLTE: Time + + """"last_used_at" field predicates.""" + lastUsedAtIsNil: Boolean + + """"last_used_at" field predicates.""" + lastUsedAtNotNil: Boolean + + """"revoked_at" field predicates.""" + revokedAt: Time + + """"revoked_at" field predicates.""" + revokedAtNEQ: Time + + """"revoked_at" field predicates.""" + revokedAtIn: [Time!] + + """"revoked_at" field predicates.""" + revokedAtNotIn: [Time!] + + """"revoked_at" field predicates.""" + revokedAtGT: Time + + """"revoked_at" field predicates.""" + revokedAtGTE: Time + + """"revoked_at" field predicates.""" + revokedAtLT: Time + + """"revoked_at" field predicates.""" + revokedAtLTE: Time + + """"revoked_at" field predicates.""" + revokedAtIsNil: Boolean + + """"revoked_at" field predicates.""" + revokedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time +} + +"""Dimension to group fleet rows by.""" +enum EdgeFleetGroupBy { + SERVICE + NAMESPACE + VERSION +} + +""" +One row per distinct value of the chosen groupBy dimension. Null key means the +instance had no value for that field. +""" +type EdgeFleetGroupRow { + key: String + instanceCount: Int! + connectedInstanceCount: Int! + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + latestSyncAt: Time +} + +"""One point of aggregated counters in a policy time series.""" +type EdgeFleetPolicyPoint { + start: Time! + end: Time! + hits: Int! + misses: Int! + errors: Int! +} + +""" +A policy time series for the filtered fleet, or a synthetic "Other" rollup of +non-top-K policies when policyId is null. +""" +type EdgeFleetPolicySeries { + """Null for the synthetic "Other" rollup row.""" + policyId: ID + logEventName: String! + serviceName: String + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + series: [EdgeFleetPolicyPoint!]! +} + +"""Fleet-wide aggregated telemetry across edge instances.""" +type EdgeFleetTelemetry { + range: TimeRange! + granularity: TimeGranularity! + instanceCount: Int! + connectedInstanceCount: Int! + totalHits: Int! + totalMisses: Int! + totalErrors: Int! + + """ + Top-K policies ordered by totalHits desc. When fewer than topK policies exist, + contains them all and otherPolicies is null. + """ + topPolicies: [EdgeFleetPolicySeries!]! + + """ + Synthetic rollup of non-top policies when topK truncated the set; null otherwise. + """ + otherPolicies: EdgeFleetPolicySeries + + """ + One row per distinct value of the groupBy dimension, sorted by instanceCount + desc then key asc. + """ + groups: [EdgeFleetGroupRow!]! +} + +"""Input for aggregated edge fleet telemetry.""" +input EdgeFleetTelemetryInput { + where: EdgeFleetTelemetryWhere + range: TimeRangeInput! + granularity: TimeGranularity! + groupBy: EdgeFleetGroupBy! + + """ + Maximum number of policy series to return. Defaults to 10 and server caps + at 100. + """ + topK: Int = 10 + + """ + Threshold for connectedInstanceCount: instances with lastSyncAt >= now() - connectedWithin. + """ + connectedWithin: Duration! +} + +""" +Filters applied to the set of edge instances summarized by edgeFleetTelemetry. +""" +input EdgeFleetTelemetryWhere { + serviceName: String + serviceNamespace: String + serviceVersion: String + + """ + Restrict to instances with lastSyncAt at or after this time. Independent of the + telemetry time range. + """ + lastSyncAtGTE: Time +} + +type EdgeInstance { + """Unique identifier of this edge instance""" + id: ID! + + """The account this edge instance belongs to""" + accountID: ID! + + """The service.instance.id resource attribute identifying this instance""" + instanceID: String! + + """The service.name resource attribute""" + serviceName: String! + + """The service.namespace resource attribute""" + serviceNamespace: String + + """The service.version resource attribute""" + serviceVersion: String + + """Additional client metadata labels from sync request metadata.""" + labels: Map! + + """Raw client resource attributes from sync request metadata.""" + resourceAttributes: Map! + + """Last policy hash this instance reported as successfully applied.""" + lastSuccessfulHash: String + + """Last policy hash returned to this instance by sync.""" + lastResponseHash: String + + """When this edge instance first synced""" + firstSeenAt: Time! + + """When this edge instance last synced""" + lastSyncAt: Time! + + """When this record was created""" + createdAt: Time! + + """When this record was last updated""" + updatedAt: Time! + + """The account this edge instance belongs to""" + account: Account! + + """Log volume totals observed by this edge instance over a time range.""" + logVolumeSummary(input: LogVolumeSummaryInput!): LogVolumeSummary! + + """Bucketed log volume observed by this edge instance.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! + + """Bucketed policy execution telemetry reported by this edge instance.""" + policyTelemetry(input: EdgeInstancePolicyTelemetryInput!): PolicyTelemetrySeries! +} + +type EdgeInstanceConnection { + edges: [EdgeInstanceEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EdgeInstanceEdge { + node: EdgeInstance! + cursor: Cursor! +} + +input EdgeInstanceOrder { + direction: OrderDirection! = ASC + field: EdgeInstanceOrderField! +} + +enum EdgeInstanceOrderField { + ID + INSTANCE_ID + SERVICE_NAME + SERVICE_NAMESPACE + SERVICE_VERSION + FIRST_SEEN_AT + LAST_SYNC_AT + CREATED_AT + UPDATED_AT +} + +input EdgeInstancePolicyTelemetryFilterInput { + """ + Restrict edge-instance telemetry to these log event policies. At most 100 + values. + """ + logEventPolicyIDs: [UUID!] +} + +input EdgeInstancePolicyTelemetryInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! + filter: EdgeInstancePolicyTelemetryFilterInput +} + +input EdgeInstanceWhereInput { + not: EdgeInstanceWhereInput + and: [EdgeInstanceWhereInput!] + or: [EdgeInstanceWhereInput!] + + """"account" edge predicates.""" + account: AccountWhereInput + + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"instance_id" field predicates.""" + instanceID: String + + """"instance_id" field predicates.""" + instanceIDNEQ: String + + """"instance_id" field predicates.""" + instanceIDIn: [String!] + + """"instance_id" field predicates.""" + instanceIDNotIn: [String!] + + """"instance_id" field predicates.""" + instanceIDGT: String + + """"instance_id" field predicates.""" + instanceIDGTE: String + + """"instance_id" field predicates.""" + instanceIDLT: String + + """"instance_id" field predicates.""" + instanceIDLTE: String + + """"instance_id" field predicates.""" + instanceIDContains: String + + """"instance_id" field predicates.""" + instanceIDHasPrefix: String + + """"instance_id" field predicates.""" + instanceIDHasSuffix: String + + """"instance_id" field predicates.""" + instanceIDEqualFold: String + + """"instance_id" field predicates.""" + instanceIDContainsFold: String + + """"service_name" field predicates.""" + serviceName: String + + """"service_name" field predicates.""" + serviceNameNEQ: String + + """"service_name" field predicates.""" + serviceNameIn: [String!] + + """"service_name" field predicates.""" + serviceNameNotIn: [String!] + + """"service_name" field predicates.""" + serviceNameGT: String + + """"service_name" field predicates.""" + serviceNameGTE: String + + """"service_name" field predicates.""" + serviceNameLT: String + + """"service_name" field predicates.""" + serviceNameLTE: String + + """"service_name" field predicates.""" + serviceNameContains: String + + """"service_name" field predicates.""" + serviceNameHasPrefix: String + + """"service_name" field predicates.""" + serviceNameHasSuffix: String + + """"service_name" field predicates.""" + serviceNameEqualFold: String + + """"service_name" field predicates.""" + serviceNameContainsFold: String + + """"service_namespace" field predicates.""" + serviceNamespace: String + + """"service_namespace" field predicates.""" + serviceNamespaceNEQ: String + + """"service_namespace" field predicates.""" + serviceNamespaceIn: [String!] + + """"service_namespace" field predicates.""" + serviceNamespaceNotIn: [String!] + + """"service_namespace" field predicates.""" + serviceNamespaceGT: String + + """"service_namespace" field predicates.""" + serviceNamespaceGTE: String + + """"service_namespace" field predicates.""" + serviceNamespaceLT: String + + """"service_namespace" field predicates.""" + serviceNamespaceLTE: String + + """"service_namespace" field predicates.""" + serviceNamespaceContains: String + + """"service_namespace" field predicates.""" + serviceNamespaceHasPrefix: String + + """"service_namespace" field predicates.""" + serviceNamespaceHasSuffix: String + + """"service_namespace" field predicates.""" + serviceNamespaceIsNil: Boolean + + """"service_namespace" field predicates.""" + serviceNamespaceNotNil: Boolean + + """"service_namespace" field predicates.""" + serviceNamespaceEqualFold: String + + """"service_namespace" field predicates.""" + serviceNamespaceContainsFold: String + + """"service_version" field predicates.""" + serviceVersion: String + + """"service_version" field predicates.""" + serviceVersionNEQ: String + + """"service_version" field predicates.""" + serviceVersionIn: [String!] + + """"service_version" field predicates.""" + serviceVersionNotIn: [String!] + + """"service_version" field predicates.""" + serviceVersionGT: String + + """"service_version" field predicates.""" + serviceVersionGTE: String + + """"service_version" field predicates.""" + serviceVersionLT: String + + """"service_version" field predicates.""" + serviceVersionLTE: String + + """"service_version" field predicates.""" + serviceVersionContains: String + + """"service_version" field predicates.""" + serviceVersionHasPrefix: String + + """"service_version" field predicates.""" + serviceVersionHasSuffix: String + + """"service_version" field predicates.""" + serviceVersionIsNil: Boolean + + """"service_version" field predicates.""" + serviceVersionNotNil: Boolean + + """"service_version" field predicates.""" + serviceVersionEqualFold: String + + """"service_version" field predicates.""" + serviceVersionContainsFold: String + + """"first_seen_at" field predicates.""" + firstSeenAt: Time + + """"first_seen_at" field predicates.""" + firstSeenAtNEQ: Time + + """"first_seen_at" field predicates.""" + firstSeenAtIn: [Time!] + + """"first_seen_at" field predicates.""" + firstSeenAtNotIn: [Time!] + + """"first_seen_at" field predicates.""" + firstSeenAtGT: Time + + """"first_seen_at" field predicates.""" + firstSeenAtGTE: Time + + """"first_seen_at" field predicates.""" + firstSeenAtLT: Time + + """"first_seen_at" field predicates.""" + firstSeenAtLTE: Time + + """"last_sync_at" field predicates.""" + lastSyncAt: Time + + """"last_sync_at" field predicates.""" + lastSyncAtNEQ: Time + + """"last_sync_at" field predicates.""" + lastSyncAtIn: [Time!] + + """"last_sync_at" field predicates.""" + lastSyncAtNotIn: [Time!] + + """"last_sync_at" field predicates.""" + lastSyncAtGT: Time + + """"last_sync_at" field predicates.""" + lastSyncAtGTE: Time + + """"last_sync_at" field predicates.""" + lastSyncAtLT: Time + + """"last_sync_at" field predicates.""" + lastSyncAtLTE: Time + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +enum EventRole { + """ + A readable substantive production event, state change, operation, outcome, alert, or failure artifact. + """ + signal + + """ + A breadcrumb, checkpoint, receipt, wrapper, or progress artifact that mainly proves control flow. + """ + marker + + """ + An inline dump, payload, snapshot, serialized object, request body, response + body, query body, or similar debugging artifact. + """ + debug_artifact + + """ + An effectively unintelligible emitted artifact whose role cannot be recovered. + """ + garbled +} + +""" +Classifies the coarse observability artifact role of a recurring log event. +""" +type EventRoleProfile { + """The coarse observability artifact role of the recurring log event.""" + role: EventRole! +} + +""" +Classifies the coarse observability artifact role of a recurring log event. +""" +input EventRoleProfileWhereInput { + """Negated predicates.""" + not: EventRoleProfileWhereInput + + """Predicates that must all match.""" + and: [EventRoleProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [EventRoleProfileWhereInput!] + + """Whether the event_role_profile JSONB value is present.""" + present: Boolean + + """The coarse observability artifact role of the recurring log event.""" + role: EventRole + + """The coarse observability artifact role of the recurring log event.""" + roleNEQ: EventRole + + """The coarse observability artifact role of the recurring log event.""" + roleIn: [EventRole!] + + """The coarse observability artifact role of the recurring log event.""" + roleNotIn: [EventRole!] +} + +enum ExpectedLevel { + """ + Implementation-facing inspection, planning, decision-path detail, step + tracing, threshold evaluation, or internal execution detail. + """ + debug + + """ + Normal work, lifecycle, housekeeping, coordination, startup, readiness, state change, or successful completion. + """ + info + + """ + Abnormal or degraded operation where the system is still operating or still trying to complete work. + """ + warn + + """ + Intended work that did not complete for this attempt, including rejection, + denial, abort, timeout, unrecovered exception, failed request, or hard broken condition. + """ + error +} + +type Finding { + """Unique identifier""" + id: ID! + + """ + Service this finding belongs to. Finding memberships must not span services. + """ + serviceID: ID! + + """ + Product domain that owns this finding. Values: cost = Cost optimization + findings that identify telemetry spend with weak value or misplaced volume.; + compliance = Compliance findings that identify sensitive or regulated data exposure. + """ + checkDomain: FindingCheckDomain! + + """ + Specific finding category raised within its owning domain. Values: + routine_system_chatter = Routine system chatter with low operator prominence + and weak per-event value.; debug_noise = Production log events whose expected + severity is debug.; dead_weight = Log events with no meaningful observability + value.; background_noise = Infrastructure, bot, probe, or other background + traffic that should not consume production log budget.; commodity_traffic = + Aggregate operational series where repeated volume adds little per-event + value.; hot_path = High-volume application-path logging that appears misplaced + for the execution path.; reactive_flood = Failure or dependency-driven floods + that materially inflate log volume.; configuration_noise = Repeated + configuration-related failures that consume production log budget.; + debug_marker = Developer breadcrumb markers left in production logging.; + debug_artifacts = Inline debug payloads or object dumps that should live + outside normal production logs.; secret_exposure = Log events that expose + secrets, credentials, or authentication material.; payment_data_exposure = Log + events that expose payment instrument or payment-processing data.; + pii_exposure = Log events that expose meaningful customer or end-user identity + data.; phi_exposure = Log events that expose health or treatment-related personal data. + """ + checkType: FindingCheckType! + + """Version of the check logic that most recently reconciled this finding.""" + checkVersion: Int! + + """ + Current workflow state for this finding. Values: pending = Finding is open and + waiting for investigation.; suppressed = Finding was investigated and + intentionally suppressed.; escalated = Finding was investigated and escalated + into an issue.; closed = Finding no longer matches the current world. + """ + status: FindingStatus! + + """ + When this finding stopped matching the current world. Null while active. + """ + closedAt: Time + + """When this finding row was created.""" + createdAt: Time! + + """When this finding row was last updated.""" + updatedAt: Time! + + """Typed finding details for the finding's check type.""" + details: FindingDetails! +} + +enum FindingCheckDomain { + cost + compliance +} + +enum FindingCheckType { + routine_system_chatter + debug_noise + dead_weight + background_noise + commodity_traffic + hot_path + reactive_flood + configuration_noise + debug_marker + debug_artifacts + secret_exposure + payment_data_exposure + pii_exposure + phi_exposure +} + +"""Typed finding details for the finding's check type.""" +type FindingDetails { + """Finding detail payload for background-noise findings.""" + costBackgroundNoise: CostBackgroundNoise + + """Finding detail payload for commodity-traffic findings.""" + costCommodityTraffic: CostCommodityTraffic + + """Finding detail payload for dead-weight findings.""" + costDeadWeight: CostDeadWeight + + """Finding detail payload for debug-artifact findings.""" + costDebugArtifacts: CostDebugArtifacts + + """Finding detail payload for debug-marker findings.""" + costDebugMarker: CostDebugMarker + + """Finding detail payload for debug-noise findings.""" + costDebugNoise: CostDebugNoise + + """Finding detail payload for configuration-noise findings.""" + costConfigurationNoise: CostConfigurationNoise + + """Finding detail payload for hot-path findings.""" + costHotPath: CostHotPath + + """Finding detail payload for reactive-flood findings.""" + costReactiveFlood: CostReactiveFlood + + """Finding detail payload for routine-system-chatter findings.""" + costRoutineSystemChatter: CostRoutineSystemChatter + + """Finding detail payload for secret-exposure findings.""" + complianceSecretExposure: ComplianceSecretExposure + + """Finding detail payload for payment-data-exposure findings.""" + compliancePaymentDataExposure: CompliancePaymentDataExposure + + """Finding detail payload for PII-exposure findings.""" + compliancePIIExposure: CompliancePIIExposure + + """Finding detail payload for PHI-exposure findings.""" + compliancePHIExposure: CompliancePHIExposure +} + +enum FindingStatus { + pending + suppressed + escalated + closed +} + +input FindingWhereInput { + not: FindingWhereInput + and: [FindingWhereInput!] + or: [FindingWhereInput!] + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"service_id" field predicates.""" + serviceID: ID + + """"service_id" field predicates.""" + serviceIDNEQ: ID + + """"service_id" field predicates.""" + serviceIDIn: [ID!] + + """"service_id" field predicates.""" + serviceIDNotIn: [ID!] + + """"check_domain" field predicates.""" + checkDomain: FindingCheckDomain + + """"check_domain" field predicates.""" + checkDomainNEQ: FindingCheckDomain + + """"check_domain" field predicates.""" + checkDomainIn: [FindingCheckDomain!] + + """"check_domain" field predicates.""" + checkDomainNotIn: [FindingCheckDomain!] + + """"check_type" field predicates.""" + checkType: FindingCheckType + + """"check_type" field predicates.""" + checkTypeNEQ: FindingCheckType + + """"check_type" field predicates.""" + checkTypeIn: [FindingCheckType!] + + """"check_type" field predicates.""" + checkTypeNotIn: [FindingCheckType!] + + """"check_version" field predicates.""" + checkVersion: Int + + """"check_version" field predicates.""" + checkVersionNEQ: Int + + """"check_version" field predicates.""" + checkVersionIn: [Int!] + + """"check_version" field predicates.""" + checkVersionNotIn: [Int!] + + """"check_version" field predicates.""" + checkVersionGT: Int + + """"check_version" field predicates.""" + checkVersionGTE: Int + + """"check_version" field predicates.""" + checkVersionLT: Int + + """"check_version" field predicates.""" + checkVersionLTE: Int + + """"fingerprint" field predicates.""" + fingerprint: String + + """"fingerprint" field predicates.""" + fingerprintNEQ: String + + """"fingerprint" field predicates.""" + fingerprintIn: [String!] + + """"fingerprint" field predicates.""" + fingerprintNotIn: [String!] + + """"fingerprint" field predicates.""" + fingerprintGT: String + + """"fingerprint" field predicates.""" + fingerprintGTE: String + + """"fingerprint" field predicates.""" + fingerprintLT: String + + """"fingerprint" field predicates.""" + fingerprintLTE: String + + """"fingerprint" field predicates.""" + fingerprintContains: String + + """"fingerprint" field predicates.""" + fingerprintHasPrefix: String + + """"fingerprint" field predicates.""" + fingerprintHasSuffix: String + + """"fingerprint" field predicates.""" + fingerprintEqualFold: String + + """"fingerprint" field predicates.""" + fingerprintContainsFold: String + + """"status" field predicates.""" + status: FindingStatus + + """"status" field predicates.""" + statusNEQ: FindingStatus + + """"status" field predicates.""" + statusIn: [FindingStatus!] + + """"status" field predicates.""" + statusNotIn: [FindingStatus!] + + """"current_basis" field predicates.""" + currentBasis: String + + """"current_basis" field predicates.""" + currentBasisNEQ: String + + """"current_basis" field predicates.""" + currentBasisIn: [String!] + + """"current_basis" field predicates.""" + currentBasisNotIn: [String!] + + """"current_basis" field predicates.""" + currentBasisGT: String + + """"current_basis" field predicates.""" + currentBasisGTE: String + + """"current_basis" field predicates.""" + currentBasisLT: String + + """"current_basis" field predicates.""" + currentBasisLTE: String + + """"current_basis" field predicates.""" + currentBasisContains: String + + """"current_basis" field predicates.""" + currentBasisHasPrefix: String + + """"current_basis" field predicates.""" + currentBasisHasSuffix: String + + """"current_basis" field predicates.""" + currentBasisEqualFold: String + + """"current_basis" field predicates.""" + currentBasisContainsFold: String + + """"reviewed_basis" field predicates.""" + reviewedBasis: String + + """"reviewed_basis" field predicates.""" + reviewedBasisNEQ: String + + """"reviewed_basis" field predicates.""" + reviewedBasisIn: [String!] + + """"reviewed_basis" field predicates.""" + reviewedBasisNotIn: [String!] + + """"reviewed_basis" field predicates.""" + reviewedBasisGT: String + + """"reviewed_basis" field predicates.""" + reviewedBasisGTE: String + + """"reviewed_basis" field predicates.""" + reviewedBasisLT: String + + """"reviewed_basis" field predicates.""" + reviewedBasisLTE: String + + """"reviewed_basis" field predicates.""" + reviewedBasisContains: String + + """"reviewed_basis" field predicates.""" + reviewedBasisHasPrefix: String + + """"reviewed_basis" field predicates.""" + reviewedBasisHasSuffix: String + + """"reviewed_basis" field predicates.""" + reviewedBasisIsNil: Boolean + + """"reviewed_basis" field predicates.""" + reviewedBasisNotNil: Boolean + + """"reviewed_basis" field predicates.""" + reviewedBasisEqualFold: String + + """"reviewed_basis" field predicates.""" + reviewedBasisContainsFold: String + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersion: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNEQ: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionIn: [Int!] + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNotIn: [Int!] + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionGT: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionGTE: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionLT: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionLTE: Int + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionIsNil: Boolean + + """"reviewed_investigation_version" field predicates.""" + reviewedInvestigationVersionNotNil: Boolean + + """"closed_at" field predicates.""" + closedAt: Time + + """"closed_at" field predicates.""" + closedAtNEQ: Time + + """"closed_at" field predicates.""" + closedAtIn: [Time!] + + """"closed_at" field predicates.""" + closedAtNotIn: [Time!] + + """"closed_at" field predicates.""" + closedAtGT: Time + + """"closed_at" field predicates.""" + closedAtGTE: Time + + """"closed_at" field predicates.""" + closedAtLT: Time + + """"closed_at" field predicates.""" + closedAtLTE: Time + + """"closed_at" field predicates.""" + closedAtIsNil: Boolean + + """"closed_at" field predicates.""" + closedAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +"""Captures the stable local semantic identity of a recurring log event.""" +type IdentityProfile { + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClass: String! + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subject: String! + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operation: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatus: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKind: String +} + +"""Captures the stable local semantic identity of a recurring log event.""" +input IdentityProfileWhereInput { + """Negated predicates.""" + not: IdentityProfileWhereInput + + """Predicates that must all match.""" + and: [IdentityProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [IdentityProfileWhereInput!] + + """Whether the identity_profile JSONB value is present.""" + present: Boolean + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClass: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassNEQ: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassIn: [String!] + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassNotIn: [String!] + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassContains: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassHasPrefix: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassHasSuffix: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassEqualFold: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + subjectClassContainsFold: String + + """ + The coarse local kind of thing that owns the recurring event, emitted as a canonical snake_case label. + """ + hasSubjectClass: Boolean + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subject: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectNEQ: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectIn: [String!] + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectNotIn: [String!] + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectContains: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectHasPrefix: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectHasSuffix: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectEqualFold: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + subjectContainsFold: String + + """ + The durable local object, subsystem, resource family, component, job, handler, + middleware step, lifecycle area, or same-service thing the event is + fundamentally about, emitted as a canonical snake_case label. + """ + hasSubject: Boolean + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operation: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationNEQ: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationIn: [String!] + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationNotIn: [String!] + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationContains: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationHasPrefix: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationHasSuffix: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationEqualFold: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + operationContainsFold: String + + """ + The stable reusable work family this event represents, when one is materially + present, emitted as a canonical snake_case label. + """ + hasOperation: Boolean + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatus: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusNEQ: String + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusIn: [String!] + + """ + The terminal polarity of attempted work when the record carries a completed success or failure outcome. + """ + terminalStatusNotIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKind: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindNEQ: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindNotIn: [String!] + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindContains: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindHasPrefix: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindHasSuffix: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindEqualFold: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + failureKindContainsFold: String + + """ + The coarse failure subtype when terminal_status is failure, emitted as a canonical snake_case label. + """ + hasFailureKind: Boolean +} + +""" +Describes visible implementation language, framework, runtime, and stack details. +""" +type ImplementationProfile { + """Short grounded summary of the implementation profile.""" + summary: String! + + """Likely implementation language when obvious, or `unknown`.""" + language: String! + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stack: [String!]! +} + +""" +Describes visible implementation language, framework, runtime, and stack details. +""" +input ImplementationProfileWhereInput { + """Negated predicates.""" + not: ImplementationProfileWhereInput + + """Predicates that must all match.""" + and: [ImplementationProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [ImplementationProfileWhereInput!] + + """Whether the implementation_profile JSONB value is present.""" + present: Boolean + + """Short grounded summary of the implementation profile.""" + summary: String + + """Short grounded summary of the implementation profile.""" + summaryNEQ: String + + """Short grounded summary of the implementation profile.""" + summaryIn: [String!] + + """Short grounded summary of the implementation profile.""" + summaryNotIn: [String!] + + """Short grounded summary of the implementation profile.""" + summaryContains: String + + """Short grounded summary of the implementation profile.""" + summaryHasPrefix: String + + """Short grounded summary of the implementation profile.""" + summaryHasSuffix: String + + """Short grounded summary of the implementation profile.""" + summaryEqualFold: String + + """Short grounded summary of the implementation profile.""" + summaryContainsFold: String + + """Short grounded summary of the implementation profile.""" + hasSummary: Boolean + + """Likely implementation language when obvious, or `unknown`.""" + language: String + + """Likely implementation language when obvious, or `unknown`.""" + languageNEQ: String + + """Likely implementation language when obvious, or `unknown`.""" + languageIn: [String!] + + """Likely implementation language when obvious, or `unknown`.""" + languageNotIn: [String!] + + """Likely implementation language when obvious, or `unknown`.""" + languageContains: String + + """Likely implementation language when obvious, or `unknown`.""" + languageHasPrefix: String + + """Likely implementation language when obvious, or `unknown`.""" + languageHasSuffix: String + + """Likely implementation language when obvious, or `unknown`.""" + languageEqualFold: String + + """Likely implementation language when obvious, or `unknown`.""" + languageContainsFold: String + + """Likely implementation language when obvious, or `unknown`.""" + hasLanguage: Boolean + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + hasStack: Boolean + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stackContainsAny: [String!] + + """Visible frameworks, runtimes, libraries, or deployment stack labels.""" + stackContainsAll: [String!] +} + +enum InvitationState { + PENDING + ACCEPTED + REVOKED + EXPIRED +} + +type Issue { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from finding.account_id. + """ + accountID: UUID! + + """The finding this issue belongs to""" + findingID: ID! + + """ + Denormalized service subject. Auto-set via trigger from finding.service_id. + """ + serviceID: UUID! + + """ + How much attention a kept finding deserves. Values: low = Legitimate finding, + but low urgency or prominence.; medium = Legitimate finding with clear but not + top-tier urgency.; high = Legitimate finding that deserves strong user attention. + """ + priority: IssuePriority! + + """Short user-facing title for this issue.""" + title: String! + + """User-facing explanation of this issue.""" + summary: String! + + """ + Optional grounded hypothesis about the likely underlying cause of this issue. + """ + causeHypothesis: String + + """When this issue episode ended. Null while this issue is still active.""" + closedAt: Time + + """ + When this issue was intentionally ignored by a user. Null while still active. + """ + ignoredAt: Time + + """When this issue row was created.""" + createdAt: Time! + + """When this issue row was last updated.""" + updatedAt: Time! + + """The finding this issue episode belongs to""" + finding: Finding! + + """Chosen actions owned by this issue.""" + issueActions(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: IssueActionOrder, where: IssueActionWhereInput): IssueActionConnection! + + """Log event policies created for this issue.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! + + """Datadog log exclusion filters created for this issue.""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! + + """ + Stable account-local issue identifier for product links and support workflows. + """ + displayID: String! + + """Account-qualified issue identifier for cross-account presentation.""" + qualifiedDisplayID: String! + + """Product check that produced this issue's finding.""" + check: Check! + state: IssueState! + logEvents: [LogEvent!]! + service: Service + actionable: Boolean! + cost: StatusMeasurementTotals! + + """Frozen issue cost remaining after applying preview remediation.""" + projectedCost: StatusMeasurementTotals! + + """Bucketed issue cost over a half-open range.""" + costTrend(input: IssueCostTrendInput!): IssueCostTrendSeries! + savings: StatusMeasurementTotals! + logEventPolicyProposals: [LogEventPolicyProposal!]! + datadogLogExclusionFilterDrafts: [DatadogLogExclusionFilterDraft!]! + complianceLeakPreviews: [ComplianceLeakPreview!]! +} + +type IssueAction { + """Unique identifier""" + id: ID! + + """ + Denormalized for tenant isolation. Auto-set via trigger from issues.account_id. + """ + accountID: UUID! + + """The issue this action belongs to.""" + issueID: ID! + + """ + Issue action kind. Values: apply_log_event_policy = Apply a log-event policy + for this issue.; notify_team = Notify the responsible team about this issue.; + open_external_issue = Open an external work item for this issue.; + create_pull_request = Create a pull request to address this issue.; + run_infra_bot = Run an infrastructure bot against this issue.; mark_waiting = + Record that this issue is waiting on something external.; dismiss_issue = + Dismiss this issue intentionally. + """ + kind: IssueActionKind! + + """ + Current lifecycle state for this issue action. Values: pending = Chosen and + waiting to execute.; running = Execution is in progress.; succeeded = + Execution completed successfully.; failed = Execution failed.; cancelled = + Canceled before completion. + """ + status: IssueActionStatus! + + """Action-specific structured payload.""" + payload: Map! + + """Most recent execution failure for this action, when status is failed.""" + error: String + + """When this issue action row was created.""" + createdAt: Time! + + """When this issue action row was last updated.""" + updatedAt: Time! + + """The issue this action belongs to.""" + issue: Issue! +} + +type IssueActionConnection { + edges: [IssueActionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type IssueActionEdge { + node: IssueAction! + cursor: Cursor! +} + +enum IssueActionKind { + apply_log_event_policy + notify_team + open_external_issue + create_pull_request + run_infra_bot + mark_waiting + dismiss_issue +} + +input IssueActionOrder { + direction: OrderDirection! = ASC + field: IssueActionOrderField! +} + +enum IssueActionOrderField { + ID +} + +"""Issue-action payload that applies per-log-event policy specifications.""" +input IssueActionPayloadWhereInput { + not: IssueActionPayloadWhereInput + and: [IssueActionPayloadWhereInput!] + or: [IssueActionPayloadWhereInput!] + + """Per-log-event policy specs to apply for this issue action.""" + hasSpecs: Boolean +} + +enum IssueActionStatus { + pending + running + succeeded + failed + cancelled +} + +input IssueActionWhereInput { + not: IssueActionWhereInput + and: [IssueActionWhereInput!] + or: [IssueActionWhereInput!] + + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"account_id" field predicates.""" + accountID: UUID + + """"account_id" field predicates.""" + accountIDNEQ: UUID + + """"account_id" field predicates.""" + accountIDIn: [UUID!] + + """"account_id" field predicates.""" + accountIDNotIn: [UUID!] + + """"account_id" field predicates.""" + accountIDGT: UUID + + """"account_id" field predicates.""" + accountIDGTE: UUID + + """"account_id" field predicates.""" + accountIDLT: UUID + + """"account_id" field predicates.""" + accountIDLTE: UUID + + """"issue_id" field predicates.""" + issueID: ID + + """"issue_id" field predicates.""" + issueIDNEQ: ID + + """"issue_id" field predicates.""" + issueIDIn: [ID!] + + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] + + """"kind" field predicates.""" + kind: IssueActionKind + + """"kind" field predicates.""" + kindNEQ: IssueActionKind + + """"kind" field predicates.""" + kindIn: [IssueActionKind!] + + """"kind" field predicates.""" + kindNotIn: [IssueActionKind!] + + """"status" field predicates.""" + status: IssueActionStatus + + """"status" field predicates.""" + statusNEQ: IssueActionStatus + + """"status" field predicates.""" + statusIn: [IssueActionStatus!] + + """"status" field predicates.""" + statusNotIn: [IssueActionStatus!] + + """"error" field predicates.""" + error: String + + """"error" field predicates.""" + errorNEQ: String + + """"error" field predicates.""" + errorIn: [String!] + + """"error" field predicates.""" + errorNotIn: [String!] + + """"error" field predicates.""" + errorGT: String + + """"error" field predicates.""" + errorGTE: String + + """"error" field predicates.""" + errorLT: String + + """"error" field predicates.""" + errorLTE: String + + """"error" field predicates.""" + errorContains: String + + """"error" field predicates.""" + errorHasPrefix: String + + """"error" field predicates.""" + errorHasSuffix: String + + """"error" field predicates.""" + errorIsNil: Boolean + + """"error" field predicates.""" + errorNotNil: Boolean + + """"error" field predicates.""" + errorEqualFold: String + + """"error" field predicates.""" + errorContainsFold: String + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time +} + +type IssueConnection { + edges: [IssueEdge!]! + pageInfo: PageInfo! + totalCount: Int! + summary: IssueSummary! + facets: IssueFacets! +} + +input IssueCostTrendInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! + + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! +} + +type IssueCostTrendPoint { + start: Time! + end: Time! + cost: StatusMeasurementTotals! +} + +type IssueCostTrendSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [IssueCostTrendPoint!]! +} + +input IssueCostWhereInput { + minUsdPerHour: Float + maxUsdPerHour: Float +} + +type IssueEdge { + node: Issue! + cursor: Cursor! +} + +type IssueFacets { + priorities(limit: Int): IssuePriorityFacet! + services(limit: Int): IssueServiceFacet! +} + +input IssueOrder { + direction: OrderDirection! = ASC + field: IssueOrderField! +} + +enum IssueOrderField { + ID + PRIORITY + TITLE + CLOSED_AT + IGNORED_AT + CREATED_AT + UPDATED_AT + SERVICE_NAME + TEAM_NAME + EVENTS_PER_HOUR +} + +enum IssuePriority { + low + medium + high +} + +type IssuePriorityFacet { + buckets: [IssuePriorityFacetBucket!]! +} + +type IssuePriorityFacetBucket { + value: IssuePriority! + count: Int! +} + +input IssueSavingsWhereInput { + minUsdPerHour: Float + maxUsdPerHour: Float +} + +type IssueServiceFacet { + buckets: [IssueServiceFacetBucket!]! +} + +type IssueServiceFacetBucket { + service: Service! + count: Int! +} + +enum IssueStage { + open + in_progress + waiting + failed + ignored + resolved +} + +type IssueState { + stage: IssueStage! +} + +type IssueSummary { + count: Int! +} + +input IssueWhereInput { + not: IssueWhereInput + and: [IssueWhereInput!] + or: [IssueWhereInput!] + + """"finding" edge predicates.""" + finding: FindingWhereInput + + """Whether the "finding" edge has at least one related row.""" + hasFinding: Boolean + + """"issue_actions" edge predicates.""" + issueActions: IssueActionWhereInput + + """Whether the "issue_actions" edge has at least one related row.""" + hasIssueActions: Boolean + + """"log_event_policies" edge predicates.""" + logEventPolicies: LogEventPolicyWhereInput + + """Whether the "log_event_policies" edge has at least one related row.""" + hasLogEventPolicies: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"id" field predicates.""" + id: ID + + """"id" field predicates.""" + idNEQ: ID + + """"id" field predicates.""" + idIn: [ID!] + + """"id" field predicates.""" + idNotIn: [ID!] + + """"id" field predicates.""" + idGT: ID + + """"id" field predicates.""" + idGTE: ID + + """"id" field predicates.""" + idLT: ID + + """"id" field predicates.""" + idLTE: ID + + """"finding_id" field predicates.""" + findingID: ID + + """"finding_id" field predicates.""" + findingIDNEQ: ID + + """"finding_id" field predicates.""" + findingIDIn: [ID!] + + """"finding_id" field predicates.""" + findingIDNotIn: [ID!] + + """"priority" field predicates.""" + priority: IssuePriority + + """"priority" field predicates.""" + priorityNEQ: IssuePriority + + """"priority" field predicates.""" + priorityIn: [IssuePriority!] + + """"priority" field predicates.""" + priorityNotIn: [IssuePriority!] + + """"title" field predicates.""" + title: String + + """"title" field predicates.""" + titleNEQ: String + + """"title" field predicates.""" + titleIn: [String!] + + """"title" field predicates.""" + titleNotIn: [String!] + + """"title" field predicates.""" + titleGT: String + + """"title" field predicates.""" + titleGTE: String + + """"title" field predicates.""" + titleLT: String + + """"title" field predicates.""" + titleLTE: String + + """"title" field predicates.""" + titleContains: String + + """"title" field predicates.""" + titleHasPrefix: String + + """"title" field predicates.""" + titleHasSuffix: String + + """"title" field predicates.""" + titleEqualFold: String + + """"title" field predicates.""" + titleContainsFold: String + + """"closed_at" field predicates.""" + closedAt: Time + + """"closed_at" field predicates.""" + closedAtNEQ: Time + + """"closed_at" field predicates.""" + closedAtIn: [Time!] + + """"closed_at" field predicates.""" + closedAtNotIn: [Time!] + + """"closed_at" field predicates.""" + closedAtGT: Time + + """"closed_at" field predicates.""" + closedAtGTE: Time + + """"closed_at" field predicates.""" + closedAtLT: Time + + """"closed_at" field predicates.""" + closedAtLTE: Time + + """"closed_at" field predicates.""" + closedAtIsNil: Boolean + + """"closed_at" field predicates.""" + closedAtNotNil: Boolean + + """"ignored_at" field predicates.""" + ignoredAt: Time + + """"ignored_at" field predicates.""" + ignoredAtNEQ: Time + + """"ignored_at" field predicates.""" + ignoredAtIn: [Time!] + + """"ignored_at" field predicates.""" + ignoredAtNotIn: [Time!] + + """"ignored_at" field predicates.""" + ignoredAtGT: Time + + """"ignored_at" field predicates.""" + ignoredAtGTE: Time + + """"ignored_at" field predicates.""" + ignoredAtLT: Time + + """"ignored_at" field predicates.""" + ignoredAtLTE: Time + + """"ignored_at" field predicates.""" + ignoredAtIsNil: Boolean + + """"ignored_at" field predicates.""" + ignoredAtNotNil: Boolean + + """"created_at" field predicates.""" + createdAt: Time + + """"created_at" field predicates.""" + createdAtNEQ: Time + + """"created_at" field predicates.""" + createdAtIn: [Time!] + + """"created_at" field predicates.""" + createdAtNotIn: [Time!] + + """"created_at" field predicates.""" + createdAtGT: Time + + """"created_at" field predicates.""" + createdAtGTE: Time + + """"created_at" field predicates.""" + createdAtLT: Time + + """"created_at" field predicates.""" + createdAtLTE: Time + + """"updated_at" field predicates.""" + updatedAt: Time + + """"updated_at" field predicates.""" + updatedAtNEQ: Time + + """"updated_at" field predicates.""" + updatedAtIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" + updatedAtGT: Time + + """"updated_at" field predicates.""" + updatedAtGTE: Time + + """"updated_at" field predicates.""" + updatedAtLT: Time + + """"updated_at" field predicates.""" + updatedAtLTE: Time + + """Severity alias for priority triage.""" + severity: IssuePriority + + """Severity alias for priority triage.""" + severityIn: [IssuePriority!] + + """Derived lifecycle stage triage status.""" + stage: IssueStage + + """Derived lifecycle stage triage status.""" + stageIn: [IssueStage!] + + """Service this issue belongs to.""" + serviceID: ID + + """Services these issues belong to.""" + serviceIDIn: [ID!] + + """Team that owns the issue's service.""" + teamID: ID + + """Teams that own the issues' services.""" + teamIDIn: [ID!] + + """Log event referenced by the issue's finding.""" + logEventID: ID + + """Log events referenced by the issue's finding.""" + logEventIDIn: [ID!] + + """Edge instance that observed one of the issue's finding log events.""" + edgeInstanceID: ID + + """Edge instances that observed one of the issue's finding log events.""" + edgeInstanceIDIn: [ID!] + + """ + Whether this issue currently has preview remediation actions available. + """ + actionable: Boolean + + """Current issue cost envelope in USD per hour.""" + cost: IssueCostWhereInput + + """Preview savings envelope in USD per hour.""" + savings: IssueSavingsWhereInput + + """Product domain that owns the issue's finding.""" + domain: FindingCheckDomain + + """Product domains that own the issue's finding.""" + domainIn: [FindingCheckDomain!] + + """Specific finding category raised within its owning domain.""" + checkType: FindingCheckType + + """Specific finding categories raised within their owning domains.""" + checkTypeIn: [FindingCheckType!] + + """Public check identity attached through the issue's finding.""" + checkID: ID + + """Public check identities attached through issues' findings.""" + checkIDIn: [ID!] +} + +scalar JSON + +""" +Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. +""" +type LevelExpectationProfile { + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevel: ExpectedLevel! +} + +""" +Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. +""" +input LevelExpectationProfileWhereInput { + """Negated predicates.""" + not: LevelExpectationProfileWhereInput + + """Predicates that must all match.""" + and: [LevelExpectationProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [LevelExpectationProfileWhereInput!] + + """Whether the level_expectation_profile JSONB value is present.""" + present: Boolean + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevel: ExpectedLevel + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelNEQ: ExpectedLevel + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelIn: [ExpectedLevel!] + + """ + The severity level this recurring event family deserves when instrumented cleanly. + """ + expectedLevelNotIn: [ExpectedLevel!] +} + +""" +Describes how one service's own logs should compress into recurring emitted lines and occurrence details. +""" +type LogEmissionModel { + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summary: String! + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundary: String! + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundary: String! +} + +""" +Describes how one service's own logs should compress into recurring emitted lines and occurrence details. +""" +input LogEmissionModelWhereInput { + """Negated predicates.""" + not: LogEmissionModelWhereInput + + """Predicates that must all match.""" + and: [LogEmissionModelWhereInput!] + + """Predicates where at least one must match.""" + or: [LogEmissionModelWhereInput!] + + """Whether the log_emission_model JSONB value is present.""" + present: Boolean + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summary: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryNEQ: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryIn: [String!] + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryNotIn: [String!] + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryContains: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryHasPrefix: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryHasSuffix: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryEqualFold: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + summaryContainsFold: String + + """ + Compressed service-specific summary of how this service's logs tend to form recurring emitted-line families. + """ + hasSummary: Boolean + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundary: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryNEQ: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryIn: [String!] + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryNotIn: [String!] + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryContains: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryHasPrefix: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryHasSuffix: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryEqualFold: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + emissionBoundaryContainsFold: String + + """ + Service-specific rule for what should bind records into one recurring emitted-line family. + """ + hasEmissionBoundary: Boolean + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundary: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryNEQ: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryIn: [String!] + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryNotIn: [String!] + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryContains: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryHasPrefix: String - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryHasSuffix: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryEqualFold: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + occurrenceDetailBoundaryContainsFold: String + + """ + Service-specific rule for what should stay below the event boundary as per-occurrence detail. + """ + hasOccurrenceDetailBoundary: Boolean } -type LogEvent implements Node { +type LogEvent { """Unique identifier of the log event""" id: ID! @@ -2058,38 +5492,33 @@ type LogEvent implements Node { """Snake_case identifier unique per service, e.g. nginx_access_log""" name: String! - """ - What the event is and what data instances carry. Helps engineers decide whether to look here. - """ - description: String! - - """ - Predominant log severity level, derived from example records. Nullable when - examples have no severity info. Values: debug, info, warn, error, other. - """ - severity: LogEventSeverity + """Human-readable title for the log event, suitable for product surfaces.""" + displayName: String """ - What role this event serves: diagnostic (investigate incidents), operational - (system behavior), lifecycle (state transitions), ephemeral (transient state). + Whether this event is a recurring fragment of a larger multi-line event rather than a complete semantic event. """ - signalPurpose: LogEventSignalPurpose! + isFragment: Boolean! """ - What this event records: system (internal mechanics), traffic (request flow), - activity (actor+action+resource), control (access/permission decisions). + Stable emitted or structural template for this log-event family, when one is visible. """ - eventNature: LogEventEventNature! + template: String! """ - True when this event represents a multi-line fragment rather than a complete log entry. Set by the classifier. + Predominant log severity level, derived from example records. Nullable when + examples have no severity info. Values: debug = The event usually appears at + debug severity.; info = The event usually appears at info severity.; warn = + The event usually appears at warn severity.; error = The event usually appears + at error severity.; other = The event usually appears at a non-standard or + other severity. """ - isFragment: Boolean! + severity: LogEventSeverity """ - Sample log records captured during discovery, used for AI analysis and pattern validation + Canonical sampled record this event was created from. Matchers must continue to match this record. """ - examples: [LogRecord!]! + targetLog: LogRecord """ Current trailing 7-day average events/hour. Refreshed on volume ingestion. @@ -2101,6 +5530,11 @@ type LogEvent implements Node { """ baselineAvgBytes: Float + """ + Current share of this service's trailing 7-day baseline event volume. Refreshed alongside per-event baselines. + """ + baselineVolumeShareOfService: Float + """When the log event was created""" createdAt: Time! @@ -2110,1338 +5544,839 @@ type LogEvent implements Node { """Service that produces this event""" service: Service! - """Log sample that produced this event during classification""" - logSample: LogSample! + """Contribution, preview, and effective per-log-event policy rows.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! - """Category-specific policies across workspaces""" - policies: [LogEventPolicy!] - - """Fields discovered in this event's example records""" - logEventFields: [LogEventField!] + """Datadog log exclusion filters targeting this log event""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! """ Status of this log event. Shows where the log event is in the preparation pipeline. - Returns null if cache has not been populated yet. + Returns null if the status view has no row yet. """ - status: LogEventStatusCache + status: LogEventStatus + + """Raw matcher rules that identify incoming logs for this event.""" + matchers: JSON! + + """Bucketed log volume for this log event.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! + + """Typed log-event JSONB facts.""" + logEventFacts: LogEventFacts! } -"""A connection to a list of items.""" type LogEventConnection { - """A list of edges.""" - edges: [LogEventEdge] - - """Information to aid in pagination.""" + edges: [LogEventEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! + summary: LogEventSummary! + facets: LogEventFacets! } -"""An edge in a connection.""" type LogEventEdge { - """The item at the end of the edge.""" - node: LogEvent - - """A cursor for use in pagination.""" + node: LogEvent! cursor: Cursor! } -"""LogEventEventNature is enum for the field event_nature""" -enum LogEventEventNature { - system - traffic - activity - control +type LogEventFacets { + isFragment(limit: Int): LogEventIsFragmentFacet! + severities(limit: Int): LogEventSeverityFacet! + services(limit: Int): LogEventServiceFacet! } -type LogEventField implements Node { - """Unique identifier""" - id: ID! +"""Typed log-event JSONB facts.""" +type LogEventFacts { + """Captures the stable local semantic identity of a recurring log event.""" + identityProfile: IdentityProfile """ - Denormalized for tenant isolation. Auto-set via trigger from log_event.account_id. + Captures a clearly exposed separate peer materially involved in a recurring log event. """ - accountID: UUID! - - """The log event this field belongs to""" - logEventID: ID! + attributionProfile: AttributionProfile - """Unambiguous path segments, e.g. {attributes, http, status}""" - fieldPath: [String!]! + """ + Classifies the coarse observability artifact role of a recurring log event. + """ + eventRoleProfile: EventRoleProfile """ - Current trailing 7-day volume-weighted average bytes for this attribute. Refreshed on volume ingestion. + Captures one-copy observability value and additional repeated-stream value for a recurring log event. """ - baselineAvgBytes: Float + observabilityValueProfile: ObservabilityValueProfile - """When this field was last seen in production log samples.""" - lastSeenAt: Time! + """ + Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. + """ + levelExpectationProfile: LevelExpectationProfile - """When this field was first discovered""" - createdAt: Time! + """Captures normal operator-facing visibility for a recurring log event.""" + operatorProminenceProfile: OperatorProminenceProfile - """The log event this field belongs to""" - logEvent: LogEvent! + """ + Lists exact observed paths that expose material sensitive data in a recurring log event. + """ + sensitiveDataProfile: SensitiveDataProfile } -""" -LogEventFieldWhereInput is used for filtering LogEventField objects. -Input was generated by ent. -""" -input LogEventFieldWhereInput { - not: LogEventFieldWhereInput - and: [LogEventFieldWhereInput!] - or: [LogEventFieldWhereInput!] +"""Filters for log-event JSONB facts.""" +input LogEventFactsWhereInput { + """Negated predicates.""" + not: LogEventFactsWhereInput - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Predicates that must all match.""" + and: [LogEventFactsWhereInput!] - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Predicates where at least one must match.""" + or: [LogEventFactsWhereInput!] - """log_event_id field predicates""" - logEventID: ID - logEventIDNEQ: ID - logEventIDIn: [ID!] - logEventIDNotIn: [ID!] + """Captures the stable local semantic identity of a recurring log event.""" + identityProfile: IdentityProfileWhereInput - """field_path field predicates""" - fieldPath: [String!] - fieldPathNEQ: [String!] - fieldPathIn: [[String!]!] - fieldPathNotIn: [[String!]!] - fieldPathGT: [String!] - fieldPathGTE: [String!] - fieldPathLT: [String!] - fieldPathLTE: [String!] - - """baseline_avg_bytes field predicates""" - baselineAvgBytes: Float - baselineAvgBytesNEQ: Float - baselineAvgBytesIn: [Float!] - baselineAvgBytesNotIn: [Float!] - baselineAvgBytesGT: Float - baselineAvgBytesGTE: Float - baselineAvgBytesLT: Float - baselineAvgBytesLTE: Float - baselineAvgBytesIsNil: Boolean - baselineAvgBytesNotNil: Boolean + """ + Captures a clearly exposed separate peer materially involved in a recurring log event. + """ + attributionProfile: AttributionProfileWhereInput - """last_seen_at field predicates""" - lastSeenAt: Time - lastSeenAtNEQ: Time - lastSeenAtIn: [Time!] - lastSeenAtNotIn: [Time!] - lastSeenAtGT: Time - lastSeenAtGTE: Time - lastSeenAtLT: Time - lastSeenAtLTE: Time + """ + Classifies the coarse observability artifact role of a recurring log event. + """ + eventRoleProfile: EventRoleProfileWhereInput - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + Captures one-copy observability value and additional repeated-stream value for a recurring log event. + """ + observabilityValueProfile: ObservabilityValueProfileWhereInput - """log_event edge predicates""" - hasLogEvent: Boolean - hasLogEventWith: [LogEventWhereInput!] + """ + Captures the normalized severity level a recurring log event deserves, independent of the level currently emitted. + """ + levelExpectationProfile: LevelExpectationProfileWhereInput + + """Captures normal operator-facing visibility for a recurring log event.""" + operatorProminenceProfile: OperatorProminenceProfileWhereInput + + """ + Lists exact observed paths that expose material sensitive data in a recurring log event. + """ + sensitiveDataProfile: SensitiveDataProfileWhereInput +} + +type LogEventIsFragmentFacet { + buckets: [LogEventIsFragmentFacetBucket!]! +} + +type LogEventIsFragmentFacetBucket { + value: Boolean! + count: Int! } -"""Ordering options for LogEvent connections""" input LogEventOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order LogEvents.""" field: LogEventOrderField! } -"""Properties by which LogEvent connections can be ordered.""" enum LogEventOrderField { + ID NAME + DISPLAY_NAME SEVERITY - SIGNAL_PURPOSE - EVENT_NATURE CREATED_AT UPDATED_AT + CURRENT_EVENTS_PER_HOUR + CURRENT_TOTAL_USD_PER_HOUR } -type LogEventPolicy implements Node { +type LogEventPolicy { """Unique identifier""" id: ID! """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + Denormalized for tenant isolation. Auto-set via trigger from the policy source. """ accountID: UUID! - """The log event this policy applies to""" - logEventID: ID! + """Issue this policy remediates.""" + issueID: ID - """The workspace that owns this policy""" - workspaceID: ID! + """The log event this policy applies to""" + logEventID: ID """ - Quality issue category this policy addresses. Compliance: pii_leakage, - secrets_leakage, phi_leakage, payment_data_leakage. Waste: health_checks, - bot_traffic, debug_artifacts, malformed, broken_records, commodity_traffic, - redundant_events, dead_weight. Quality: duplicate_fields, - instrumentation_bloat, oversized_fields, wrong_level. + Source that created or owns this log event policy. Values: log_event = Policy + sourced from a catalog log event.; datadog_exclusion_filter = Policy imported + from a Datadog log exclusion filter.; manual = Policy created manually without + a catalog log event source. """ - category: LogEventPolicyCategory! + sourceKind: LogEventPolicySourceKind! - """ - Whether this category requires AI judgment (true) vs mechanically verifiable - (false). Auto-set via trigger from CategoryMeta. - """ - subjective: Boolean! + """Datadog exclusion filter this policy was imported from.""" + sourceDatadogLogExclusionFilterID: ID """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), - or quality (field-level improvements). Auto-set via trigger from CategoryMeta. + Zero-based matcher branch index when one Datadog filter imports into multiple policies. """ - categoryType: LogEventPolicyCategoryType! + sourceBranchIndex: Int """ - Max compliance severity across sensitivity types. NULL for non-compliance categories. Auto-set via trigger. + Policy-owned log matchers used when this policy is not sourced from a catalog log event. """ - severity: LogEventPolicySeverity + matchers: Map """ - What this policy does when enforced: 'drop' (remove all events), 'sample' - (keep at reduced rate), 'filter' (drop subset by field value), 'trim' - (remove/truncate fields), 'none' (informational only). Auto-set via trigger. + Lifecycle state for this log event policy. Values: active = Policy is active + and eligible for publication.; disabled = Policy is retained but not published. """ - action: LogEventPolicyAction! + status: LogEventPolicyStatus! - """When this policy was approved by a user""" - approvedAt: Time + """When this policy row was last compiled from its current inputs.""" + compiledAt: Time! - """User ID who approved this policy""" - approvedBy: String + """When this policy row was created.""" + createdAt: Time! - """ - Baseline volume/hour frozen at approval time. Snapshot of log_event.baseline_volume_per_hour. - """ - approvedBaselineVolumePerHour: Float + """When this policy row was last updated.""" + updatedAt: Time! + + """Issue this policy remediates""" + issue: Issue + + """The log event this policy applies to""" + logEvent: LogEvent + + """Datadog exclusion filter this policy was imported from""" + sourceDatadogLogExclusionFilter: DatadogLogExclusionFilter """ - Baseline avg bytes frozen at approval time. Snapshot of log_event.baseline_avg_bytes. + Stable account-local policy identifier for product links and support workflows. """ - approvedBaselineAvgBytes: Float - - """When this policy was dismissed by a user""" - dismissedAt: Time + displayID: String! - """User ID who dismissed this policy""" - dismissedBy: String + """Account-qualified policy identifier for cross-account presentation.""" + qualifiedDisplayID: String! - """When this policy was created""" - createdAt: Time! + """Execution counter totals for this policy over a time range.""" + executionSummary(input: PolicyExecutionSummaryInput!): PolicyExecutionSummary! - """When this policy was last updated""" - updatedAt: Time! + """Bucketed execution telemetry for this policy.""" + executionTelemetry(input: PolicyTelemetryInput!): PolicyTelemetrySeries! - """The log event this policy applies to""" - logEvent: LogEvent! + """Edge deployments that reported execution telemetry for this policy.""" + deployments(input: LogEventPolicyDeploymentsInput!): [LogEventPolicyDeployment!]! - """The workspace that owns this policy""" - workspace: Workspace! + """Log-event policy spec applied to a log event.""" + spec: LogEventPolicySpec! } -"""LogEventPolicyAction is enum for the field action""" -enum LogEventPolicyAction { - drop - sample - filter - trim - none +type LogEventPolicyConnection { + edges: [LogEventPolicyEdge!]! + pageInfo: PageInfo! + totalCount: Int! } -"""LogEventPolicyCategory is enum for the field category""" -enum LogEventPolicyCategory { - pii_leakage - secrets_leakage - phi_leakage - payment_data_leakage - health_checks - bot_traffic - debug_artifacts - malformed - broken_records - commodity_traffic - redundant_events - dead_weight - duplicate_fields - instrumentation_bloat - oversized_fields - wrong_level -} +"""LogEventPolicy creation input.""" +input LogEventPolicyCreateInput { + """Issue this policy remediates.""" + issueID: ID -type LogEventPolicyCategoryStatusCache implements Node { - id: ID! + """The log event this policy applies to""" + logEventID: ID - """Account ID for tenant isolation""" - accountID: UUID! + """Typed compiled log stream change approved for this policy.""" + spec: LogEventPolicySpecInput! +} + +type LogEventPolicyDeployment { + edgeInstance: EdgeInstance! + executionSummary: PolicyExecutionSummary! + lastHitAt: Time +} - """Quality issue category (e.g., pii_leakage, noise, health_checks)""" - category: String! +input LogEventPolicyDeploymentsInput { + """Half-open range used to decide which deployments reported this policy.""" + range: TimeRangeInput! + where: LogEventPolicyDeploymentWhereInput +} +input LogEventPolicyDeploymentWhereInput { """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). + Restrict deployments to edge instances that synced at or after this time. """ - categoryType: LogEventPolicyCategoryStatusCacheCategoryType! + lastSyncAtGTE: Time +} - """Human-readable category name (e.g., 'PII Leakage')""" - displayName: String! +"""Drop policy settings.""" +type LogEventPolicyDrop { + """Whether matching log events should be dropped.""" + enabled: Boolean! +} - """ - Whether this category requires AI judgment (true) vs mechanically verifiable (false) - """ - subjective: Boolean! +input LogEventPolicyDropInput { + enabled: Boolean! +} - """What this category detects — the fundamental test for membership""" - principle: String! +type LogEventPolicyEdge { + node: LogEventPolicy! + cursor: Cursor! +} - """Where this category stops applying — what NOT to flag""" - boundary: String! +"""Configured field rewrite rules.""" +type LogEventPolicyFieldRewrite { + """Source field path in the event payload.""" + from: [String!]! - """ - What the policy does: drop (remove events), sample (reduce rate), filter (drop - subset), trim (modify fields), none (informational) - """ - action: String + """Destination field path in the event payload.""" + to: [String!]! +} - """Policies awaiting user review in this category""" - pendingCount: Int! +input LogEventPolicyFieldRewriteInput { + from: [String!]! + to: [String!]! +} - """Policies approved by user in this category""" - approvedCount: Int! +input LogEventPolicyOrder { + direction: OrderDirection! = ASC + field: LogEventPolicyOrderField! +} - """Policies dismissed by user in this category""" - dismissedCount: Int! +enum LogEventPolicyOrderField { + ID + SOURCE_KIND + SOURCE_BRANCH_INDEX + STATUS + COMPILED_AT + CREATED_AT + UPDATED_AT +} - """Pending policies with low compliance severity""" - policyPendingLowCount: Int! +type LogEventPolicyProposal { + key: String! + sourceKind: LogEventPolicyProposalSourceKind! + issueID: ID + logEventID: ID + datadogLogExclusionFilterID: ID + sourceBranchIndex: Int + matchers: JSON + spec: LogEventPolicySpec! + compiledPolicies: JSON +} - """Pending policies with medium compliance severity""" - policyPendingMediumCount: Int! +type LogEventPolicyProposalSet { + proposals: [LogEventPolicyProposal!]! + blockers: [String!]! + warnings: [String!]! + unsupportedSemantics: [String!]! + sampleKeyRequired: Boolean! +} - """Pending policies with high compliance severity""" - policyPendingHighCount: Int! +enum LogEventPolicyProposalSourceKind { + issue + datadog_log_exclusion_filter +} - """Pending policies with critical compliance severity""" - policyPendingCriticalCount: Int! +"""Redaction policy settings.""" +type LogEventPolicyRedact { + """Configured redaction entries.""" + entries: [LogEventPolicyRedactEntry!]! +} - """Total log events that have a policy in this category""" - totalEventCount: Int! +"""Configured redaction entries.""" +type LogEventPolicyRedactEntry { + """Event field paths that should be redacted.""" + targetPaths: [[String!]!]! """ - Log events in this category that have volume data (subset of total_event_count) + Optional RE2 pattern that narrows redaction to a substring of the value. When empty, the entire field value is redacted. """ - eventsWithVolumes: Int! - - """Events/hour saved by all pending policies in this category combined""" - estimatedVolumeReductionPerHour: Float - - """Bytes/hour saved by all pending policies in this category combined""" - estimatedBytesReductionPerHour: Float + regex: String """ - Estimated ingestion savings in USD/hour from pending policies in this category + Optional RE2 replacement template applied to the regex match. May reference + capture groups with $1, ${name}, etc. When empty, the matched substring (or + whole value) becomes [REDACTED]. """ - estimatedCostReductionPerHourBytesUsd: Float + replacement: String +} + +input LogEventPolicyRedactEntryInput { + targetPaths: [[String!]!] """ - Estimated indexing savings in USD/hour from pending policies in this category + Optional RE2 pattern that narrows redaction to a substring of the value. + Empty or null means the entire field value is redacted. """ - estimatedCostReductionPerHourVolumeUsd: Float + regex: String """ - Estimated total savings in USD/hour from pending policies in this category + Optional RE2 replacement template applied to regex matches. May reference + capture groups with $1, ${name}, etc. Empty or null means the match + becomes [REDACTED]. Requires regex to be set. """ - estimatedCostReductionPerHourUsd: Float - refreshedAt: Time! + replacement: String } -""" -LogEventPolicyCategoryStatusCacheCategoryType is enum for the field category_type -""" -enum LogEventPolicyCategoryStatusCacheCategoryType { - compliance - waste - quality +input LogEventPolicyRedactInput { + entries: [LogEventPolicyRedactEntryInput!] } -""" -LogEventPolicyCategoryStatusCacheWhereInput is used for filtering LogEventPolicyCategoryStatusCache objects. -Input was generated by ent. -""" -input LogEventPolicyCategoryStatusCacheWhereInput { - not: LogEventPolicyCategoryStatusCacheWhereInput - and: [LogEventPolicyCategoryStatusCacheWhereInput!] - or: [LogEventPolicyCategoryStatusCacheWhereInput!] +"""Rewrite policy settings.""" +type LogEventPolicyRewrite { + """Severity rewrite applied to the event.""" + severity: LogEventPolicySeverityRewrite - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Configured field rewrite rules.""" + fields: [LogEventPolicyFieldRewrite!]! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +input LogEventPolicyRewriteInput { + severity: LogEventPolicySeverityRewriteInput + fields: [LogEventPolicyFieldRewriteInput!] +} - """category field predicates""" - category: String - categoryNEQ: String - categoryIn: [String!] - categoryNotIn: [String!] - categoryGT: String - categoryGTE: String - categoryLT: String - categoryLTE: String - categoryContains: String - categoryHasPrefix: String - categoryHasSuffix: String - categoryEqualFold: String - categoryContainsFold: String - - """category_type field predicates""" - categoryType: LogEventPolicyCategoryStatusCacheCategoryType - categoryTypeNEQ: LogEventPolicyCategoryStatusCacheCategoryType - categoryTypeIn: [LogEventPolicyCategoryStatusCacheCategoryType!] - categoryTypeNotIn: [LogEventPolicyCategoryStatusCacheCategoryType!] - - """display_name field predicates""" - displayName: String - displayNameNEQ: String - displayNameIn: [String!] - displayNameNotIn: [String!] - displayNameGT: String - displayNameGTE: String - displayNameLT: String - displayNameLTE: String - displayNameContains: String - displayNameHasPrefix: String - displayNameHasSuffix: String - displayNameEqualFold: String - displayNameContainsFold: String +"""Sampling policy settings.""" +type LogEventPolicySample { + """Whether sampling is enabled.""" + enabled: Boolean! - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """principle field predicates""" - principle: String - principleNEQ: String - principleIn: [String!] - principleNotIn: [String!] - principleGT: String - principleGTE: String - principleLT: String - principleLTE: String - principleContains: String - principleHasPrefix: String - principleHasSuffix: String - principleEqualFold: String - principleContainsFold: String - - """boundary field predicates""" - boundary: String - boundaryNEQ: String - boundaryIn: [String!] - boundaryNotIn: [String!] - boundaryGT: String - boundaryGTE: String - boundaryLT: String - boundaryLTE: String - boundaryContains: String - boundaryHasPrefix: String - boundaryHasSuffix: String - boundaryEqualFold: String - boundaryContainsFold: String - - """action field predicates""" - action: String - actionNEQ: String - actionIn: [String!] - actionNotIn: [String!] - actionGT: String - actionGTE: String - actionLT: String - actionLTE: String - actionContains: String - actionHasPrefix: String - actionHasSuffix: String - actionIsNil: Boolean - actionNotNil: Boolean - actionEqualFold: String - actionContainsFold: String - - """pending_count field predicates""" - pendingCount: Int - pendingCountNEQ: Int - pendingCountIn: [Int!] - pendingCountNotIn: [Int!] - pendingCountGT: Int - pendingCountGTE: Int - pendingCountLT: Int - pendingCountLTE: Int - - """approved_count field predicates""" - approvedCount: Int - approvedCountNEQ: Int - approvedCountIn: [Int!] - approvedCountNotIn: [Int!] - approvedCountGT: Int - approvedCountGTE: Int - approvedCountLT: Int - approvedCountLTE: Int - - """dismissed_count field predicates""" - dismissedCount: Int - dismissedCountNEQ: Int - dismissedCountIn: [Int!] - dismissedCountNotIn: [Int!] - dismissedCountGT: Int - dismissedCountGTE: Int - dismissedCountLT: Int - dismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """total_event_count field predicates""" - totalEventCount: Int - totalEventCountNEQ: Int - totalEventCountIn: [Int!] - totalEventCountNotIn: [Int!] - totalEventCountGT: Int - totalEventCountGTE: Int - totalEventCountLT: Int - totalEventCountLTE: Int - - """events_with_volumes field predicates""" - eventsWithVolumes: Int - eventsWithVolumesNEQ: Int - eventsWithVolumesIn: [Int!] - eventsWithVolumesNotIn: [Int!] - eventsWithVolumesGT: Int - eventsWithVolumesGTE: Int - eventsWithVolumesLT: Int - eventsWithVolumesLTE: Int - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time + """Sampling interval in seconds when sampling is enabled.""" + intervalSeconds: Int + + """ + Percentage of matching events to keep when percentage sampling is enabled. + """ + keepPercentage: Float + + """Log-record path used as the deterministic sampling key.""" + sampleKey: [String!] } -"""LogEventPolicyCategoryType is enum for the field category_type""" -enum LogEventPolicyCategoryType { - compliance - waste - quality +input LogEventPolicySampleInput { + enabled: Boolean! + intervalSeconds: Int! } -"""A connection to a list of items.""" -type LogEventPolicyConnection { - """A list of edges.""" - edges: [LogEventPolicyEdge] +"""Severity rewrite applied to the event.""" +type LogEventPolicySeverityRewrite { + """New severity value assigned to the event.""" + value: String! +} - """Information to aid in pagination.""" - pageInfo: PageInfo! +input LogEventPolicySeverityRewriteInput { + value: String! +} - """Identifies the total count of items in the connection.""" - totalCount: Int! +enum LogEventPolicySourceKind { + log_event + datadog_exclusion_filter + manual } -"""An edge in a connection.""" -type LogEventPolicyEdge { - """The item at the end of the edge.""" - node: LogEventPolicy +"""Log-event policy spec applied to a log event.""" +type LogEventPolicySpec { + """Drop policy settings.""" + drop: LogEventPolicyDrop! - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Sampling policy settings.""" + sample: LogEventPolicySample! -"""Ordering options for LogEventPolicy connections""" -input LogEventPolicyOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Trim policy settings.""" + trim: LogEventPolicyTrim! - """The field by which to order LogEventPolicies.""" - field: LogEventPolicyOrderField! -} + """Redaction policy settings.""" + redact: LogEventPolicyRedact! -"""Properties by which LogEventPolicy connections can be ordered.""" -enum LogEventPolicyOrderField { - CATEGORY - SUBJECTIVE - CATEGORY_TYPE - SEVERITY - ACTION - APPROVED_AT - DISMISSED_AT - CREATED_AT - UPDATED_AT + """Rewrite policy settings.""" + rewrite: LogEventPolicyRewrite! } -"""LogEventPolicySeverity is enum for the field severity""" -enum LogEventPolicySeverity { - low - medium - high - critical +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecDropWhereInput { + not: LogEventPolicySpecDropWhereInput + and: [LogEventPolicySpecDropWhereInput!] + or: [LogEventPolicySpecDropWhereInput!] + + """Whether matching log events should be dropped.""" + enabledEQ: Boolean + + """Whether matching log events should be dropped.""" + enabledNEQ: Boolean } -type LogEventPolicyStatusCache implements Node { - id: ID! - - """Account ID for tenant isolation""" - accountID: UUID! +input LogEventPolicySpecInput { + drop: LogEventPolicyDropInput + sample: LogEventPolicySampleInput + trim: LogEventPolicyTrimInput + redact: LogEventPolicyRedactInput + rewrite: LogEventPolicyRewriteInput +} - """The policy this status row represents""" - policyID: ID! +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRedactWhereInput { + not: LogEventPolicySpecRedactWhereInput + and: [LogEventPolicySpecRedactWhereInput!] + or: [LogEventPolicySpecRedactWhereInput!] - """The log event this policy targets""" - logEventID: UUID! + """Configured redaction entries.""" + hasEntries: Boolean +} - """The workspace that owns this policy""" - workspaceID: UUID! +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRewriteSeverityWhereInput { + not: LogEventPolicySpecRewriteSeverityWhereInput + and: [LogEventPolicySpecRewriteSeverityWhereInput!] + or: [LogEventPolicySpecRewriteSeverityWhereInput!] - """ - Quality issue category this policy addresses (e.g., pii_leakage, noise, health_checks) - """ - category: String! + """New severity value assigned to the event.""" + valueEQ: String - """ - User decision on this policy. PENDING (awaiting review), APPROVED (accepted - for enforcement), DISMISSED (rejected by user). - """ - status: LogEventPolicyStatusCacheStatus! + """New severity value assigned to the event.""" + valueNEQ: String - """ - Whether this category requires AI judgment (true) vs mechanically verifiable (false) - """ - subjective: Boolean! + """New severity value assigned to the event.""" + valueIn: [String!] - """ - Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - """ - categoryType: LogEventPolicyStatusCacheCategoryType! + """New severity value assigned to the event.""" + valueNotIn: [String!] - """ - Max compliance severity across sensitivity types. NULL for non-compliance categories. - """ - severity: LogEventPolicyStatusCacheSeverity + """New severity value assigned to the event.""" + valueContains: String - """When this policy was approved by a user""" - approvedAt: Time + """New severity value assigned to the event.""" + valueHasPrefix: String - """When this policy was dismissed by a user""" - dismissedAt: Time + """New severity value assigned to the event.""" + valueHasSuffix: String - """When this policy was created""" - createdAt: Time! + """New severity value assigned to the event.""" + valueEqualFold: String - """ - What the policy does: drop (remove events), sample (reduce rate), filter (drop - subset), trim (modify fields), none (informational) - """ - action: String + """New severity value assigned to the event.""" + valueContainsFold: String - """ - Fraction of events that survive this policy (0.0 = all dropped, 1.0 = all kept). NULL if not estimable. - """ - survivalRate: Float + """New severity value assigned to the event.""" + valuePresent: Boolean +} - """Events/hour saved if this policy applied alone. NULL if not estimable.""" - estimatedVolumeReductionPerHour: Float +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecRewriteWhereInput { + not: LogEventPolicySpecRewriteWhereInput + and: [LogEventPolicySpecRewriteWhereInput!] + or: [LogEventPolicySpecRewriteWhereInput!] - """Bytes/hour saved if this policy applied alone. NULL if not estimable.""" - estimatedBytesReductionPerHour: Float + """Severity rewrite applied to the event.""" + severity: LogEventPolicySpecRewriteSeverityWhereInput - """Estimated ingestion savings in USD/hour from bytes reduction""" - estimatedCostReductionPerHourBytesUsd: Float + """Configured field rewrite rules.""" + hasFields: Boolean +} - """Estimated indexing savings in USD/hour from volume reduction""" - estimatedCostReductionPerHourVolumeUsd: Float +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecSampleWhereInput { + not: LogEventPolicySpecSampleWhereInput + and: [LogEventPolicySpecSampleWhereInput!] + or: [LogEventPolicySpecSampleWhereInput!] - """Estimated total savings in USD/hour (bytes + volume)""" - estimatedCostReductionPerHourUsd: Float + """Whether sampling is enabled.""" + enabledEQ: Boolean - """Service that produces the targeted log event (denormalized)""" - serviceID: UUID + """Whether sampling is enabled.""" + enabledNEQ: Boolean +} - """Name of the service (denormalized for display)""" - serviceName: String +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecTrimWhereInput { + not: LogEventPolicySpecTrimWhereInput + and: [LogEventPolicySpecTrimWhereInput!] + or: [LogEventPolicySpecTrimWhereInput!] - """Name of the targeted log event (denormalized for display)""" - logEventName: String + """Whether trimming is enabled.""" + enabledEQ: Boolean - """Current throughput of the targeted log event in events/hour""" - volumePerHour: Float + """Whether trimming is enabled.""" + enabledNEQ: Boolean - """Current throughput of the targeted log event in bytes/hour""" - bytesPerHour: Float - refreshedAt: Time! + """Field paths that should be trimmed from the event payload.""" + hasTargetPaths: Boolean } -""" -LogEventPolicyStatusCacheCategoryType is enum for the field category_type -""" -enum LogEventPolicyStatusCacheCategoryType { - compliance - waste - quality -} +"""Log-event policy spec applied to a log event.""" +input LogEventPolicySpecWhereInput { + not: LogEventPolicySpecWhereInput + and: [LogEventPolicySpecWhereInput!] + or: [LogEventPolicySpecWhereInput!] -"""LogEventPolicyStatusCacheSeverity is enum for the field severity""" -enum LogEventPolicyStatusCacheSeverity { - low - medium - high - critical -} + """Drop policy settings.""" + drop: LogEventPolicySpecDropWhereInput -"""LogEventPolicyStatusCacheStatus is enum for the field status""" -enum LogEventPolicyStatusCacheStatus { - PENDING - APPROVED - DISMISSED -} + """Sampling policy settings.""" + sample: LogEventPolicySpecSampleWhereInput -""" -LogEventPolicyStatusCacheWhereInput is used for filtering LogEventPolicyStatusCache objects. -Input was generated by ent. -""" -input LogEventPolicyStatusCacheWhereInput { - not: LogEventPolicyStatusCacheWhereInput - and: [LogEventPolicyStatusCacheWhereInput!] - or: [LogEventPolicyStatusCacheWhereInput!] + """Trim policy settings.""" + trim: LogEventPolicySpecTrimWhereInput - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """Redaction policy settings.""" + redact: LogEventPolicySpecRedactWhereInput - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Rewrite policy settings.""" + rewrite: LogEventPolicySpecRewriteWhereInput - """policy_id field predicates""" - policyID: ID - policyIDNEQ: ID - policyIDIn: [ID!] - policyIDNotIn: [ID!] - - """log_event_id field predicates""" - logEventID: UUID - logEventIDNEQ: UUID - logEventIDIn: [UUID!] - logEventIDNotIn: [UUID!] - logEventIDGT: UUID - logEventIDGTE: UUID - logEventIDLT: UUID - logEventIDLTE: UUID - - """workspace_id field predicates""" - workspaceID: UUID - workspaceIDNEQ: UUID - workspaceIDIn: [UUID!] - workspaceIDNotIn: [UUID!] - workspaceIDGT: UUID - workspaceIDGTE: UUID - workspaceIDLT: UUID - workspaceIDLTE: UUID - - """category field predicates""" - category: String - categoryNEQ: String - categoryIn: [String!] - categoryNotIn: [String!] - categoryGT: String - categoryGTE: String - categoryLT: String - categoryLTE: String - categoryContains: String - categoryHasPrefix: String - categoryHasSuffix: String - categoryEqualFold: String - categoryContainsFold: String - - """status field predicates""" - status: LogEventPolicyStatusCacheStatus - statusNEQ: LogEventPolicyStatusCacheStatus - statusIn: [LogEventPolicyStatusCacheStatus!] - statusNotIn: [LogEventPolicyStatusCacheStatus!] - - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """category_type field predicates""" - categoryType: LogEventPolicyStatusCacheCategoryType - categoryTypeNEQ: LogEventPolicyStatusCacheCategoryType - categoryTypeIn: [LogEventPolicyStatusCacheCategoryType!] - categoryTypeNotIn: [LogEventPolicyStatusCacheCategoryType!] - - """severity field predicates""" - severity: LogEventPolicyStatusCacheSeverity - severityNEQ: LogEventPolicyStatusCacheSeverity - severityIn: [LogEventPolicyStatusCacheSeverity!] - severityNotIn: [LogEventPolicyStatusCacheSeverity!] - severityIsNil: Boolean - severityNotNil: Boolean + """"HasSelectionOperations" method predicates.""" + hasSelectionOperations: Boolean - """approved_at field predicates""" - approvedAt: Time - approvedAtNEQ: Time - approvedAtIn: [Time!] - approvedAtNotIn: [Time!] - approvedAtGT: Time - approvedAtGTE: Time - approvedAtLT: Time - approvedAtLTE: Time - approvedAtIsNil: Boolean - approvedAtNotNil: Boolean - - """dismissed_at field predicates""" - dismissedAt: Time - dismissedAtNEQ: Time - dismissedAtIn: [Time!] - dismissedAtNotIn: [Time!] - dismissedAtGT: Time - dismissedAtGTE: Time - dismissedAtLT: Time - dismissedAtLTE: Time - dismissedAtIsNil: Boolean - dismissedAtNotNil: Boolean - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"HasTransformOperations" method predicates.""" + hasTransformOperations: Boolean +} - """action field predicates""" - action: String - actionNEQ: String - actionIn: [String!] - actionNotIn: [String!] - actionGT: String - actionGTE: String - actionLT: String - actionLTE: String - actionContains: String - actionHasPrefix: String - actionHasSuffix: String - actionIsNil: Boolean - actionNotNil: Boolean - actionEqualFold: String - actionContainsFold: String - - """survival_rate field predicates""" - survivalRate: Float - survivalRateNEQ: Float - survivalRateIn: [Float!] - survivalRateNotIn: [Float!] - survivalRateGT: Float - survivalRateGTE: Float - survivalRateLT: Float - survivalRateLTE: Float - survivalRateIsNil: Boolean - survivalRateNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """service_id field predicates""" - serviceID: UUID - serviceIDNEQ: UUID - serviceIDIn: [UUID!] - serviceIDNotIn: [UUID!] - serviceIDGT: UUID - serviceIDGTE: UUID - serviceIDLT: UUID - serviceIDLTE: UUID - serviceIDIsNil: Boolean - serviceIDNotNil: Boolean - - """service_name field predicates""" - serviceName: String - serviceNameNEQ: String - serviceNameIn: [String!] - serviceNameNotIn: [String!] - serviceNameGT: String - serviceNameGTE: String - serviceNameLT: String - serviceNameLTE: String - serviceNameContains: String - serviceNameHasPrefix: String - serviceNameHasSuffix: String - serviceNameIsNil: Boolean - serviceNameNotNil: Boolean - serviceNameEqualFold: String - serviceNameContainsFold: String +enum LogEventPolicyStatus { + active + disabled +} - """log_event_name field predicates""" - logEventName: String - logEventNameNEQ: String - logEventNameIn: [String!] - logEventNameNotIn: [String!] - logEventNameGT: String - logEventNameGTE: String - logEventNameLT: String - logEventNameLTE: String - logEventNameContains: String - logEventNameHasPrefix: String - logEventNameHasSuffix: String - logEventNameIsNil: Boolean - logEventNameNotNil: Boolean - logEventNameEqualFold: String - logEventNameContainsFold: String - - """volume_per_hour field predicates""" - volumePerHour: Float - volumePerHourNEQ: Float - volumePerHourIn: [Float!] - volumePerHourNotIn: [Float!] - volumePerHourGT: Float - volumePerHourGTE: Float - volumePerHourLT: Float - volumePerHourLTE: Float - volumePerHourIsNil: Boolean - volumePerHourNotNil: Boolean - - """bytes_per_hour field predicates""" - bytesPerHour: Float - bytesPerHourNEQ: Float - bytesPerHourIn: [Float!] - bytesPerHourNotIn: [Float!] - bytesPerHourGT: Float - bytesPerHourGTE: Float - bytesPerHourLT: Float - bytesPerHourLTE: Float - bytesPerHourIsNil: Boolean - bytesPerHourNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time +"""Trim policy settings.""" +type LogEventPolicyTrim { + """Whether trimming is enabled.""" + enabled: Boolean! + + """Field paths that should be trimmed from the event payload.""" + targetPaths: [[String!]!]! +} + +input LogEventPolicyTrimInput { + enabled: Boolean! + targetPaths: [[String!]!] } -""" -LogEventPolicyWhereInput is used for filtering LogEventPolicy objects. -Input was generated by ent. -""" input LogEventPolicyWhereInput { not: LogEventPolicyWhereInput and: [LogEventPolicyWhereInput!] or: [LogEventPolicyWhereInput!] - """id field predicates""" + """"issue" edge predicates.""" + issue: IssueWhereInput + + """Whether the "issue" edge has at least one related row.""" + hasIssue: Boolean + + """"log_event" edge predicates.""" + logEvent: LogEventWhereInput + + """Whether the "log_event" edge has at least one related row.""" + hasLogEvent: Boolean + + """"source_datadog_log_exclusion_filter" edge predicates.""" + sourceDatadogLogExclusionFilter: DatadogLogExclusionFilterWhereInput + + """ + Whether the "source_datadog_log_exclusion_filter" edge has at least one related row. + """ + hasSourceDatadogLogExclusionFilter: Boolean + + """"spec" JSONB field predicates.""" + spec: LogEventPolicySpecWhereInput + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"issue_id" field predicates.""" + issueID: ID + + """"issue_id" field predicates.""" + issueIDNEQ: ID + + """"issue_id" field predicates.""" + issueIDIn: [ID!] - """log_event_id field predicates""" + """"issue_id" field predicates.""" + issueIDNotIn: [ID!] + + """"issue_id" field predicates.""" + issueIDIsNil: Boolean + + """"issue_id" field predicates.""" + issueIDNotNil: Boolean + + """"log_event_id" field predicates.""" logEventID: ID + + """"log_event_id" field predicates.""" logEventIDNEQ: ID + + """"log_event_id" field predicates.""" logEventIDIn: [ID!] + + """"log_event_id" field predicates.""" logEventIDNotIn: [ID!] - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] - - """category field predicates""" - category: LogEventPolicyCategory - categoryNEQ: LogEventPolicyCategory - categoryIn: [LogEventPolicyCategory!] - categoryNotIn: [LogEventPolicyCategory!] - - """subjective field predicates""" - subjective: Boolean - subjectiveNEQ: Boolean - - """category_type field predicates""" - categoryType: LogEventPolicyCategoryType - categoryTypeNEQ: LogEventPolicyCategoryType - categoryTypeIn: [LogEventPolicyCategoryType!] - categoryTypeNotIn: [LogEventPolicyCategoryType!] - - """severity field predicates""" - severity: LogEventPolicySeverity - severityNEQ: LogEventPolicySeverity - severityIn: [LogEventPolicySeverity!] - severityNotIn: [LogEventPolicySeverity!] - severityIsNil: Boolean - severityNotNil: Boolean + """"log_event_id" field predicates.""" + logEventIDIsNil: Boolean + + """"log_event_id" field predicates.""" + logEventIDNotNil: Boolean + + """"source_kind" field predicates.""" + sourceKind: LogEventPolicySourceKind + + """"source_kind" field predicates.""" + sourceKindNEQ: LogEventPolicySourceKind + + """"source_kind" field predicates.""" + sourceKindIn: [LogEventPolicySourceKind!] + + """"source_kind" field predicates.""" + sourceKindNotIn: [LogEventPolicySourceKind!] + + """"source_branch_index" field predicates.""" + sourceBranchIndex: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexNEQ: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexIn: [Int!] + + """"source_branch_index" field predicates.""" + sourceBranchIndexNotIn: [Int!] + + """"source_branch_index" field predicates.""" + sourceBranchIndexGT: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexGTE: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexLT: Int - """action field predicates""" - action: LogEventPolicyAction - actionNEQ: LogEventPolicyAction - actionIn: [LogEventPolicyAction!] - actionNotIn: [LogEventPolicyAction!] - - """approved_at field predicates""" - approvedAt: Time - approvedAtNEQ: Time - approvedAtIn: [Time!] - approvedAtNotIn: [Time!] - approvedAtGT: Time - approvedAtGTE: Time - approvedAtLT: Time - approvedAtLTE: Time - approvedAtIsNil: Boolean - approvedAtNotNil: Boolean - - """approved_by field predicates""" - approvedBy: String - approvedByNEQ: String - approvedByIn: [String!] - approvedByNotIn: [String!] - approvedByGT: String - approvedByGTE: String - approvedByLT: String - approvedByLTE: String - approvedByContains: String - approvedByHasPrefix: String - approvedByHasSuffix: String - approvedByIsNil: Boolean - approvedByNotNil: Boolean - approvedByEqualFold: String - approvedByContainsFold: String - - """approved_baseline_volume_per_hour field predicates""" - approvedBaselineVolumePerHour: Float - approvedBaselineVolumePerHourNEQ: Float - approvedBaselineVolumePerHourIn: [Float!] - approvedBaselineVolumePerHourNotIn: [Float!] - approvedBaselineVolumePerHourGT: Float - approvedBaselineVolumePerHourGTE: Float - approvedBaselineVolumePerHourLT: Float - approvedBaselineVolumePerHourLTE: Float - approvedBaselineVolumePerHourIsNil: Boolean - approvedBaselineVolumePerHourNotNil: Boolean - - """approved_baseline_avg_bytes field predicates""" - approvedBaselineAvgBytes: Float - approvedBaselineAvgBytesNEQ: Float - approvedBaselineAvgBytesIn: [Float!] - approvedBaselineAvgBytesNotIn: [Float!] - approvedBaselineAvgBytesGT: Float - approvedBaselineAvgBytesGTE: Float - approvedBaselineAvgBytesLT: Float - approvedBaselineAvgBytesLTE: Float - approvedBaselineAvgBytesIsNil: Boolean - approvedBaselineAvgBytesNotNil: Boolean - - """dismissed_at field predicates""" - dismissedAt: Time - dismissedAtNEQ: Time - dismissedAtIn: [Time!] - dismissedAtNotIn: [Time!] - dismissedAtGT: Time - dismissedAtGTE: Time - dismissedAtLT: Time - dismissedAtLTE: Time - dismissedAtIsNil: Boolean - dismissedAtNotNil: Boolean - - """dismissed_by field predicates""" - dismissedBy: String - dismissedByNEQ: String - dismissedByIn: [String!] - dismissedByNotIn: [String!] - dismissedByGT: String - dismissedByGTE: String - dismissedByLT: String - dismissedByLTE: String - dismissedByContains: String - dismissedByHasPrefix: String - dismissedByHasSuffix: String - dismissedByIsNil: Boolean - dismissedByNotNil: Boolean - dismissedByEqualFold: String - dismissedByContainsFold: String - - """model field predicates""" - model: String - modelNEQ: String - modelIn: [String!] - modelNotIn: [String!] - modelGT: String - modelGTE: String - modelLT: String - modelLTE: String - modelContains: String - modelHasPrefix: String - modelHasSuffix: String - modelEqualFold: String - modelContainsFold: String - - """created_at field predicates""" + """"source_branch_index" field predicates.""" + sourceBranchIndexLTE: Int + + """"source_branch_index" field predicates.""" + sourceBranchIndexIsNil: Boolean + + """"source_branch_index" field predicates.""" + sourceBranchIndexNotNil: Boolean + + """"status" field predicates.""" + status: LogEventPolicyStatus + + """"status" field predicates.""" + statusNEQ: LogEventPolicyStatus + + """"status" field predicates.""" + statusIn: [LogEventPolicyStatus!] + + """"status" field predicates.""" + statusNotIn: [LogEventPolicyStatus!] + + """"compiled_at" field predicates.""" + compiledAt: Time + + """"compiled_at" field predicates.""" + compiledAtNEQ: Time + + """"compiled_at" field predicates.""" + compiledAtIn: [Time!] + + """"compiled_at" field predicates.""" + compiledAtNotIn: [Time!] + + """"compiled_at" field predicates.""" + compiledAtGT: Time + + """"compiled_at" field predicates.""" + compiledAtGTE: Time + + """"compiled_at" field predicates.""" + compiledAtLT: Time + + """"compiled_at" field predicates.""" + compiledAtLTE: Time + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time - """log_event edge predicates""" - hasLogEvent: Boolean - hasLogEventWith: [LogEventWhereInput!] + """Edge instance that reported execution telemetry for this policy.""" + edgeInstanceID: ID + + """Edge instances that reported execution telemetry for this policy.""" + edgeInstanceIDIn: [ID!] +} - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] +type LogEventServiceFacet { + buckets: [LogEventServiceFacetBucket!]! +} + +type LogEventServiceFacetBucket { + service: Service! + count: Int! } -"""LogEventSeverity is enum for the field severity""" enum LogEventSeverity { debug info @@ -3450,584 +6385,411 @@ enum LogEventSeverity { other } -"""LogEventSignalPurpose is enum for the field signal_purpose""" -enum LogEventSignalPurpose { - diagnostic - operational - lifecycle - ephemeral +type LogEventSeverityFacet { + buckets: [LogEventSeverityFacetBucket!]! } -type LogEventStatusCache implements Node { - id: ID! - accountID: UUID! - logEventID: ID! - serviceID: UUID! - hasVolumes: Boolean! - hasBeenAnalyzed: Boolean! - policyCount: Int! - pendingPolicyCount: Int! - approvedPolicyCount: Int! - dismissedPolicyCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - volumePerHour: Float - bytesPerHour: Float - costPerHourBytesUsd: Float - costPerHourVolumeUsd: Float - costPerHourUsd: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! +type LogEventSeverityFacetBucket { + value: LogEventSeverity! + count: Int! } -""" -LogEventStatusCacheWhereInput is used for filtering LogEventStatusCache objects. -Input was generated by ent. -""" -input LogEventStatusCacheWhereInput { - not: LogEventStatusCacheWhereInput - and: [LogEventStatusCacheWhereInput!] - or: [LogEventStatusCacheWhereInput!] - - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID - - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +type LogEventStatus { + signals: LogEventStatusSignals! + current: StatusMeasurementTotals! + preview: StatusScenario! + effective: StatusScenario! +} - """log_event_id field predicates""" - logEventID: ID - logEventIDNEQ: ID - logEventIDIn: [ID!] - logEventIDNotIn: [ID!] +type LogEventStatusSignals { + hasVolumes: Boolean! + hasBeenAnalyzed: Boolean! + hasPreviewPolicy: Boolean! + hasEffectivePolicy: Boolean! +} - """service_id field predicates""" - serviceID: UUID - serviceIDNEQ: UUID - serviceIDIn: [UUID!] - serviceIDNotIn: [UUID!] - serviceIDGT: UUID - serviceIDGTE: UUID - serviceIDLT: UUID - serviceIDLTE: UUID - - """has_volumes field predicates""" - hasVolumes: Boolean - hasVolumesNEQ: Boolean - - """has_been_analyzed field predicates""" - hasBeenAnalyzed: Boolean - hasBeenAnalyzedNEQ: Boolean - - """policy_count field predicates""" - policyCount: Int - policyCountNEQ: Int - policyCountIn: [Int!] - policyCountNotIn: [Int!] - policyCountGT: Int - policyCountGTE: Int - policyCountLT: Int - policyCountLTE: Int - - """pending_policy_count field predicates""" - pendingPolicyCount: Int - pendingPolicyCountNEQ: Int - pendingPolicyCountIn: [Int!] - pendingPolicyCountNotIn: [Int!] - pendingPolicyCountGT: Int - pendingPolicyCountGTE: Int - pendingPolicyCountLT: Int - pendingPolicyCountLTE: Int - - """approved_policy_count field predicates""" - approvedPolicyCount: Int - approvedPolicyCountNEQ: Int - approvedPolicyCountIn: [Int!] - approvedPolicyCountNotIn: [Int!] - approvedPolicyCountGT: Int - approvedPolicyCountGTE: Int - approvedPolicyCountLT: Int - approvedPolicyCountLTE: Int - - """dismissed_policy_count field predicates""" - dismissedPolicyCount: Int - dismissedPolicyCountNEQ: Int - dismissedPolicyCountIn: [Int!] - dismissedPolicyCountNotIn: [Int!] - dismissedPolicyCountGT: Int - dismissedPolicyCountGTE: Int - dismissedPolicyCountLT: Int - dismissedPolicyCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """volume_per_hour field predicates""" - volumePerHour: Float - volumePerHourNEQ: Float - volumePerHourIn: [Float!] - volumePerHourNotIn: [Float!] - volumePerHourGT: Float - volumePerHourGTE: Float - volumePerHourLT: Float - volumePerHourLTE: Float - volumePerHourIsNil: Boolean - volumePerHourNotNil: Boolean - - """bytes_per_hour field predicates""" - bytesPerHour: Float - bytesPerHourNEQ: Float - bytesPerHourIn: [Float!] - bytesPerHourNotIn: [Float!] - bytesPerHourGT: Float - bytesPerHourGTE: Float - bytesPerHourLT: Float - bytesPerHourLTE: Float - bytesPerHourIsNil: Boolean - bytesPerHourNotNil: Boolean - - """cost_per_hour_bytes_usd field predicates""" - costPerHourBytesUsd: Float - costPerHourBytesUsdNEQ: Float - costPerHourBytesUsdIn: [Float!] - costPerHourBytesUsdNotIn: [Float!] - costPerHourBytesUsdGT: Float - costPerHourBytesUsdGTE: Float - costPerHourBytesUsdLT: Float - costPerHourBytesUsdLTE: Float - costPerHourBytesUsdIsNil: Boolean - costPerHourBytesUsdNotNil: Boolean - - """cost_per_hour_volume_usd field predicates""" - costPerHourVolumeUsd: Float - costPerHourVolumeUsdNEQ: Float - costPerHourVolumeUsdIn: [Float!] - costPerHourVolumeUsdNotIn: [Float!] - costPerHourVolumeUsdGT: Float - costPerHourVolumeUsdGTE: Float - costPerHourVolumeUsdLT: Float - costPerHourVolumeUsdLTE: Float - costPerHourVolumeUsdIsNil: Boolean - costPerHourVolumeUsdNotNil: Boolean - - """cost_per_hour_usd field predicates""" - costPerHourUsd: Float - costPerHourUsdNEQ: Float - costPerHourUsdIn: [Float!] - costPerHourUsdNotIn: [Float!] - costPerHourUsdGT: Float - costPerHourUsdGTE: Float - costPerHourUsdLT: Float - costPerHourUsdLTE: Float - costPerHourUsdIsNil: Boolean - costPerHourUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time +type LogEventSummary { + count: Int! } -""" -LogEventWhereInput is used for filtering LogEvent objects. -Input was generated by ent. -""" input LogEventWhereInput { not: LogEventWhereInput and: [LogEventWhereInput!] or: [LogEventWhereInput!] - """id field predicates""" + """"service" edge predicates.""" + service: ServiceWhereInput + + """Whether the "service" edge has at least one related row.""" + hasService: Boolean + + """"log_event_policies" edge predicates.""" + logEventPolicies: LogEventPolicyWhereInput + + """Whether the "log_event_policies" edge has at least one related row.""" + hasLogEventPolicies: Boolean + + """"datadog_log_exclusion_filters" edge predicates.""" + datadogLogExclusionFilters: DatadogLogExclusionFilterWhereInput + + """ + Whether the "datadog_log_exclusion_filters" edge has at least one related row. + """ + hasDatadogLogExclusionFilters: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID - idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"id" field predicates.""" + idLTE: ID - """service_id field predicates""" + """"service_id" field predicates.""" serviceID: ID + + """"service_id" field predicates.""" serviceIDNEQ: ID + + """"service_id" field predicates.""" serviceIDIn: [ID!] + + """"service_id" field predicates.""" serviceIDNotIn: [ID!] - """name field predicates""" + """"log_sample_id" field predicates.""" + logSampleID: ID + + """"log_sample_id" field predicates.""" + logSampleIDNEQ: ID + + """"log_sample_id" field predicates.""" + logSampleIDIn: [ID!] + + """"log_sample_id" field predicates.""" + logSampleIDNotIn: [ID!] + + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """description field predicates""" - description: String - descriptionNEQ: String - descriptionIn: [String!] - descriptionNotIn: [String!] - descriptionGT: String - descriptionGTE: String - descriptionLT: String - descriptionLTE: String - descriptionContains: String - descriptionHasPrefix: String - descriptionHasSuffix: String - descriptionEqualFold: String - descriptionContainsFold: String + """"display_name" field predicates.""" + displayName: String + + """"display_name" field predicates.""" + displayNameNEQ: String + + """"display_name" field predicates.""" + displayNameIn: [String!] + + """"display_name" field predicates.""" + displayNameNotIn: [String!] + + """"display_name" field predicates.""" + displayNameGT: String + + """"display_name" field predicates.""" + displayNameGTE: String + + """"display_name" field predicates.""" + displayNameLT: String + + """"display_name" field predicates.""" + displayNameLTE: String + + """"display_name" field predicates.""" + displayNameContains: String + + """"display_name" field predicates.""" + displayNameHasPrefix: String + + """"display_name" field predicates.""" + displayNameHasSuffix: String + + """"display_name" field predicates.""" + displayNameIsNil: Boolean + + """"display_name" field predicates.""" + displayNameNotNil: Boolean + + """"display_name" field predicates.""" + displayNameEqualFold: String + + """"display_name" field predicates.""" + displayNameContainsFold: String + + """"is_fragment" field predicates.""" + isFragment: Boolean + + """"is_fragment" field predicates.""" + isFragmentNEQ: Boolean + + """"template" field predicates.""" + template: String + + """"template" field predicates.""" + templateNEQ: String + + """"template" field predicates.""" + templateIn: [String!] + + """"template" field predicates.""" + templateNotIn: [String!] + + """"template" field predicates.""" + templateGT: String - """severity field predicates""" + """"template" field predicates.""" + templateGTE: String + + """"template" field predicates.""" + templateLT: String + + """"template" field predicates.""" + templateLTE: String + + """"template" field predicates.""" + templateContains: String + + """"template" field predicates.""" + templateHasPrefix: String + + """"template" field predicates.""" + templateHasSuffix: String + + """"template" field predicates.""" + templateEqualFold: String + + """"template" field predicates.""" + templateContainsFold: String + + """"severity" field predicates.""" severity: LogEventSeverity + + """"severity" field predicates.""" severityNEQ: LogEventSeverity + + """"severity" field predicates.""" severityIn: [LogEventSeverity!] + + """"severity" field predicates.""" severityNotIn: [LogEventSeverity!] + + """"severity" field predicates.""" severityIsNil: Boolean + + """"severity" field predicates.""" severityNotNil: Boolean - """signal_purpose field predicates""" - signalPurpose: LogEventSignalPurpose - signalPurposeNEQ: LogEventSignalPurpose - signalPurposeIn: [LogEventSignalPurpose!] - signalPurposeNotIn: [LogEventSignalPurpose!] + """"matchers" field predicates.""" + matchersIsNil: Boolean - """event_nature field predicates""" - eventNature: LogEventEventNature - eventNatureNEQ: LogEventEventNature - eventNatureIn: [LogEventEventNature!] - eventNatureNotIn: [LogEventEventNature!] + """"matchers" field predicates.""" + matchersNotNil: Boolean - """is_fragment field predicates""" - isFragment: Boolean - isFragmentNEQ: Boolean + """"target_log" field predicates.""" + targetLogIsNil: Boolean + + """"target_log" field predicates.""" + targetLogNotNil: Boolean - """baseline_volume_per_hour field predicates""" + """"examples" field predicates.""" + examplesIsNil: Boolean + + """"examples" field predicates.""" + examplesNotNil: Boolean + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHour: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNEQ: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourIn: [Float!] + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNotIn: [Float!] + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourGT: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourGTE: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourLT: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourLTE: Float + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourIsNil: Boolean + + """"baseline_volume_per_hour" field predicates.""" baselineVolumePerHourNotNil: Boolean - """baseline_avg_bytes field predicates""" + """"baseline_avg_bytes" field predicates.""" baselineAvgBytes: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNEQ: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesIn: [Float!] + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNotIn: [Float!] + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesGT: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesGTE: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesLT: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesLTE: Float + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesIsNil: Boolean + + """"baseline_avg_bytes" field predicates.""" baselineAvgBytesNotNil: Boolean - """created_at field predicates""" + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfService: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNEQ: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceIn: [Float!] + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNotIn: [Float!] + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceGT: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceGTE: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceLT: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceLTE: Float + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceIsNil: Boolean + + """"baseline_volume_share_of_service" field predicates.""" + baselineVolumeShareOfServiceNotNil: Boolean + + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time - """service edge predicates""" - hasService: Boolean - hasServiceWith: [ServiceWhereInput!] + """"updated_at" field predicates.""" + updatedAtGTE: Time - """log_sample edge predicates""" - hasLogSample: Boolean - hasLogSampleWith: [LogSampleWhereInput!] + """"updated_at" field predicates.""" + updatedAtLT: Time - """policies edge predicates""" - hasPolicies: Boolean - hasPoliciesWith: [LogEventPolicyWhereInput!] + """"updated_at" field predicates.""" + updatedAtLTE: Time - """log_event_fields edge predicates""" - hasLogEventFields: Boolean - hasLogEventFieldsWith: [LogEventFieldWhereInput!] + """Filters for log-event JSONB facts.""" + logEventFacts: LogEventFactsWhereInput } """A normalized log record following OTEL conventions""" @@ -4051,455 +6813,375 @@ type LogRecord { scopeAttributes: Map } -type LogSample implements Node { - """Unique identifier of the log sample""" - id: ID! +input LogVolumeFilterInput { + """ + Sources to include. Omitted sources are inferred from source-specific filters, + or all supported sources when no source-specific filter is present. At most + 100 values. + """ + sources: [TelemetrySource!] - """Parent account for tenant isolation""" - accountID: ID! + """ + Restrict Datadog-backed observations to these log indexes. At most 100 + values. + """ + datadogLogIndexIDs: [UUID!] - """Service these logs belong to""" - serviceID: ID! + """ + Restrict edge-backed observations to these edge instances. At most 100 values. + """ + edgeInstanceIDs: [UUID!] +} - """Datadog account that produced this sample""" - datadogAccountID: ID! +input LogVolumeInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! - """Number of OTEL log records in this page""" - recordCount: Int! + """ + Granularity for returned points. Requests may include at most 1000 points. + """ + granularity: TimeGranularity! + filter: LogVolumeFilterInput +} - """Size of the GCS object in bytes""" - byteSize: Int! +type LogVolumePoint { + start: Time! + end: Time! + logCount: Float! + estimatedBytes: Float + avgBytes: Float + quality: MeasurementQuality! +} - """GCS object path for the log payload""" - storagePath: String! +type LogVolumeSeries { + range: TimeRange! + granularity: TimeGranularity! + points: [LogVolumePoint!]! + totals: LogVolumeTotals! +} - """Earliest log timestamp in this page""" - timeFrom: Time! +type LogVolumeSummary { + logCount: Float! + estimatedBytes: Float + avgBytes: Float +} - """Latest log timestamp in this page""" - timeTo: Time! +input LogVolumeSummaryFilterInput { + """ + Sources to include. Omitted sources are inferred from source-specific filters, + or all supported sources when no source-specific filter is present. At most + 100 values. + """ + sources: [TelemetrySource!] - """When this sample was ingested""" - createdAt: Time! + """ + Restrict Datadog-backed observations to these log indexes. At most 100 + values. + """ + datadogLogIndexIDs: [UUID!] +} - """When this record was last updated""" - updatedAt: Time! +input LogVolumeSummaryInput { + """Half-open range to summarize.""" + range: TimeRangeInput! + filter: LogVolumeSummaryFilterInput +} - """Account this sample belongs to""" - account: Account! +type LogVolumeTotals { + logCount: Float! + estimatedBytes: Float + avgBytes: Float +} - """Service these logs belong to""" - service: Service! +scalar Map - """Datadog account that produced this sample""" - datadogAccount: DatadogAccount! +"""Baseline used for a measurement delta.""" +type MeasurementComparison { + window: MeasurementComparisonWindow! +} - """Log events discovered from this sample""" - logEvents: [LogEvent!] +enum MeasurementComparisonWindow { + PRIOR_30_DAYS + PRIOR_90_DAYS + PRIOR_YEAR + PREVIOUS_PERIOD } -""" -LogSampleWhereInput is used for filtering LogSample objects. -Input was generated by ent. -""" -input LogSampleWhereInput { - not: LogSampleWhereInput - and: [LogSampleWhereInput!] - or: [LogSampleWhereInput!] +"""Movement from a named measurement comparison baseline.""" +type MeasurementDelta { + value: Float! + unit: MeasurementDeltaUnit! + direction: MeasurementDeltaDirection! + comparison: MeasurementComparison! +} - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID +enum MeasurementDeltaDirection { + UP + DOWN + FLAT +} - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] +enum MeasurementDeltaUnit { + PERCENT + POINTS + MONEY_USD + COUNT +} - """service_id field predicates""" - serviceID: ID - serviceIDNEQ: ID - serviceIDIn: [ID!] - serviceIDNotIn: [ID!] +"""How the server interpreted a measurement.""" +enum MeasurementQuality { + OBSERVED + ZERO + PARTIAL + MISSING +} - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] +"""Money amount in USD.""" +type MoneyAmount { + usd: Float! +} - """record_count field predicates""" - recordCount: Int - recordCountNEQ: Int - recordCountIn: [Int!] - recordCountNotIn: [Int!] - recordCountGT: Int - recordCountGTE: Int - recordCountLT: Int - recordCountLTE: Int - - """byte_size field predicates""" - byteSize: Int - byteSizeNEQ: Int - byteSizeIn: [Int!] - byteSizeNotIn: [Int!] - byteSizeGT: Int - byteSizeGTE: Int - byteSizeLT: Int - byteSizeLTE: Int - - """storage_path field predicates""" - storagePath: String - storagePathNEQ: String - storagePathIn: [String!] - storagePathNotIn: [String!] - storagePathGT: String - storagePathGTE: String - storagePathLT: String - storagePathLTE: String - storagePathContains: String - storagePathHasPrefix: String - storagePathHasSuffix: String - storagePathEqualFold: String - storagePathContainsFold: String - - """time_from field predicates""" - timeFrom: Time - timeFromNEQ: Time - timeFromIn: [Time!] - timeFromNotIn: [Time!] - timeFromGT: Time - timeFromGTE: Time - timeFromLT: Time - timeFromLTE: Time - - """time_to field predicates""" - timeTo: Time - timeToNEQ: Time - timeToIn: [Time!] - timeToNotIn: [Time!] - timeToGT: Time - timeToGTE: Time - timeToLT: Time - timeToLTE: Time - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time +type Mutation { + """Create account.""" + createAccount(input: AccountCreateInput!): Account! - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """Delete account.""" + deleteAccount(id: ID!, confirmation: String!): Boolean! - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """Update account.""" + updateAccount(id: ID!, input: AccountUpdateInput!): Account! - """service edge predicates""" - hasService: Boolean - hasServiceWith: [ServiceWhereInput!] + """Create datadogaccount.""" + createDatadogAccount(input: DatadogAccountCreateInput!): DatadogAccount! - """datadog_account edge predicates""" - hasDatadogAccount: Boolean - hasDatadogAccountWith: [DatadogAccountWhereInput!] + """Delete datadogaccount.""" + deleteDatadogAccount(id: ID!): Boolean! - """log_events edge predicates""" - hasLogEvents: Boolean - hasLogEventsWith: [LogEventWhereInput!] -} + """Update datadogaccount.""" + updateDatadogAccount(id: ID!, input: DatadogAccountUpdateInput!): DatadogAccount! -scalar Map + """Validate a Datadog API key against a Datadog site.""" + validateDatadogApiKey(input: ValidateDatadogApiKeyInput!): ValidateDatadogApiKeyResult! -type Message implements Node { - """Unique identifier""" - id: ID! + """Create datadoglogexclusionfilter.""" + createDatadogLogExclusionFilter(input: DatadogLogExclusionFilterCreateInput!): DatadogLogExclusionFilter! + createLogEventPoliciesFromDatadogExclusionFilter(id: ID!, input: DatadogLogExclusionFilterPolicyImportInput!): [LogEventPolicy!]! """ - Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. + Revoke an edge API key. The key can no longer be used for authentication. """ - accountID: UUID! - - """Conversation this message belongs to""" - conversationID: ID! + revokeEdgeApiKey(id: ID!): EdgeApiKey! """ - Who sent this message. user: human-originated, assistant: AI-originated. + Close an issue with a user-supplied note. The note is persisted on the + issue's resolution_reason alongside the actor; it is required and shown + beside the issue's resolution in the product. No-op when the issue is + already closed (returns the existing issue unchanged). """ - role: MessageRole! + closeIssue(id: ID!, note: String!): Issue! + + """Ignore an issue.""" + ignoreIssue(id: ID!): Issue! + + """Create logeventpolicy.""" + createLogEventPolicy(input: LogEventPolicyCreateInput!): LogEventPolicy! """ - Why the assistant stopped generating. end_turn: completed response, tool_use: - paused to call a tool. Null for user messages. + Create a new organization and its default account using the additive runtime. """ - stopReason: MessageStopReason + createOrganizationAndBootstrap(input: OrganizationCreateInput!): OrganizationBootstrapResult! - """AI model that produced this message. Null for user messages.""" - model: String + """Create organization.""" + createOrganization(input: OrganizationCreateInput!): Organization! - """When the message was created""" - createdAt: Time! + """Delete organization.""" + deleteOrganization(id: ID!, confirmation: String!): Boolean! - """Conversation this message belongs to""" - conversation: Conversation! + """Revoke an organization invitation.""" + revokeOrganizationInvitation(id: String!): OrganizationInvitation! - """Views created by this message via show_view tool calls""" - views: [View!] + """Send an organization invitation.""" + sendOrganizationInvitation(input: SendOrganizationInvitationInput!): OrganizationInvitation! - """Array of typed content blocks: text, thinking, tool_use, tool_result.""" - content: [ContentBlock!]! -} + """Update organization.""" + updateOrganization(id: ID!, input: OrganizationUpdateInput!): Organization! -"""A connection to a list of items.""" -type MessageConnection { - """A list of edges.""" - edges: [MessageEdge] + """Set service enabled state.""" + setServiceEnabled(id: ID!, enabled: Boolean!): Service! - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Assign a service to a team.""" + assignServiceToTeam(serviceID: ID!, teamID: ID!): Service! - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """Remove the team mapping from a service.""" + removeServiceTeamMapping(serviceID: ID!): Service! -"""An edge in a connection.""" -type MessageEdge { - """The item at the end of the edge.""" - node: Message + """Create team.""" + createTeam(input: TeamCreateInput!): Team! - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Delete team.""" + deleteTeam(id: ID!): Boolean! -"""Ordering options for Message connections""" -input MessageOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Add a user to a team.""" + addTeamMember(teamID: ID!, userID: String!): Team! - """The field by which to order Messages.""" - field: MessageOrderField! -} + """Remove a user from a team.""" + removeTeamMember(teamID: ID!, userID: String!): Team! -"""Properties by which Message connections can be ordered.""" -enum MessageOrderField { - CREATED_AT -} + """Update team.""" + updateTeam(id: ID!, input: TeamUpdateInput!): Team! -"""MessageRole is enum for the field role""" -enum MessageRole { - user - assistant + """ + Create a new API key for edge instance authentication. + Returns the plain key only once - it cannot be retrieved afterward. + """ + createEdgeApiKey(input: CreateEdgeApiKeyInput!): CreateEdgeApiKeyResult! } -"""MessageStopReason is enum for the field stop_reason""" -enum MessageStopReason { - end_turn - tool_use +enum ObservabilityValueLevel { + """Adds essentially no meaningful observability value.""" + none + + """Adds weak but real observability value.""" + low + + """Adds materially important observability value.""" + high } """ -MessageWhereInput is used for filtering Message objects. -Input was generated by ent. +Captures one-copy observability value and additional repeated-stream value for a recurring log event. """ -input MessageWhereInput { - not: MessageWhereInput - and: [MessageWhereInput!] - or: [MessageWhereInput!] +type ObservabilityValueProfile { + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValue: ObservabilityValueLevel! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGain: ObservabilityValueLevel! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +""" +Captures one-copy observability value and additional repeated-stream value for a recurring log event. +""" +input ObservabilityValueProfileWhereInput { + """Negated predicates.""" + not: ObservabilityValueProfileWhereInput - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """role field predicates""" - role: MessageRole - roleNEQ: MessageRole - roleIn: [MessageRole!] - roleNotIn: [MessageRole!] - - """stop_reason field predicates""" - stopReason: MessageStopReason - stopReasonNEQ: MessageStopReason - stopReasonIn: [MessageStopReason!] - stopReasonNotIn: [MessageStopReason!] - stopReasonIsNil: Boolean - stopReasonNotNil: Boolean - - """model field predicates""" - model: String - modelNEQ: String - modelIn: [String!] - modelNotIn: [String!] - modelGT: String - modelGTE: String - modelLT: String - modelLTE: String - modelContains: String - modelHasPrefix: String - modelHasSuffix: String - modelIsNil: Boolean - modelNotNil: Boolean - modelEqualFold: String - modelContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """Predicates that must all match.""" + and: [ObservabilityValueProfileWhereInput!] - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] + """Predicates where at least one must match.""" + or: [ObservabilityValueProfileWhereInput!] - """views edge predicates""" - hasViews: Boolean - hasViewsWith: [ViewWhereInput!] -} + """Whether the observability_value_profile JSONB value is present.""" + present: Boolean -type Mutation { - createAccount(input: CreateAccountInput!): Account! - updateAccount(id: ID!, input: UpdateAccountInput!): Account! - deleteAccount(id: ID!): Boolean! - createOrganization(input: CreateOrganizationInput!): Organization! - createOrganizationAndBootstrap(input: CreateOrganizationInput!): OrganizationBootstrapResult! - updateOrganization(id: ID!, input: UpdateOrganizationInput!): Organization! - deleteOrganization(id: ID!, confirmed: Boolean!): Boolean! - createDatadogAccount(input: CreateDatadogAccountWithCredentialsInput!): DatadogAccount! - updateDatadogAccount(id: ID!, input: UpdateDatadogAccountInput!): DatadogAccount! - deleteDatadogAccount(id: ID!): Boolean! - validateDatadogApiKey(input: ValidateDatadogApiKeyInput!): ValidateDatadogApiKeyResult! - createWorkspace(input: CreateWorkspaceInput!): Workspace! - updateWorkspace(id: ID!, input: UpdateWorkspaceInput!): Workspace! - deleteWorkspace(id: ID!): Boolean! - createTeam(input: CreateTeamInput!): Team! - updateTeam(id: ID!, input: UpdateTeamInput!): Team! - deleteTeam(id: ID!): Boolean! - updateService(id: ID!, input: UpdateServiceInput!): Service! + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValue: ObservabilityValueLevel """ - Create a new API key for edge instance authentication. - Returns the plain key only once - it cannot be retrieved afterward. + How much observability value one representative copy of the recurring event provides on its own. """ - createEdgeApiKey(input: CreateEdgeApiKeyInput!): CreateEdgeApiKeyResult! + instanceValueNEQ: ObservabilityValueLevel """ - Revoke an edge API key. The key can no longer be used for authentication. + How much observability value one representative copy of the recurring event provides on its own. """ - revokeEdgeApiKey(id: ID!): EdgeApiKey! + instanceValueIn: [ObservabilityValueLevel!] + + """ + How much observability value one representative copy of the recurring event provides on its own. + """ + instanceValueNotIn: [ObservabilityValueLevel!] + + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGain: ObservabilityValueLevel """ - Create a new conversation in a workspace. - The conversation is owned by the authenticated user. + How much additional observability value the repeated stream provides after one representative copy is already understood. """ - createConversation(input: CreateConversationInput!): Conversation! + collectionGainNEQ: ObservabilityValueLevel - """Update a conversation (e.g., set title).""" - updateConversation(id: ID!, input: UpdateConversationInput!): Conversation! + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGainIn: [ObservabilityValueLevel!] + + """ + How much additional observability value the repeated stream provides after one representative copy is already understood. + """ + collectionGainNotIn: [ObservabilityValueLevel!] +} - """Delete a conversation and all its messages.""" - deleteConversation(id: ID!): Boolean! +enum OperatorProminenceLevel { + """Should fade out of the normal operator-facing log surface.""" + none - """Create a new message in a conversation.""" - createMessage(input: CreateMessageInput!): Message! + """Useful routine context that deserves visible but secondary presence.""" + low """ - Create a new view. Called by clients when executing show_view tool results. - If the conversation has view_id set (iteration mode), forked_from_id is auto-set. + Important operator-facing signal that should be noticed in normal workflows. """ - createView(input: CreateViewInput!): View! + high +} +"""Captures normal operator-facing visibility for a recurring log event.""" +type OperatorProminenceProfile { """ - Add a view to the user's favorites. - Idempotent: if already favorited, returns the existing favorite. + How prominently this recurring event family should remain visible in normal operator workflows. """ - createViewFavorite(input: CreateViewFavoriteInput!): ViewFavorite! + operatorProminence: OperatorProminenceLevel! +} + +"""Captures normal operator-facing visibility for a recurring log event.""" +input OperatorProminenceProfileWhereInput { + """Negated predicates.""" + not: OperatorProminenceProfileWhereInput - """Remove a view from the user's favorites.""" - deleteViewFavorite(viewId: ID!): Boolean! + """Predicates that must all match.""" + and: [OperatorProminenceProfileWhereInput!] + + """Predicates where at least one must match.""" + or: [OperatorProminenceProfileWhereInput!] + + """Whether the operator_prominence_profile JSONB value is present.""" + present: Boolean """ - Approve a log event policy, enabling it for enforcement. - Clears any previous dismissal. + How prominently this recurring event family should remain visible in normal operator workflows. """ - approveLogEventPolicy(id: ID!): LogEventPolicy! + operatorProminence: OperatorProminenceLevel """ - Dismiss a log event policy, hiding it from pending review. - Clears any previous approval. + How prominently this recurring event family should remain visible in normal operator workflows. """ - dismissLogEventPolicy(id: ID!): LogEventPolicy! + operatorProminenceNEQ: OperatorProminenceLevel """ - Reset a log event policy to pending, clearing any approval or dismissal. + How prominently this recurring event family should remain visible in normal operator workflows. """ - resetLogEventPolicy(id: ID!): LogEventPolicy! -} + operatorProminenceIn: [OperatorProminenceLevel!] -""" -An object with an ID. -Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) -""" -interface Node { - """The id of the object.""" - id: ID! + """ + How prominently this recurring event family should remain visible in normal operator workflows. + """ + operatorProminenceNotIn: [OperatorProminenceLevel!] } -""" -Possible directions in which to order a list of items when provided an `orderBy` argument. -""" enum OrderDirection { - """Specifies an ascending order for a given `orderBy` argument.""" ASC - - """Specifies a descending order for a given `orderBy` argument.""" DESC } -type Organization implements Node { +type Organization { """Unique identifier of the organization""" id: ID! @@ -4516,2075 +7198,1800 @@ type Organization implements Node { updatedAt: Time! """Accounts belonging to this organization""" - accounts: [Account!] + accounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: AccountOrder, where: AccountWhereInput): AccountConnection! + + """Teams belonging to this organization""" + teams(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: TeamOrder, where: TeamWhereInput): TeamConnection! } type OrganizationBootstrapResult { organization: Organization! account: Account! - workspace: Workspace! } -"""A connection to a list of items.""" type OrganizationConnection { - """A list of edges.""" - edges: [OrganizationEdge] - - """Information to aid in pagination.""" + edges: [OrganizationEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type OrganizationEdge { - """The item at the end of the edge.""" - node: Organization +"""Organization creation input.""" +input OrganizationCreateInput { + """Human-readable name, unique across the system""" + name: String! +} - """A cursor for use in pagination.""" +type OrganizationEdge { + node: Organization! cursor: Cursor! } -"""Ordering options for Organization connections""" +""" +OrganizationInvitation is an invitation to join the viewer's current +organization. Returns all states; the webapp filters to PENDING client-side. +""" +type OrganizationInvitation { + """ + WorkOS invitation id (e.g. `invitation_01H...`). Typed as `String!` for the + same reason as `OrganizationMember.id`. + """ + id: String! + email: String! + role: OrganizationMemberRole! + state: InvitationState! + expiresAt: Time! + createdAt: Time! +} + +""" +OrganizationMember is a user belonging to the viewer's current WorkOS +organization. There is no internal Tero record for members yet — this is a +pass-through projection of the WorkOS User object scoped to the viewer's org. +""" +type OrganizationMember { + """ + WorkOS user id (e.g. `user_01H...`). Typed as `String!` rather than `ID!` + because the repo's `ID` scalar is mapped to `uuid.UUID` and WorkOS ids are + not UUIDs. Matches the convention used by `Viewer.id` and + `TeamMember.userID`. + """ + id: String! + email: String! + firstName: String + lastName: String + role: OrganizationMemberRole! + createdAt: Time! +} + +enum OrganizationMemberRole { + ADMIN + MEMBER +} + input OrganizationOrder { - """The ordering direction.""" direction: OrderDirection! = ASC - - """The field by which to order Organizations.""" field: OrganizationOrderField! } -"""Properties by which Organization connections can be ordered.""" enum OrganizationOrderField { + ID NAME CREATED_AT UPDATED_AT } -""" -OrganizationWhereInput is used for filtering Organization objects. -Input was generated by ent. -""" +"""Organization update input.""" +input OrganizationUpdateInput { + """Human-readable name, unique across the system""" + name: String +} + input OrganizationWhereInput { not: OrganizationWhereInput and: [OrganizationWhereInput!] or: [OrganizationWhereInput!] - """id field predicates""" + """"accounts" edge predicates.""" + accounts: AccountWhereInput + + """Whether the "accounts" edge has at least one related row.""" + hasAccounts: Boolean + + """"teams" edge predicates.""" + teams: TeamWhereInput + + """Whether the "teams" edge has at least one related row.""" + hasTeams: Boolean + + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time - updatedAtLTE: Time - """accounts edge predicates""" - hasAccounts: Boolean - hasAccountsWith: [AccountWhereInput!] + """"updated_at" field predicates.""" + updatedAtLTE: Time } """ -Information about pagination in a connection. -https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo +Describes who owns the service code and who operates the running service. """ -type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: Cursor - - """When paginating forwards, the cursor to continue.""" - endCursor: Cursor -} - -type Query { - """Fetches an object given its ID.""" - node( - """ID of the object.""" - id: ID! - ): Node - - """Lookup nodes by a list of IDs.""" - nodes( - """The list of node IDs.""" - ids: [ID!]! - ): [Node]! +type OwnershipModel { + """Whether the service code is first-party, third-party, or unknown.""" + code: ServiceOwnershipCode! """ - Query accounts. Accounts belong to an organization and contain services and workspaces. + Whether the running service is self-operated, vendor-operated, or unknown. """ - accounts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + operation: ServiceOwnershipOperation! - """Returns the first _n_ elements from the list.""" - first: Int + """Short grounded summary of the ownership model.""" + summary: String! +} - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor +""" +Describes who owns the service code and who operates the running service. +""" +input OwnershipModelWhereInput { + """Negated predicates.""" + not: OwnershipModelWhereInput - """Returns the last _n_ elements from the list.""" - last: Int + """Predicates that must all match.""" + and: [OwnershipModelWhereInput!] - """Ordering options for Accounts returned from the connection.""" - orderBy: AccountOrder + """Predicates where at least one must match.""" + or: [OwnershipModelWhereInput!] - """Filtering options for Accounts returned from the connection.""" - where: AccountWhereInput - ): AccountConnection! + """Whether the ownership_model JSONB value is present.""" + present: Boolean - """Query conversations.""" - conversations( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Whether the service code is first-party, third-party, or unknown.""" + code: ServiceOwnershipCode - """Returns the first _n_ elements from the list.""" - first: Int + """Whether the service code is first-party, third-party, or unknown.""" + codeNEQ: ServiceOwnershipCode - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Whether the service code is first-party, third-party, or unknown.""" + codeIn: [ServiceOwnershipCode!] - """Returns the last _n_ elements from the list.""" - last: Int + """Whether the service code is first-party, third-party, or unknown.""" + codeNotIn: [ServiceOwnershipCode!] - """Ordering options for Conversations returned from the connection.""" - orderBy: ConversationOrder + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operation: ServiceOwnershipOperation - """Filtering options for Conversations returned from the connection.""" - where: ConversationWhereInput - ): ConversationConnection! + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationNEQ: ServiceOwnershipOperation - """Query active context entities in conversations.""" - conversationContexts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationIn: [ServiceOwnershipOperation!] - """Returns the first _n_ elements from the list.""" - first: Int + """ + Whether the running service is self-operated, vendor-operated, or unknown. + """ + operationNotIn: [ServiceOwnershipOperation!] - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Short grounded summary of the ownership model.""" + summary: String - """Returns the last _n_ elements from the list.""" - last: Int + """Short grounded summary of the ownership model.""" + summaryNEQ: String - """ - Ordering options for ConversationContexts returned from the connection. - """ - orderBy: ConversationContextOrder + """Short grounded summary of the ownership model.""" + summaryIn: [String!] - """ - Filtering options for ConversationContexts returned from the connection. - """ - where: ConversationContextWhereInput - ): ConversationContextConnection! + """Short grounded summary of the ownership model.""" + summaryNotIn: [String!] - """Query connected Datadog accounts.""" - datadogAccounts( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Short grounded summary of the ownership model.""" + summaryContains: String - """Returns the first _n_ elements from the list.""" - first: Int + """Short grounded summary of the ownership model.""" + summaryHasPrefix: String - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Short grounded summary of the ownership model.""" + summaryHasSuffix: String - """Returns the last _n_ elements from the list.""" - last: Int + """Short grounded summary of the ownership model.""" + summaryEqualFold: String - """Ordering options for DatadogAccounts returned from the connection.""" - orderBy: DatadogAccountOrder + """Short grounded summary of the ownership model.""" + summaryContainsFold: String - """Filtering options for DatadogAccounts returned from the connection.""" - where: DatadogAccountWhereInput - ): DatadogAccountConnection! + """Short grounded summary of the ownership model.""" + hasSummary: Boolean +} - """Query API keys for edge instance authentication.""" - edgeAPIKeys( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: Cursor + endCursor: Cursor +} - """Returns the first _n_ elements from the list.""" - first: Int +enum PeerRole { + """A human or end-user actor.""" + user - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """ + An upstream webhook source, upstream business system, same-estate application, + partner platform, customer system, business destination, or other application boundary. + """ + service - """Returns the last _n_ elements from the list.""" - last: Int + """ + A technical API, provider, database, cache, queue, storage system, or + infrastructure component the emitting service calls or depends on to perform its own work. + """ + dependency - """Ordering options for EdgeApiKeys returned from the connection.""" - orderBy: EdgeApiKeyOrder + """A crawler or automated internet agent.""" + bot - """Filtering options for EdgeApiKeys returned from the connection.""" - where: EdgeApiKeyWhereInput - ): EdgeApiKeyConnection! + """A health, readiness, uptime, synthetic, or monitoring probe.""" + probe - """Query edge instances that sync policies from the control plane.""" - edgeInstances( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """A real peer is present, but its broad role is unclear.""" + unknown +} - """Returns the first _n_ elements from the list.""" - first: Int +"""Percentage value, expressed as percentage points rather than a ratio.""" +type PercentAmount { + value: Float! +} - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor +type PolicyExecutionSummary { + matchHits: Int! + errors: Int! +} - """Returns the last _n_ elements from the list.""" - last: Int +input PolicyExecutionSummaryInput { + """Half-open range to summarize.""" + range: TimeRangeInput! + filter: PolicyTelemetryFilterInput +} - """Ordering options for EdgeInstances returned from the connection.""" - orderBy: EdgeInstanceOrder +input PolicyTelemetryFilterInput { + """Restrict policy telemetry to these edge instances. At most 100 values.""" + edgeInstanceIDs: [UUID!] +} - """Filtering options for EdgeInstances returned from the connection.""" - where: EdgeInstanceWhereInput - ): EdgeInstanceConnection! +input PolicyTelemetryInput { + """Half-open range to bucket. Boundaries must align to granularity.""" + range: TimeRangeInput! """ - Query log events discovered in your services. Each log event is a distinct message pattern. + Granularity for returned points. Requests may include at most 1000 points. """ - logEvents( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + granularity: TimeGranularity! + filter: PolicyTelemetryFilterInput +} - """Returns the last _n_ elements from the list.""" - last: Int +type PolicyTelemetryPoint { + start: Time! + end: Time! + matchHits: Int! + matchMisses: Int! + errors: Int! + removeHits: Int! + redactHits: Int! + renameHits: Int! + addHits: Int! + quality: MeasurementQuality! +} - """Ordering options for LogEvents returned from the connection.""" - orderBy: LogEventOrder +type PolicyTelemetrySeries { + range: TimeRange! + granularity: TimeGranularity! + points: [PolicyTelemetryPoint!]! + totals: PolicyTelemetryTotals! +} - """Filtering options for LogEvents returned from the connection.""" - where: LogEventWhereInput - ): LogEventConnection! +type PolicyTelemetryTotals { + matchHits: Int! + matchMisses: Int! + errors: Int! + removeHits: Int! + redactHits: Int! + renameHits: Int! + addHits: Int! +} +type Query { """ - Query log event policies. Each policy is a category-specific recommendation. + Returns the currently authenticated user. + Use this to check authentication status and organization assignment. """ - logEventPolicies( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int - - """Ordering options for LogEventPolicies returned from the connection.""" - orderBy: LogEventPolicyOrder - - """Filtering options for LogEventPolicies returned from the connection.""" - where: LogEventPolicyWhereInput - ): LogEventPolicyConnection! - - """Query messages in chat conversations.""" - messages( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + viewer: Viewer! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one Account by ID.""" + account(id: ID!): Account - """Returns the last _n_ elements from the list.""" - last: Int + """Query Accounts records in your account.""" + accounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: AccountOrder, where: AccountWhereInput): AccountConnection! - """Ordering options for Messages returned from the connection.""" - orderBy: MessageOrder + """Query one code-defined product check by ID.""" + check(id: ID!): Check - """Filtering options for Messages returned from the connection.""" - where: MessageWhereInput - ): MessageConnection! + """Code-defined product checks with account-scoped posture measurements.""" + checks(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: CheckOrder, where: CheckWhereInput): CheckConnection! """ - Query organizations. An organization is the top-level container that holds accounts. + Compliance lane reporting summary for the current account and optional product filters. """ - organizations( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int - - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int - - """Ordering options for Organizations returned from the connection.""" - orderBy: OrganizationOrder - - """Filtering options for Organizations returned from the connection.""" - where: OrganizationWhereInput - ): OrganizationConnection! - - """Query services in your system.""" - services( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + complianceReport(input: ComplianceReportInput): ComplianceReport! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor - - """Returns the last _n_ elements from the list.""" - last: Int + """ + Cost lane reporting summary for the current account and optional product filters. + """ + costReport(input: CostReportInput): CostReport! - """Ordering options for Services returned from the connection.""" - orderBy: ServiceOrder + """Query one DatadogAccount by ID.""" + datadogAccount(id: ID!): DatadogAccount - """Filtering options for Services returned from the connection.""" - where: ServiceWhereInput - ): ServiceConnection! + """Query DatadogAccounts records in your account.""" + datadogAccounts(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogAccountOrder, where: DatadogAccountWhereInput): DatadogAccountConnection! - """Query teams in your organization.""" - teams( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one DatadogLogExclusionFilter by ID.""" + datadogLogExclusionFilter(id: ID!): DatadogLogExclusionFilter - """Returns the first _n_ elements from the list.""" - first: Int + """Query DatadogLogExclusionFilters records in your account.""" + datadogLogExclusionFilters(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogExclusionFilterOrder, where: DatadogLogExclusionFilterWhereInput): DatadogLogExclusionFilterConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one DatadogLogIndex by ID.""" + datadogLogIndex(id: ID!): DatadogLogIndex - """Returns the last _n_ elements from the list.""" - last: Int + """Query DatadogLogIndices records in your account.""" + datadogLogIndices(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: DatadogLogIndexOrder, where: DatadogLogIndexWhereInput): DatadogLogIndexConnection! - """Ordering options for Teams returned from the connection.""" - orderBy: TeamOrder + """Query one EdgeApiKey by ID.""" + edgeAPIKey(id: ID!): EdgeApiKey - """Filtering options for Teams returned from the connection.""" - where: TeamWhereInput - ): TeamConnection! + """Query EdgeApiKeys records in your account.""" + edgeAPIKeys(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeApiKeyOrder, where: EdgeApiKeyWhereInput): EdgeApiKeyConnection! """ - Query saved views. Views are immutable queries against catalog entities. + Aggregated fleet-wide edge telemetry: per-policy time series, group rows, and + fleet totals. Time range, granularity, group dimension, and connectivity + threshold are caller-provided. Range boundaries must align to granularity. + Requests may include at most 1000 points. """ - views( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor - - """Returns the first _n_ elements from the list.""" - first: Int + edgeFleetTelemetry(input: EdgeFleetTelemetryInput!): EdgeFleetTelemetry! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one EdgeInstance by ID.""" + edgeInstance(id: ID!): EdgeInstance - """Returns the last _n_ elements from the list.""" - last: Int + """Query EdgeInstances records in your account.""" + edgeInstances(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: EdgeInstanceOrder, where: EdgeInstanceWhereInput): EdgeInstanceConnection! - """Ordering options for Views returned from the connection.""" - orderBy: ViewOrder + """Query one Issue by ID.""" + issue(id: ID!): Issue - """Filtering options for Views returned from the connection.""" - where: ViewWhereInput - ): ViewConnection! + """Query Issues records in your account.""" + issues(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: IssueOrder, where: IssueWhereInput): IssueConnection! - """Query view favorites. Each favorite links a user to a saved view.""" - viewFavorites( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one LogEventPolicy by ID.""" + logEventPolicy(id: ID!): LogEventPolicy - """Returns the first _n_ elements from the list.""" - first: Int + """Query LogEventPolicies records in your account.""" + logEventPolicies(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventPolicyOrder, where: LogEventPolicyWhereInput): LogEventPolicyConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one LogEvent by ID.""" + logEvent(id: ID!): LogEvent - """Returns the last _n_ elements from the list.""" - last: Int + """Query LogEvents records in your account.""" + logEvents(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventOrder, where: LogEventWhereInput): LogEventConnection! - """Ordering options for ViewFavorites returned from the connection.""" - orderBy: ViewFavoriteOrder + """Query one Organization by ID.""" + organization(id: ID!): Organization - """Filtering options for ViewFavorites returned from the connection.""" - where: ViewFavoriteWhereInput - ): ViewFavoriteConnection! + """Query Organizations records in your account.""" + organizations(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: OrganizationOrder, where: OrganizationWhereInput): OrganizationConnection! - """ - Query workspaces. Workspaces are used to analyze and classify telemetry. - """ - workspaces( - """Returns the elements in the list that come after the specified cursor.""" - after: Cursor + """Query one Service by ID.""" + service(id: ID!): Service - """Returns the first _n_ elements from the list.""" - first: Int + """Query Services records in your account.""" + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! - """ - Returns the elements in the list that come before the specified cursor. - """ - before: Cursor + """Query one Team by ID.""" + team(id: ID!): Team - """Returns the last _n_ elements from the list.""" - last: Int + """Query Teams records in your account.""" + teams(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: TeamOrder, where: TeamWhereInput): TeamConnection! - """Ordering options for Workspaces returned from the connection.""" - orderBy: WorkspaceOrder - - """Filtering options for Workspaces returned from the connection.""" - where: WorkspaceWhereInput - ): WorkspaceConnection! + """ + Members of the viewer's current WorkOS organization. + Wraps workos.userManagement.listUsers({ organizationId: viewer.OrganizationID }). + Any authenticated user with an OrganizationID may call this. + """ + organizationMembers: [OrganizationMember!]! """ - Returns the currently authenticated user. - Use this to check authentication status and organization assignment. + Invitations for the viewer's current organization. Returns all states; + the webapp filters to PENDING client-side. + Wraps workos.userManagement.listInvitations({ organizationId: viewer.OrganizationID }). + Any authenticated user with an OrganizationID may call this. """ - viewer: Viewer! + organizationInvitations: [OrganizationInvitation!]! } -type Service implements Node { - """Unique identifier of the service""" - id: ID! - - """Parent account this service belongs to""" - accountID: ID! - - """Service identifier in telemetry (e.g., 'checkout-service')""" - name: String! +"""Describes the technical runtime roles a service plays in the system.""" +type RuntimeRole { + """Short grounded summary of the service's runtime roles.""" + summary: String! - """ - AI-generated description of what this service does and its telemetry characteristics - """ - description: String! + """Short normalized runtime role labels.""" + roles: [String!]! +} - """Whether log analysis and policy generation is active for this service""" - enabled: Boolean! +"""Describes the technical runtime roles a service plays in the system.""" +input RuntimeRoleWhereInput { + """Negated predicates.""" + not: RuntimeRoleWhereInput - """ - Approximate weekly log count from initial discovery (7-day period from Datadog) - """ - initialWeeklyLogCount: Int + """Predicates that must all match.""" + and: [RuntimeRoleWhereInput!] - """When the service was created""" - createdAt: Time! + """Predicates where at least one must match.""" + or: [RuntimeRoleWhereInput!] - """When the service was last updated""" - updatedAt: Time! + """Whether the runtime_role JSONB value is present.""" + present: Boolean - """Account this service belongs to""" - account: Account! + """Short grounded summary of the service's runtime roles.""" + summary: String - """Log event types produced by this service""" - logEvents: [LogEvent!] + """Short grounded summary of the service's runtime roles.""" + summaryNEQ: String - """ - Status of this service from a specific Datadog account. - Shows where the service is in the discovery pipeline. - Returns null if cache has not been populated yet. - """ - status(datadogAccountID: ID!): ServiceStatusCache -} + """Short grounded summary of the service's runtime roles.""" + summaryIn: [String!] -"""A connection to a list of items.""" -type ServiceConnection { - """A list of edges.""" - edges: [ServiceEdge] + """Short grounded summary of the service's runtime roles.""" + summaryNotIn: [String!] - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Short grounded summary of the service's runtime roles.""" + summaryContains: String - """Identifies the total count of items in the connection.""" - totalCount: Int! -} + """Short grounded summary of the service's runtime roles.""" + summaryHasPrefix: String -"""An edge in a connection.""" -type ServiceEdge { - """The item at the end of the edge.""" - node: Service + """Short grounded summary of the service's runtime roles.""" + summaryHasSuffix: String - """A cursor for use in pagination.""" - cursor: Cursor! -} + """Short grounded summary of the service's runtime roles.""" + summaryEqualFold: String -"""Ordering options for Service connections""" -input ServiceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """Short grounded summary of the service's runtime roles.""" + summaryContainsFold: String - """The field by which to order Services.""" - field: ServiceOrderField! -} + """Short grounded summary of the service's runtime roles.""" + hasSummary: Boolean -"""Properties by which Service connections can be ordered.""" -enum ServiceOrderField { - NAME - ENABLED - INITIAL_WEEKLY_LOG_COUNT - CREATED_AT - UPDATED_AT -} + """Short normalized runtime role labels.""" + hasRoles: Boolean -type ServiceStatusCache implements Node { - id: ID! - serviceID: ID! - accountID: UUID! - datadogAccountID: ID! + """Short normalized runtime role labels.""" + rolesContainsAny: [String!] - """ - Overall health of the service. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - """ - health: ServiceStatusCacheHealth! - logEventCount: Int! - logEventAnalyzedCount: Int! - policyPendingCount: Int! - policyApprovedCount: Int! - policyDismissedCount: Int! - policyPendingLowCount: Int! - policyPendingMediumCount: Int! - policyPendingHighCount: Int! - policyPendingCriticalCount: Int! - serviceVolumePerHour: Float - serviceDebugVolumePerHour: Float - serviceInfoVolumePerHour: Float - serviceWarnVolumePerHour: Float - serviceErrorVolumePerHour: Float - serviceOtherVolumePerHour: Float - serviceCostPerHourVolumeUsd: Float - logEventVolumePerHour: Float - logEventBytesPerHour: Float - logEventCostPerHourBytesUsd: Float - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourUsd: Float - estimatedVolumeReductionPerHour: Float - estimatedBytesReductionPerHour: Float - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourUsd: Float - observedVolumePerHourBefore: Float - observedVolumePerHourAfter: Float - observedBytesPerHourBefore: Float - observedBytesPerHourAfter: Float - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeUsd: Float - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterUsd: Float - refreshedAt: Time! + """Short normalized runtime role labels.""" + rolesContainsAll: [String!] } -"""ServiceStatusCacheHealth is enum for the field health""" -enum ServiceStatusCacheHealth { - DISABLED - INACTIVE - ERROR - OK +input SendOrganizationInvitationInput { + email: String! + role: OrganizationMemberRole = MEMBER } -""" -ServiceStatusCacheWhereInput is used for filtering ServiceStatusCache objects. -Input was generated by ent. -""" -input ServiceStatusCacheWhereInput { - not: ServiceStatusCacheWhereInput - and: [ServiceStatusCacheWhereInput!] - or: [ServiceStatusCacheWhereInput!] +enum SensitiveDataPaymentElement { + """Primary account number or full card number.""" + pan - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Card expiration month/year or expiration date exposed with full PAN or other + unmasked card data. Do not use for expiry alone with masked card metadata. + """ + card_expiration_date - """service_id field predicates""" - serviceID: ID - serviceIDNEQ: ID - serviceIDIn: [ID!] - serviceIDNotIn: [ID!] + """Card service code.""" + service_code - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """Full magnetic stripe or chip track data.""" + full_track_data - """datadog_account_id field predicates""" - datadogAccountID: ID - datadogAccountIDNEQ: ID - datadogAccountIDIn: [ID!] - datadogAccountIDNotIn: [ID!] + """CVV, CVC, CID, CAV2, or similar card verification code.""" + card_verification_code - """health field predicates""" - health: ServiceStatusCacheHealth - healthNEQ: ServiceStatusCacheHealth - healthIn: [ServiceStatusCacheHealth!] - healthNotIn: [ServiceStatusCacheHealth!] - - """log_event_count field predicates""" - logEventCount: Int - logEventCountNEQ: Int - logEventCountIn: [Int!] - logEventCountNotIn: [Int!] - logEventCountGT: Int - logEventCountGTE: Int - logEventCountLT: Int - logEventCountLTE: Int - - """log_event_analyzed_count field predicates""" - logEventAnalyzedCount: Int - logEventAnalyzedCountNEQ: Int - logEventAnalyzedCountIn: [Int!] - logEventAnalyzedCountNotIn: [Int!] - logEventAnalyzedCountGT: Int - logEventAnalyzedCountGTE: Int - logEventAnalyzedCountLT: Int - logEventAnalyzedCountLTE: Int - - """policy_pending_count field predicates""" - policyPendingCount: Int - policyPendingCountNEQ: Int - policyPendingCountIn: [Int!] - policyPendingCountNotIn: [Int!] - policyPendingCountGT: Int - policyPendingCountGTE: Int - policyPendingCountLT: Int - policyPendingCountLTE: Int - - """policy_approved_count field predicates""" - policyApprovedCount: Int - policyApprovedCountNEQ: Int - policyApprovedCountIn: [Int!] - policyApprovedCountNotIn: [Int!] - policyApprovedCountGT: Int - policyApprovedCountGTE: Int - policyApprovedCountLT: Int - policyApprovedCountLTE: Int - - """policy_dismissed_count field predicates""" - policyDismissedCount: Int - policyDismissedCountNEQ: Int - policyDismissedCountIn: [Int!] - policyDismissedCountNotIn: [Int!] - policyDismissedCountGT: Int - policyDismissedCountGTE: Int - policyDismissedCountLT: Int - policyDismissedCountLTE: Int - - """policy_pending_low_count field predicates""" - policyPendingLowCount: Int - policyPendingLowCountNEQ: Int - policyPendingLowCountIn: [Int!] - policyPendingLowCountNotIn: [Int!] - policyPendingLowCountGT: Int - policyPendingLowCountGTE: Int - policyPendingLowCountLT: Int - policyPendingLowCountLTE: Int - - """policy_pending_medium_count field predicates""" - policyPendingMediumCount: Int - policyPendingMediumCountNEQ: Int - policyPendingMediumCountIn: [Int!] - policyPendingMediumCountNotIn: [Int!] - policyPendingMediumCountGT: Int - policyPendingMediumCountGTE: Int - policyPendingMediumCountLT: Int - policyPendingMediumCountLTE: Int - - """policy_pending_high_count field predicates""" - policyPendingHighCount: Int - policyPendingHighCountNEQ: Int - policyPendingHighCountIn: [Int!] - policyPendingHighCountNotIn: [Int!] - policyPendingHighCountGT: Int - policyPendingHighCountGTE: Int - policyPendingHighCountLT: Int - policyPendingHighCountLTE: Int - - """policy_pending_critical_count field predicates""" - policyPendingCriticalCount: Int - policyPendingCriticalCountNEQ: Int - policyPendingCriticalCountIn: [Int!] - policyPendingCriticalCountNotIn: [Int!] - policyPendingCriticalCountGT: Int - policyPendingCriticalCountGTE: Int - policyPendingCriticalCountLT: Int - policyPendingCriticalCountLTE: Int - - """service_volume_per_hour field predicates""" - serviceVolumePerHour: Float - serviceVolumePerHourNEQ: Float - serviceVolumePerHourIn: [Float!] - serviceVolumePerHourNotIn: [Float!] - serviceVolumePerHourGT: Float - serviceVolumePerHourGTE: Float - serviceVolumePerHourLT: Float - serviceVolumePerHourLTE: Float - serviceVolumePerHourIsNil: Boolean - serviceVolumePerHourNotNil: Boolean - - """service_debug_volume_per_hour field predicates""" - serviceDebugVolumePerHour: Float - serviceDebugVolumePerHourNEQ: Float - serviceDebugVolumePerHourIn: [Float!] - serviceDebugVolumePerHourNotIn: [Float!] - serviceDebugVolumePerHourGT: Float - serviceDebugVolumePerHourGTE: Float - serviceDebugVolumePerHourLT: Float - serviceDebugVolumePerHourLTE: Float - serviceDebugVolumePerHourIsNil: Boolean - serviceDebugVolumePerHourNotNil: Boolean - - """service_info_volume_per_hour field predicates""" - serviceInfoVolumePerHour: Float - serviceInfoVolumePerHourNEQ: Float - serviceInfoVolumePerHourIn: [Float!] - serviceInfoVolumePerHourNotIn: [Float!] - serviceInfoVolumePerHourGT: Float - serviceInfoVolumePerHourGTE: Float - serviceInfoVolumePerHourLT: Float - serviceInfoVolumePerHourLTE: Float - serviceInfoVolumePerHourIsNil: Boolean - serviceInfoVolumePerHourNotNil: Boolean - - """service_warn_volume_per_hour field predicates""" - serviceWarnVolumePerHour: Float - serviceWarnVolumePerHourNEQ: Float - serviceWarnVolumePerHourIn: [Float!] - serviceWarnVolumePerHourNotIn: [Float!] - serviceWarnVolumePerHourGT: Float - serviceWarnVolumePerHourGTE: Float - serviceWarnVolumePerHourLT: Float - serviceWarnVolumePerHourLTE: Float - serviceWarnVolumePerHourIsNil: Boolean - serviceWarnVolumePerHourNotNil: Boolean - - """service_error_volume_per_hour field predicates""" - serviceErrorVolumePerHour: Float - serviceErrorVolumePerHourNEQ: Float - serviceErrorVolumePerHourIn: [Float!] - serviceErrorVolumePerHourNotIn: [Float!] - serviceErrorVolumePerHourGT: Float - serviceErrorVolumePerHourGTE: Float - serviceErrorVolumePerHourLT: Float - serviceErrorVolumePerHourLTE: Float - serviceErrorVolumePerHourIsNil: Boolean - serviceErrorVolumePerHourNotNil: Boolean - - """service_other_volume_per_hour field predicates""" - serviceOtherVolumePerHour: Float - serviceOtherVolumePerHourNEQ: Float - serviceOtherVolumePerHourIn: [Float!] - serviceOtherVolumePerHourNotIn: [Float!] - serviceOtherVolumePerHourGT: Float - serviceOtherVolumePerHourGTE: Float - serviceOtherVolumePerHourLT: Float - serviceOtherVolumePerHourLTE: Float - serviceOtherVolumePerHourIsNil: Boolean - serviceOtherVolumePerHourNotNil: Boolean - - """service_cost_per_hour_volume_usd field predicates""" - serviceCostPerHourVolumeUsd: Float - serviceCostPerHourVolumeUsdNEQ: Float - serviceCostPerHourVolumeUsdIn: [Float!] - serviceCostPerHourVolumeUsdNotIn: [Float!] - serviceCostPerHourVolumeUsdGT: Float - serviceCostPerHourVolumeUsdGTE: Float - serviceCostPerHourVolumeUsdLT: Float - serviceCostPerHourVolumeUsdLTE: Float - serviceCostPerHourVolumeUsdIsNil: Boolean - serviceCostPerHourVolumeUsdNotNil: Boolean - - """log_event_volume_per_hour field predicates""" - logEventVolumePerHour: Float - logEventVolumePerHourNEQ: Float - logEventVolumePerHourIn: [Float!] - logEventVolumePerHourNotIn: [Float!] - logEventVolumePerHourGT: Float - logEventVolumePerHourGTE: Float - logEventVolumePerHourLT: Float - logEventVolumePerHourLTE: Float - logEventVolumePerHourIsNil: Boolean - logEventVolumePerHourNotNil: Boolean - - """log_event_bytes_per_hour field predicates""" - logEventBytesPerHour: Float - logEventBytesPerHourNEQ: Float - logEventBytesPerHourIn: [Float!] - logEventBytesPerHourNotIn: [Float!] - logEventBytesPerHourGT: Float - logEventBytesPerHourGTE: Float - logEventBytesPerHourLT: Float - logEventBytesPerHourLTE: Float - logEventBytesPerHourIsNil: Boolean - logEventBytesPerHourNotNil: Boolean - - """log_event_cost_per_hour_bytes_usd field predicates""" - logEventCostPerHourBytesUsd: Float - logEventCostPerHourBytesUsdNEQ: Float - logEventCostPerHourBytesUsdIn: [Float!] - logEventCostPerHourBytesUsdNotIn: [Float!] - logEventCostPerHourBytesUsdGT: Float - logEventCostPerHourBytesUsdGTE: Float - logEventCostPerHourBytesUsdLT: Float - logEventCostPerHourBytesUsdLTE: Float - logEventCostPerHourBytesUsdIsNil: Boolean - logEventCostPerHourBytesUsdNotNil: Boolean - - """log_event_cost_per_hour_volume_usd field predicates""" - logEventCostPerHourVolumeUsd: Float - logEventCostPerHourVolumeUsdNEQ: Float - logEventCostPerHourVolumeUsdIn: [Float!] - logEventCostPerHourVolumeUsdNotIn: [Float!] - logEventCostPerHourVolumeUsdGT: Float - logEventCostPerHourVolumeUsdGTE: Float - logEventCostPerHourVolumeUsdLT: Float - logEventCostPerHourVolumeUsdLTE: Float - logEventCostPerHourVolumeUsdIsNil: Boolean - logEventCostPerHourVolumeUsdNotNil: Boolean - - """log_event_cost_per_hour_usd field predicates""" - logEventCostPerHourUsd: Float - logEventCostPerHourUsdNEQ: Float - logEventCostPerHourUsdIn: [Float!] - logEventCostPerHourUsdNotIn: [Float!] - logEventCostPerHourUsdGT: Float - logEventCostPerHourUsdGTE: Float - logEventCostPerHourUsdLT: Float - logEventCostPerHourUsdLTE: Float - logEventCostPerHourUsdIsNil: Boolean - logEventCostPerHourUsdNotNil: Boolean - - """estimated_volume_reduction_per_hour field predicates""" - estimatedVolumeReductionPerHour: Float - estimatedVolumeReductionPerHourNEQ: Float - estimatedVolumeReductionPerHourIn: [Float!] - estimatedVolumeReductionPerHourNotIn: [Float!] - estimatedVolumeReductionPerHourGT: Float - estimatedVolumeReductionPerHourGTE: Float - estimatedVolumeReductionPerHourLT: Float - estimatedVolumeReductionPerHourLTE: Float - estimatedVolumeReductionPerHourIsNil: Boolean - estimatedVolumeReductionPerHourNotNil: Boolean - - """estimated_bytes_reduction_per_hour field predicates""" - estimatedBytesReductionPerHour: Float - estimatedBytesReductionPerHourNEQ: Float - estimatedBytesReductionPerHourIn: [Float!] - estimatedBytesReductionPerHourNotIn: [Float!] - estimatedBytesReductionPerHourGT: Float - estimatedBytesReductionPerHourGTE: Float - estimatedBytesReductionPerHourLT: Float - estimatedBytesReductionPerHourLTE: Float - estimatedBytesReductionPerHourIsNil: Boolean - estimatedBytesReductionPerHourNotNil: Boolean - - """estimated_cost_reduction_per_hour_bytes_usd field predicates""" - estimatedCostReductionPerHourBytesUsd: Float - estimatedCostReductionPerHourBytesUsdNEQ: Float - estimatedCostReductionPerHourBytesUsdIn: [Float!] - estimatedCostReductionPerHourBytesUsdNotIn: [Float!] - estimatedCostReductionPerHourBytesUsdGT: Float - estimatedCostReductionPerHourBytesUsdGTE: Float - estimatedCostReductionPerHourBytesUsdLT: Float - estimatedCostReductionPerHourBytesUsdLTE: Float - estimatedCostReductionPerHourBytesUsdIsNil: Boolean - estimatedCostReductionPerHourBytesUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_volume_usd field predicates""" - estimatedCostReductionPerHourVolumeUsd: Float - estimatedCostReductionPerHourVolumeUsdNEQ: Float - estimatedCostReductionPerHourVolumeUsdIn: [Float!] - estimatedCostReductionPerHourVolumeUsdNotIn: [Float!] - estimatedCostReductionPerHourVolumeUsdGT: Float - estimatedCostReductionPerHourVolumeUsdGTE: Float - estimatedCostReductionPerHourVolumeUsdLT: Float - estimatedCostReductionPerHourVolumeUsdLTE: Float - estimatedCostReductionPerHourVolumeUsdIsNil: Boolean - estimatedCostReductionPerHourVolumeUsdNotNil: Boolean - - """estimated_cost_reduction_per_hour_usd field predicates""" - estimatedCostReductionPerHourUsd: Float - estimatedCostReductionPerHourUsdNEQ: Float - estimatedCostReductionPerHourUsdIn: [Float!] - estimatedCostReductionPerHourUsdNotIn: [Float!] - estimatedCostReductionPerHourUsdGT: Float - estimatedCostReductionPerHourUsdGTE: Float - estimatedCostReductionPerHourUsdLT: Float - estimatedCostReductionPerHourUsdLTE: Float - estimatedCostReductionPerHourUsdIsNil: Boolean - estimatedCostReductionPerHourUsdNotNil: Boolean - - """observed_volume_per_hour_before field predicates""" - observedVolumePerHourBefore: Float - observedVolumePerHourBeforeNEQ: Float - observedVolumePerHourBeforeIn: [Float!] - observedVolumePerHourBeforeNotIn: [Float!] - observedVolumePerHourBeforeGT: Float - observedVolumePerHourBeforeGTE: Float - observedVolumePerHourBeforeLT: Float - observedVolumePerHourBeforeLTE: Float - observedVolumePerHourBeforeIsNil: Boolean - observedVolumePerHourBeforeNotNil: Boolean - - """observed_volume_per_hour_after field predicates""" - observedVolumePerHourAfter: Float - observedVolumePerHourAfterNEQ: Float - observedVolumePerHourAfterIn: [Float!] - observedVolumePerHourAfterNotIn: [Float!] - observedVolumePerHourAfterGT: Float - observedVolumePerHourAfterGTE: Float - observedVolumePerHourAfterLT: Float - observedVolumePerHourAfterLTE: Float - observedVolumePerHourAfterIsNil: Boolean - observedVolumePerHourAfterNotNil: Boolean - - """observed_bytes_per_hour_before field predicates""" - observedBytesPerHourBefore: Float - observedBytesPerHourBeforeNEQ: Float - observedBytesPerHourBeforeIn: [Float!] - observedBytesPerHourBeforeNotIn: [Float!] - observedBytesPerHourBeforeGT: Float - observedBytesPerHourBeforeGTE: Float - observedBytesPerHourBeforeLT: Float - observedBytesPerHourBeforeLTE: Float - observedBytesPerHourBeforeIsNil: Boolean - observedBytesPerHourBeforeNotNil: Boolean - - """observed_bytes_per_hour_after field predicates""" - observedBytesPerHourAfter: Float - observedBytesPerHourAfterNEQ: Float - observedBytesPerHourAfterIn: [Float!] - observedBytesPerHourAfterNotIn: [Float!] - observedBytesPerHourAfterGT: Float - observedBytesPerHourAfterGTE: Float - observedBytesPerHourAfterLT: Float - observedBytesPerHourAfterLTE: Float - observedBytesPerHourAfterIsNil: Boolean - observedBytesPerHourAfterNotNil: Boolean - - """observed_cost_per_hour_before_bytes_usd field predicates""" - observedCostPerHourBeforeBytesUsd: Float - observedCostPerHourBeforeBytesUsdNEQ: Float - observedCostPerHourBeforeBytesUsdIn: [Float!] - observedCostPerHourBeforeBytesUsdNotIn: [Float!] - observedCostPerHourBeforeBytesUsdGT: Float - observedCostPerHourBeforeBytesUsdGTE: Float - observedCostPerHourBeforeBytesUsdLT: Float - observedCostPerHourBeforeBytesUsdLTE: Float - observedCostPerHourBeforeBytesUsdIsNil: Boolean - observedCostPerHourBeforeBytesUsdNotNil: Boolean - - """observed_cost_per_hour_before_volume_usd field predicates""" - observedCostPerHourBeforeVolumeUsd: Float - observedCostPerHourBeforeVolumeUsdNEQ: Float - observedCostPerHourBeforeVolumeUsdIn: [Float!] - observedCostPerHourBeforeVolumeUsdNotIn: [Float!] - observedCostPerHourBeforeVolumeUsdGT: Float - observedCostPerHourBeforeVolumeUsdGTE: Float - observedCostPerHourBeforeVolumeUsdLT: Float - observedCostPerHourBeforeVolumeUsdLTE: Float - observedCostPerHourBeforeVolumeUsdIsNil: Boolean - observedCostPerHourBeforeVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_before_usd field predicates""" - observedCostPerHourBeforeUsd: Float - observedCostPerHourBeforeUsdNEQ: Float - observedCostPerHourBeforeUsdIn: [Float!] - observedCostPerHourBeforeUsdNotIn: [Float!] - observedCostPerHourBeforeUsdGT: Float - observedCostPerHourBeforeUsdGTE: Float - observedCostPerHourBeforeUsdLT: Float - observedCostPerHourBeforeUsdLTE: Float - observedCostPerHourBeforeUsdIsNil: Boolean - observedCostPerHourBeforeUsdNotNil: Boolean - - """observed_cost_per_hour_after_bytes_usd field predicates""" - observedCostPerHourAfterBytesUsd: Float - observedCostPerHourAfterBytesUsdNEQ: Float - observedCostPerHourAfterBytesUsdIn: [Float!] - observedCostPerHourAfterBytesUsdNotIn: [Float!] - observedCostPerHourAfterBytesUsdGT: Float - observedCostPerHourAfterBytesUsdGTE: Float - observedCostPerHourAfterBytesUsdLT: Float - observedCostPerHourAfterBytesUsdLTE: Float - observedCostPerHourAfterBytesUsdIsNil: Boolean - observedCostPerHourAfterBytesUsdNotNil: Boolean - - """observed_cost_per_hour_after_volume_usd field predicates""" - observedCostPerHourAfterVolumeUsd: Float - observedCostPerHourAfterVolumeUsdNEQ: Float - observedCostPerHourAfterVolumeUsdIn: [Float!] - observedCostPerHourAfterVolumeUsdNotIn: [Float!] - observedCostPerHourAfterVolumeUsdGT: Float - observedCostPerHourAfterVolumeUsdGTE: Float - observedCostPerHourAfterVolumeUsdLT: Float - observedCostPerHourAfterVolumeUsdLTE: Float - observedCostPerHourAfterVolumeUsdIsNil: Boolean - observedCostPerHourAfterVolumeUsdNotNil: Boolean - - """observed_cost_per_hour_after_usd field predicates""" - observedCostPerHourAfterUsd: Float - observedCostPerHourAfterUsdNEQ: Float - observedCostPerHourAfterUsdIn: [Float!] - observedCostPerHourAfterUsdNotIn: [Float!] - observedCostPerHourAfterUsdGT: Float - observedCostPerHourAfterUsdGTE: Float - observedCostPerHourAfterUsdLT: Float - observedCostPerHourAfterUsdLTE: Float - observedCostPerHourAfterUsdIsNil: Boolean - observedCostPerHourAfterUsdNotNil: Boolean - - """refreshed_at field predicates""" - refreshedAt: Time - refreshedAtNEQ: Time - refreshedAtIn: [Time!] - refreshedAtNotIn: [Time!] - refreshedAtGT: Time - refreshedAtGTE: Time - refreshedAtLT: Time - refreshedAtLTE: Time -} + """PIN or PIN block.""" + pin -""" -ServiceWhereInput is used for filtering Service objects. -Input was generated by ent. -""" -input ServiceWhereInput { - not: ServiceWhereInput - and: [ServiceWhereInput!] - or: [ServiceWhereInput!] + """Bank account, routing, IBAN, or similar bank payment detail.""" + bank_account - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + Reusable tokenized payment credential, vault token, provider payment method + token, payment account token, or stored payment method token. Do not use for + transaction IDs, charge IDs, dispute IDs, payment method IDs, payment + instrument IDs, PSP references, or one-off workflow references. + """ + payment_token - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """ + Billing address or billing-address input exposed in a payment or billing + context, including natural-person name fields when they are part of the + billing address object. Do not use for country, region, state, locale, + currency, status, or address-like operational metadata by itself, even inside + a billing-address object. + """ + billing_address - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String + """ + Phone number exposed in a payment or billing context, including telephone + fields inside a billing address/contact input. Prefer this over generic PII + phone when the phone is materially part of billing or payment handling. + """ + billing_phone - """description field predicates""" - description: String - descriptionNEQ: String - descriptionIn: [String!] - descriptionNotIn: [String!] - descriptionGT: String - descriptionGTE: String - descriptionLT: String - descriptionLTE: String - descriptionContains: String - descriptionHasPrefix: String - descriptionHasSuffix: String - descriptionEqualFold: String - descriptionContainsFold: String + """ + Government, tax, or identity document number exposed in a payment or billing + context. Prefer this over generic PII government ID when the identifier is + materially part of billing or payment handling. + """ + billing_government_id +} - """enabled field predicates""" - enabled: Boolean - enabledNEQ: Boolean +enum SensitiveDataPHIElement { + """ + Patient, medical record, or healthcare-system patient identifier. Do not use + for lab order IDs, provider IDs, event IDs, encounter workflow IDs, request + IDs, or other operational workflow references. + """ + patient_identifier - """initial_weekly_log_count field predicates""" - initialWeeklyLogCount: Int - initialWeeklyLogCountNEQ: Int - initialWeeklyLogCountIn: [Int!] - initialWeeklyLogCountNotIn: [Int!] - initialWeeklyLogCountGT: Int - initialWeeklyLogCountGTE: Int - initialWeeklyLogCountLT: Int - initialWeeklyLogCountLTE: Int - initialWeeklyLogCountIsNil: Boolean - initialWeeklyLogCountNotNil: Boolean + """Diagnosed condition.""" + diagnosis - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + Health condition, symptom, pregnancy status, mental health detail, substance-use detail, or similar health status. + """ + health_condition - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time + """ + Treatment, procedure, surgery, therapy, care plan, or clinical activity. + """ + treatment_or_procedure - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] + """Medication or prescription detail.""" + medication - """log_events edge predicates""" - hasLogEvents: Boolean - hasLogEventsWith: [LogEventWhereInput!] -} + """ + Lab result, vital sign, test result, or clinical measurement tied to care. Use + this for measured clinical values rather than health_condition. + """ + lab_result -type Team implements Node { - """Unique identifier of the team""" - id: ID! + """ + Clinical note or health narrative. A single free-text clinical narrative is + one clinical_note exposure unless separate structured fields expose distinct PHI elements. + """ + clinical_note + + """Health plan member, policy, beneficiary, or coverage identifier.""" + health_plan_identifier """ - Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. + Healthcare claim, explanation-of-benefits, or payment-for-care identifier or + detail. Do not use for generic claim status values by themselves. """ - accountID: UUID! + healthcare_claim + + """ + Healthcare appointment, encounter, admission, discharge, or visit detail that + reveals care context. Do not use for generic appointment or visit status/date + values, even when adjacent fields reveal care context. + """ + appointment_or_visit +} - """Parent workspace this team belongs to""" - workspaceID: ID! +enum SensitiveDataPIIElement { + """Email address.""" + email - """Human-readable name within the workspace""" - name: String! + """Phone number.""" + phone - """When the team was created""" - createdAt: Time! + """Person name.""" + name - """When the team was last updated""" - updatedAt: Time! + """Mailing, shipping, or residential address.""" + address - """Workspace this team belongs to""" - workspace: Workspace! + """Date of birth.""" + date_of_birth + + """ + Government-issued identifier such as SSN, tax ID, passport, or driver's license. + """ + government_id } -"""A connection to a list of items.""" -type TeamConnection { - """A list of edges.""" - edges: [TeamEdge] +""" +Lists exact observed paths that expose material sensitive data in a recurring log event. +""" +type SensitiveDataProfile { + """ + Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. + """ + secretExposures: [SensitiveDataProfileSecretExposures!]! - """Information to aid in pagination.""" - pageInfo: PageInfo! + """Exposed real payment-sensitive data.""" + paymentExposures: [SensitiveDataProfilePaymentExposures!]! - """Identifies the total count of items in the connection.""" - totalCount: Int! + """Exposed meaningful natural-person identity data.""" + piiExposures: [SensitiveDataProfilePIIExposures!]! + + """Exposed individually identifiable health, care, or coverage data.""" + phiExposures: [SensitiveDataProfilePHIExposures!]! } -"""An edge in a connection.""" -type TeamEdge { - """The item at the end of the edge.""" - node: Team +"""Exposed real payment-sensitive data.""" +type SensitiveDataProfilePaymentExposures { + """The specific payment data element exposed at this path.""" + element: SensitiveDataPaymentElement! - """A cursor for use in pagination.""" - cursor: Cursor! + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePaymentExposuresPath! } -"""Ordering options for Team connections""" -input TeamOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfilePaymentExposuresPath { + """The exact observed field path.""" + path: [String!]! - """The field by which to order Teams.""" - field: TeamOrderField! + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } -"""Properties by which Team connections can be ordered.""" -enum TeamOrderField { - NAME - CREATED_AT - UPDATED_AT +"""Exposed individually identifiable health, care, or coverage data.""" +type SensitiveDataProfilePHIExposures { + """The specific PHI element exposed at this path.""" + element: SensitiveDataPHIElement! + + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePHIExposuresPath! } """ -TeamWhereInput is used for filtering Team objects. -Input was generated by ent. +The minimal exact observed leaf path whose value exposes the sensitive element. """ -input TeamWhereInput { - not: TeamWhereInput - and: [TeamWhereInput!] - or: [TeamWhereInput!] +type SensitiveDataProfilePHIExposuresPath { + """The exact observed field path.""" + path: [String!]! - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! +} - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID +"""Exposed meaningful natural-person identity data.""" +type SensitiveDataProfilePIIExposures { + """The specific PII element exposed at this path.""" + element: SensitiveDataPIIElement! - """workspace_id field predicates""" - workspaceID: ID - workspaceIDNEQ: ID - workspaceIDIn: [ID!] - workspaceIDNotIn: [ID!] + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfilePIIExposuresPath! +} - """name field predicates""" - name: String - nameNEQ: String - nameIn: [String!] - nameNotIn: [String!] - nameGT: String - nameGTE: String - nameLT: String - nameLTE: String - nameContains: String - nameHasPrefix: String - nameHasSuffix: String - nameEqualFold: String - nameContainsFold: String +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfilePIIExposuresPath { + """The exact observed field path.""" + path: [String!]! - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! +} - """updated_at field predicates""" - updatedAt: Time - updatedAtNEQ: Time - updatedAtIn: [Time!] - updatedAtNotIn: [Time!] - updatedAtGT: Time - updatedAtGTE: Time - updatedAtLT: Time - updatedAtLTE: Time +""" +Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. +""" +type SensitiveDataProfileSecretExposures { + """The specific secret element exposed at this path.""" + element: SensitiveDataSecretElement! - """workspace edge predicates""" - hasWorkspace: Boolean - hasWorkspaceWith: [WorkspaceWhereInput!] + """ + The minimal exact observed leaf path whose value exposes the sensitive element. + """ + path: SensitiveDataProfileSecretExposuresPath! } -"""Text content from the user or assistant.""" -type TextBlock { - content: String! +""" +The minimal exact observed leaf path whose value exposes the sensitive element. +""" +type SensitiveDataProfileSecretExposuresPath { + """The exact observed field path.""" + path: [String!]! + + """ + The grouping path for sibling fields that should be reasoned about together. + """ + pathFamily: [String!]! } -"""Text content from the user or assistant.""" -input TextBlockInput { - content: String! -} +""" +Lists exact observed paths that expose material sensitive data in a recurring log event. +""" +input SensitiveDataProfileWhereInput { + """Negated predicates.""" + not: SensitiveDataProfileWhereInput -"""The AI's internal reasoning (extended thinking).""" -type ThinkingBlock { - content: String! -} + """Predicates that must all match.""" + and: [SensitiveDataProfileWhereInput!] -"""The AI's internal reasoning (extended thinking).""" -input ThinkingBlockInput { - content: String! -} + """Predicates where at least one must match.""" + or: [SensitiveDataProfileWhereInput!] -"""The builtin Time type""" -scalar Time + """Whether the sensitive_data_profile JSONB value is present.""" + present: Boolean -"""The result of a tool call.""" -type ToolResult { - """The ID of the tool call this result is for.""" - toolUseId: String! + """ + Exposed real secrets, credentials, authentication tokens, signing material, encryption keys, or connection secrets. + """ + hasSecretExposures: Boolean - """Whether the tool execution resulted in an error.""" - isError: Boolean + """Exposed real payment-sensitive data.""" + hasPaymentExposures: Boolean - """Human-readable error message when isError is true.""" - error: String + """Exposed meaningful natural-person identity data.""" + hasPIIExposures: Boolean - """Structured result data (JSON object).""" - content: Map + """Exposed individually identifiable health, care, or coverage data.""" + hasPHIExposures: Boolean } -"""The result of a tool call.""" -input ToolResultInput { - """The ID of the tool call this result is for.""" - toolUseId: String! +enum SensitiveDataSecretElement { + """API key or service key material.""" + api_key - """Whether the tool execution resulted in an error.""" - isError: Boolean! + """ + Bearer token, OAuth token, JWT, refresh token, or similar access credential. + """ + access_token - """Human-readable error message when isError is true.""" - error: String + """ + Session token, session cookie, or session secret usable for authentication. + """ + session_token - """Structured result data (JSON object).""" - content: Map -} + """Password, passphrase, or password-like credential.""" + password -"""A tool call from the assistant.""" -type ToolUse { - """Unique identifier for this tool call.""" - id: String! + """ + Private key, signing key, certificate private material, or similar cryptographic secret. + """ + private_key - """The name of the tool being called.""" - name: String! + """ + Database URL, service URL, connection string, DSN, or embedded connection credential. + """ + connection_secret + + """ + OAuth client secret, application client secret, or similar client credential. Do not use for client IDs. + """ + client_secret - """The input parameters for the tool (JSON object).""" - input: Map! + """ + HMAC secret, shared signing secret, JWT signing secret, webhook signing + secret, callback verification secret, or similar message-signing material. + """ + signing_secret + + """Symmetric encryption key or data-encryption key material.""" + encryption_key } -"""A tool call from the assistant.""" -input ToolUseInput { - """Unique identifier for this tool call.""" - id: String! +type Service { + """Unique identifier of the service""" + id: ID! + + """Parent account this service belongs to""" + accountID: ID! - """The name of the tool being called.""" + """Service identifier in telemetry (e.g., 'checkout-service')""" name: String! - """The input parameters for the tool (JSON object).""" - input: Map! -} + """Whether log analysis and policy generation is active for this service""" + enabled: Boolean! -""" -UpdateAccountInput is used for update Account object. -Input was generated by ent. -""" -input UpdateAccountInput { - """Human-readable name within the organization""" - name: String + """ + Whether pre-catalog audit workflows should collect logs, build service context, and run preclustering for this service + """ + auditEnabled: Boolean! """ - Multiplier applied to volume data via trigger. 1 = real data, >1 = scaled for demos. + Approximate weekly log count from initial catalog loop pass (7-day period from Datadog) """ - demoScaleFactor: Int - organizationID: ID - datadogAccountID: ID - clearDatadogAccount: Boolean -} + initialWeeklyLogCount: Int -""" -UpdateConversationInput is used for update Conversation object. -Input was generated by ent. -""" -input UpdateConversationInput { - """AI-generated title, set after first exchange""" - title: String - clearTitle: Boolean - viewID: ID - clearView: Boolean -} + """When the service was created""" + createdAt: Time! -""" -UpdateDatadogAccountInput is used for update DatadogAccount object. -Input was generated by ent. -""" -input UpdateDatadogAccountInput { - """Display name for this Datadog account""" - name: String + """When the service was last updated""" + updatedAt: Time! + + """Account this service belongs to""" + account: Account! + + """Log event types produced by this service""" + logEvents(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: LogEventOrder, where: LogEventWhereInput): LogEventConnection! """ - Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - ap1.datadoghq.com, AP2: ap2.datadoghq.com. + Status of this service in the catalog pipeline. + Shows where the service is in the catalog pipeline. + Returns null if the status view has no row yet. """ - site: DatadogAccountSite + status: ServiceStatus """ - Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - ($0.10/GB). Set to override with actual contract rate. + Narrow status surface for the services list. + Drops issue-projection rollups (preview / effective / savings) and + per-service indexing cost from the full status to keep the list query cheap. + Use status for the service detail page. + Returns null if no baseline or volume data is available yet. """ - costPerGBIngested: Float - clearCostPerGBIngested: Boolean + statusSummary: ServiceStatusSummary """ - Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - 80%). Leaves headroom for the customer's own API usage. + Active issue counts for this service, grouped by product domain and priority. """ - rateLimitUtilization: Float - clearRateLimitUtilization: Boolean - accountID: ID -} + issueSummary: ServiceIssueSummary! -""" -UpdateEdgeApiKeyInput is used for update EdgeApiKey object. -Input was generated by ent. -""" -input UpdateEdgeApiKeyInput { - """User-provided name for this key (e.g., 'Production Collector')""" - name: String + """Owning team for this service, if one has been assigned.""" + team: Team - """When this key was revoked (null if active)""" - revokedAt: Time - clearRevokedAt: Boolean -} + """Bucketed log volume for this service.""" + logVolume(input: LogVolumeInput!): LogVolumeSeries! -""" -UpdateOrganizationInput is used for update Organization object. -Input was generated by ent. -""" -input UpdateOrganizationInput { - """Human-readable name, unique across the system""" - name: String + """Typed service JSONB facts.""" + serviceFacts: ServiceFacts! } -""" -UpdateServiceInput is used for update Service object. -Input was generated by ent. -""" -input UpdateServiceInput { - """Whether log analysis and policy generation is active for this service""" - enabled: Boolean +type ServiceAuditEnabledFacet { + buckets: [ServiceAuditEnabledFacetBucket!]! } -""" -UpdateTeamInput is used for update Team object. -Input was generated by ent. -""" -input UpdateTeamInput { - """Human-readable name within the workspace""" - name: String - workspaceID: ID +type ServiceAuditEnabledFacetBucket { + value: Boolean! + count: Int! } -""" -UpdateWorkspaceInput is used for update Workspace object. -Input was generated by ent. -""" -input UpdateWorkspaceInput { - """Human-readable name within the account""" - name: String +type ServiceConnection { + edges: [ServiceEdge!]! + pageInfo: PageInfo! + totalCount: Int! + summary: ServiceSummary! + facets: ServiceFacets! +} - """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. - """ - purpose: WorkspacePurpose - accountID: ID +type ServiceCurrentStatus { + serviceTotals: StatusServiceTotals! + severity: StatusSeverityTotals! + totals: StatusMeasurementTotals! } -scalar UUID +type ServiceEdge { + node: Service! + cursor: Cursor! +} -input ValidateDatadogApiKeyInput { - apiKey: String! - site: DatadogAccountSite! +type ServiceEnabledFacet { + buckets: [ServiceEnabledFacetBucket!]! } -type ValidateDatadogApiKeyResult { - valid: Boolean! - error: String +type ServiceEnabledFacetBucket { + value: Boolean! + count: Int! } -type View implements Node { - """Unique identifier""" - id: ID! +type ServiceFacets { + enabled(limit: Int): ServiceEnabledFacet! + auditEnabled(limit: Int): ServiceAuditEnabledFacet! +} +"""Typed service JSONB facts.""" +type ServiceFacts { """ - Denormalized for tenant isolation. Auto-set via trigger from message.account_id. + Names the concrete telemetry-emitting technology when it is recognizable. """ - accountID: UUID! - - """Assistant message that created this view via show_view tool call""" - messageID: ID! + technologyIdentity: TechnologyIdentity - """Denormalized from message for easier queries""" - conversationID: ID! + """ + Describes who owns the service code and who operates the running service. + """ + ownershipModel: OwnershipModel - """Parent view if this is a refinement/iteration""" - forkedFromID: ID + """Describes the technical runtime roles a service plays in the system.""" + runtimeRole: RuntimeRole """ - Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. + Describes visible implementation language, framework, runtime, and stack details. """ - entityType: ViewEntityType! + implementationProfile: ImplementationProfile - """Raw SQL query executed against the client's local SQLite database""" - query: String! + """ + Describes what this service does for the business, product, or platform. + """ + domainFunction: DomainFunction - """WorkOS user ID who triggered this view creation""" - createdBy: String! + """ + Describes how one service's own logs should compress into recurring emitted lines and occurrence details. + """ + logEmissionModel: LogEmissionModel +} - """When the view was created""" - createdAt: Time! +"""Filters for service JSONB facts.""" +input ServiceFactsWhereInput { + """Negated predicates.""" + not: ServiceFactsWhereInput - """Assistant message that created this view""" - message: Message! + """Predicates that must all match.""" + and: [ServiceFactsWhereInput!] - """Conversation this view was created in""" - conversation: Conversation! + """Predicates where at least one must match.""" + or: [ServiceFactsWhereInput!] - """Parent view if this is a fork""" - forkedFrom: View - forks: [View!] + """ + Names the concrete telemetry-emitting technology when it is recognizable. + """ + technologyIdentity: TechnologyIdentityWhereInput - """Users who favorited this view""" - favorites: [ViewFavorite!] + """ + Describes who owns the service code and who operates the running service. + """ + ownershipModel: OwnershipModelWhereInput - """Conversations created to iterate on this view""" - iterationConversations: [Conversation!] -} + """Describes the technical runtime roles a service plays in the system.""" + runtimeRole: RuntimeRoleWhereInput -"""A connection to a list of items.""" -type ViewConnection { - """A list of edges.""" - edges: [ViewEdge] + """ + Describes visible implementation language, framework, runtime, and stack details. + """ + implementationProfile: ImplementationProfileWhereInput - """Information to aid in pagination.""" - pageInfo: PageInfo! + """ + Describes what this service does for the business, product, or platform. + """ + domainFunction: DomainFunctionWhereInput - """Identifies the total count of items in the connection.""" - totalCount: Int! + """ + Describes how one service's own logs should compress into recurring emitted lines and occurrence details. + """ + logEmissionModel: LogEmissionModelWhereInput } -"""An edge in a connection.""" -type ViewEdge { - """The item at the end of the edge.""" - node: View +type ServiceIssueSummary { + openCount: Int! + highPriorityOpenCount: Int! + costOpenCount: Int! + costHighPriorityOpenCount: Int! + complianceOpenCount: Int! + complianceHighPriorityOpenCount: Int! +} - """A cursor for use in pagination.""" - cursor: Cursor! +input ServiceOrder { + direction: OrderDirection! = ASC + field: ServiceOrderField! } -"""ViewEntityType is enum for the field entity_type""" -enum ViewEntityType { - service - log_event - policy +enum ServiceOrderField { + ID + NAME + ENABLED + AUDIT_ENABLED + CREATED_AT + UPDATED_AT + TEAM_NAME + CURRENT_BYTES_PER_HOUR } -""" -The authenticated user making the request. -Returns null for organizationId if the user is not assigned to an organization. -""" -type Viewer { - """The user's unique identifier (WorkOS user ID).""" - id: String! +enum ServiceOwnershipCode { + """First-party software the team appears to own or write.""" + first_party - """The user's email address.""" - email: String! + """Third-party software, product, library, or vendor code.""" + third_party - """ - The organization ID the user is authenticated for, or null if not assigned to any organization. - """ - organizationId: UUID + """Insufficient evidence for code ownership.""" + unknown } -type ViewFavorite implements Node { - """Unique identifier""" - id: ID! - +enum ServiceOwnershipOperation { """ - Denormalized for tenant isolation. Auto-set via trigger from view.account_id. + The team appears to operate, deploy, configure, or administer the running software. """ - accountID: UUID! - - """The view being favorited""" - viewID: ID! - - """WorkOS user ID who favorited this view""" - userID: String! + self_operated - """When the view was favorited""" - createdAt: Time! + """A vendor or external provider appears to operate the running software.""" + vendor_operated - """The view being favorited""" - view: View! + """Insufficient evidence for operational ownership.""" + unknown } -"""A connection to a list of items.""" -type ViewFavoriteConnection { - """A list of edges.""" - edges: [ViewFavoriteEdge] +type ServiceStatus { + health: StatusHealth! + coverage: ServiceStatusCoverage! + current: ServiceCurrentStatus! + preview: StatusScenario! + effective: StatusScenario! +} - """Information to aid in pagination.""" - pageInfo: PageInfo! +type ServiceStatusCoverage { + logEventCount: Int! + logEventAnalyzedCount: Int! + previewLogEventCount: Int! + effectiveLogEventCount: Int! +} - """Identifies the total count of items in the connection.""" - totalCount: Int! +type ServiceStatusSummary { + health: StatusHealth! + logEventCount: Int! + logEventAnalyzedCount: Int! + current: ServiceStatusSummaryCurrent! } -"""An edge in a connection.""" -type ViewFavoriteEdge { - """The item at the end of the edge.""" - node: ViewFavorite +type ServiceStatusSummaryCurrent { + eventsPerHour: Float + bytesPerHour: Float + totalUsdPerHour: Float + severity: StatusSeverityTotals! +} - """A cursor for use in pagination.""" - cursor: Cursor! +type ServiceSummary { + count: Int! } -"""Ordering options for ViewFavorite connections""" -input ViewFavoriteOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +input ServiceWhereInput { + not: ServiceWhereInput + and: [ServiceWhereInput!] + or: [ServiceWhereInput!] - """The field by which to order ViewFavorites.""" - field: ViewFavoriteOrderField! -} + """"account" edge predicates.""" + account: AccountWhereInput -"""Properties by which ViewFavorite connections can be ordered.""" -enum ViewFavoriteOrderField { - CREATED_AT -} + """Whether the "account" edge has at least one related row.""" + hasAccount: Boolean -""" -ViewFavoriteWhereInput is used for filtering ViewFavorite objects. -Input was generated by ent. -""" -input ViewFavoriteWhereInput { - not: ViewFavoriteWhereInput - and: [ViewFavoriteWhereInput!] - or: [ViewFavoriteWhereInput!] + """"log_events" edge predicates.""" + logEvents: LogEventWhereInput + + """Whether the "log_events" edge has at least one related row.""" + hasLogEvents: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"account_id" field predicates.""" + accountID: ID + + """"account_id" field predicates.""" + accountIDNEQ: ID + + """"account_id" field predicates.""" + accountIDIn: [ID!] + + """"account_id" field predicates.""" + accountIDNotIn: [ID!] + + """"name" field predicates.""" + name: String + + """"name" field predicates.""" + nameNEQ: String + + """"name" field predicates.""" + nameIn: [String!] + + """"name" field predicates.""" + nameNotIn: [String!] + + """"name" field predicates.""" + nameGT: String + + """"name" field predicates.""" + nameGTE: String + + """"name" field predicates.""" + nameLT: String + + """"name" field predicates.""" + nameLTE: String + + """"name" field predicates.""" + nameContains: String + + """"name" field predicates.""" + nameHasPrefix: String + + """"name" field predicates.""" + nameHasSuffix: String + + """"name" field predicates.""" + nameEqualFold: String + + """"name" field predicates.""" + nameContainsFold: String + + """"enabled" field predicates.""" + enabled: Boolean + + """"enabled" field predicates.""" + enabledNEQ: Boolean + + """"audit_enabled" field predicates.""" + auditEnabled: Boolean + + """"audit_enabled" field predicates.""" + auditEnabledNEQ: Boolean + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCount: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNEQ: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountIn: [Int!] + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNotIn: [Int!] + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountGT: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountGTE: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountLT: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountLTE: Int + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountIsNil: Boolean + + """"initial_weekly_log_count" field predicates.""" + initialWeeklyLogCountNotNil: Boolean - """view_id field predicates""" - viewID: ID - viewIDNEQ: ID - viewIDIn: [ID!] - viewIDNotIn: [ID!] - - """user_id field predicates""" - userID: String - userIDNEQ: String - userIDIn: [String!] - userIDNotIn: [String!] - userIDGT: String - userIDGTE: String - userIDLT: String - userIDLTE: String - userIDContains: String - userIDHasPrefix: String - userIDHasSuffix: String - userIDEqualFold: String - userIDContainsFold: String - - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """view edge predicates""" - hasView: Boolean - hasViewWith: [ViewWhereInput!] -} + """"updated_at" field predicates.""" + updatedAt: Time -"""Ordering options for View connections""" -input ViewOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC + """"updated_at" field predicates.""" + updatedAtNEQ: Time - """The field by which to order Views.""" - field: ViewOrderField! -} + """"updated_at" field predicates.""" + updatedAtIn: [Time!] -"""Properties by which View connections can be ordered.""" -enum ViewOrderField { - ENTITY_TYPE - CREATED_AT -} + """"updated_at" field predicates.""" + updatedAtNotIn: [Time!] -""" -ViewWhereInput is used for filtering View objects. -Input was generated by ent. -""" -input ViewWhereInput { - not: ViewWhereInput - and: [ViewWhereInput!] - or: [ViewWhereInput!] + """"updated_at" field predicates.""" + updatedAtGT: Time - """id field predicates""" - id: ID - idNEQ: ID - idIn: [ID!] - idNotIn: [ID!] - idGT: ID - idGTE: ID - idLT: ID - idLTE: ID + """"updated_at" field predicates.""" + updatedAtGTE: Time - """account_id field predicates""" - accountID: UUID - accountIDNEQ: UUID - accountIDIn: [UUID!] - accountIDNotIn: [UUID!] - accountIDGT: UUID - accountIDGTE: UUID - accountIDLT: UUID - accountIDLTE: UUID + """"updated_at" field predicates.""" + updatedAtLT: Time - """message_id field predicates""" - messageID: ID - messageIDNEQ: ID - messageIDIn: [ID!] - messageIDNotIn: [ID!] - - """conversation_id field predicates""" - conversationID: ID - conversationIDNEQ: ID - conversationIDIn: [ID!] - conversationIDNotIn: [ID!] - - """forked_from_id field predicates""" - forkedFromID: ID - forkedFromIDNEQ: ID - forkedFromIDIn: [ID!] - forkedFromIDNotIn: [ID!] - forkedFromIDIsNil: Boolean - forkedFromIDNotNil: Boolean - - """entity_type field predicates""" - entityType: ViewEntityType - entityTypeNEQ: ViewEntityType - entityTypeIn: [ViewEntityType!] - entityTypeNotIn: [ViewEntityType!] - - """query field predicates""" - query: String - queryNEQ: String - queryIn: [String!] - queryNotIn: [String!] - queryGT: String - queryGTE: String - queryLT: String - queryLTE: String - queryContains: String - queryHasPrefix: String - queryHasSuffix: String - queryEqualFold: String - queryContainsFold: String - - """created_by field predicates""" - createdBy: String - createdByNEQ: String - createdByIn: [String!] - createdByNotIn: [String!] - createdByGT: String - createdByGTE: String - createdByLT: String - createdByLTE: String - createdByContains: String - createdByHasPrefix: String - createdByHasSuffix: String - createdByEqualFold: String - createdByContainsFold: String - - """created_at field predicates""" - createdAt: Time - createdAtNEQ: Time - createdAtIn: [Time!] - createdAtNotIn: [Time!] - createdAtGT: Time - createdAtGTE: Time - createdAtLT: Time - createdAtLTE: Time + """"updated_at" field predicates.""" + updatedAtLTE: Time + + """Filters for service JSONB facts.""" + serviceFacts: ServiceFactsWhereInput +} - """message edge predicates""" - hasMessage: Boolean - hasMessageWith: [MessageWhereInput!] +enum StatusHealth { + DISABLED + INACTIVE + ERROR + OK +} - """conversation edge predicates""" - hasConversation: Boolean - hasConversationWith: [ConversationWhereInput!] +type StatusMeasurementTotals { + eventsPerHour: Float + bytesPerHour: Float + bytesUsdPerHour: Float + volumeUsdPerHour: Float + totalUsdPerHour: Float +} - """forked_from edge predicates""" - hasForkedFrom: Boolean - hasForkedFromWith: [ViewWhereInput!] +type StatusReadiness { + readyForUse: Boolean! +} - """forks edge predicates""" - hasForks: Boolean - hasForksWith: [ViewWhereInput!] +type StatusScenario { + totals: StatusMeasurementTotals! + savings: StatusMeasurementTotals! +} - """favorites edge predicates""" - hasFavorites: Boolean - hasFavoritesWith: [ViewFavoriteWhereInput!] +type StatusServiceTotals { + eventsPerHour: Float + volumeUsdPerHour: Float +} - """iteration_conversations edge predicates""" - hasIterationConversations: Boolean - hasIterationConversationsWith: [ConversationWhereInput!] +type StatusSeverityTotals { + debugEventsPerHour: Float + infoEventsPerHour: Float + warnEventsPerHour: Float + errorEventsPerHour: Float + otherEventsPerHour: Float } -type Workspace implements Node { - """Unique identifier of the workspace""" +type Team { + """Unique identifier of the team""" id: ID! - """Parent account this workspace belongs to""" - accountID: ID! + """Organization this team belongs to""" + organizationID: ID! - """Human-readable name within the account""" + """Human-readable team name within the organization""" name: String! - """ - Primary purpose determining evaluation strategy. observability: performance - and reliability, security: threat detection, compliance: regulatory requirements. - """ - purpose: WorkspacePurpose! + """Optional description of the team's scope and responsibilities""" + description: String - """When the workspace was created""" + """When the team was created""" createdAt: Time! - """When the workspace was last updated""" + """When the team was last updated""" updatedAt: Time! - """Account this workspace belongs to""" - account: Account! - - """Teams assigned to manage this workspace""" - teams: [Team!] - - """Chat conversations in this workspace""" - conversations: [Conversation!] - - """Log event policies for this workspace""" - logEventPolicies: [LogEventPolicy!] + """Organization this team belongs to""" + organization: Organization! + members: [TeamMember!]! + services(after: Cursor, first: Int, before: Cursor, last: Int, orderBy: ServiceOrder, where: ServiceWhereInput): ServiceConnection! } -"""A connection to a list of items.""" -type WorkspaceConnection { - """A list of edges.""" - edges: [WorkspaceEdge] - - """Information to aid in pagination.""" +type TeamConnection { + edges: [TeamEdge!]! pageInfo: PageInfo! - - """Identifies the total count of items in the connection.""" totalCount: Int! } -"""An edge in a connection.""" -type WorkspaceEdge { - """The item at the end of the edge.""" - node: Workspace +"""Team creation input.""" +input TeamCreateInput { + """Organization this team belongs to""" + organizationID: ID! + + """Human-readable team name within the organization""" + name: String! +} - """A cursor for use in pagination.""" +type TeamEdge { + node: Team! cursor: Cursor! } -"""Ordering options for Workspace connections""" -input WorkspaceOrder { - """The ordering direction.""" - direction: OrderDirection! = ASC +type TeamMember { + userID: String! +} - """The field by which to order Workspaces.""" - field: WorkspaceOrderField! +input TeamOrder { + direction: OrderDirection! = ASC + field: TeamOrderField! } -"""Properties by which Workspace connections can be ordered.""" -enum WorkspaceOrderField { +enum TeamOrderField { + ID NAME - PURPOSE + DESCRIPTION CREATED_AT UPDATED_AT } -"""WorkspacePurpose is enum for the field purpose""" -enum WorkspacePurpose { - observability - security - compliance +"""Team update input.""" +input TeamUpdateInput { + """Human-readable team name within the organization""" + name: String } -""" -WorkspaceWhereInput is used for filtering Workspace objects. -Input was generated by ent. -""" -input WorkspaceWhereInput { - not: WorkspaceWhereInput - and: [WorkspaceWhereInput!] - or: [WorkspaceWhereInput!] +input TeamWhereInput { + not: TeamWhereInput + and: [TeamWhereInput!] + or: [TeamWhereInput!] + + """"organization" edge predicates.""" + organization: OrganizationWhereInput + + """Whether the "organization" edge has at least one related row.""" + hasOrganization: Boolean - """id field predicates""" + """"id" field predicates.""" id: ID + + """"id" field predicates.""" idNEQ: ID + + """"id" field predicates.""" idIn: [ID!] + + """"id" field predicates.""" idNotIn: [ID!] + + """"id" field predicates.""" idGT: ID + + """"id" field predicates.""" idGTE: ID + + """"id" field predicates.""" idLT: ID + + """"id" field predicates.""" idLTE: ID - """account_id field predicates""" - accountID: ID - accountIDNEQ: ID - accountIDIn: [ID!] - accountIDNotIn: [ID!] + """"organization_id" field predicates.""" + organizationID: ID + + """"organization_id" field predicates.""" + organizationIDNEQ: ID + + """"organization_id" field predicates.""" + organizationIDIn: [ID!] + + """"organization_id" field predicates.""" + organizationIDNotIn: [ID!] - """name field predicates""" + """"name" field predicates.""" name: String + + """"name" field predicates.""" nameNEQ: String + + """"name" field predicates.""" nameIn: [String!] + + """"name" field predicates.""" nameNotIn: [String!] + + """"name" field predicates.""" nameGT: String + + """"name" field predicates.""" nameGTE: String + + """"name" field predicates.""" nameLT: String + + """"name" field predicates.""" nameLTE: String + + """"name" field predicates.""" nameContains: String + + """"name" field predicates.""" nameHasPrefix: String + + """"name" field predicates.""" nameHasSuffix: String + + """"name" field predicates.""" nameEqualFold: String + + """"name" field predicates.""" nameContainsFold: String - """purpose field predicates""" - purpose: WorkspacePurpose - purposeNEQ: WorkspacePurpose - purposeIn: [WorkspacePurpose!] - purposeNotIn: [WorkspacePurpose!] + """"description" field predicates.""" + description: String + + """"description" field predicates.""" + descriptionNEQ: String + + """"description" field predicates.""" + descriptionIn: [String!] + + """"description" field predicates.""" + descriptionNotIn: [String!] + + """"description" field predicates.""" + descriptionGT: String + + """"description" field predicates.""" + descriptionGTE: String + + """"description" field predicates.""" + descriptionLT: String + + """"description" field predicates.""" + descriptionLTE: String + + """"description" field predicates.""" + descriptionContains: String + + """"description" field predicates.""" + descriptionHasPrefix: String + + """"description" field predicates.""" + descriptionHasSuffix: String + + """"description" field predicates.""" + descriptionIsNil: Boolean + + """"description" field predicates.""" + descriptionNotNil: Boolean + + """"description" field predicates.""" + descriptionEqualFold: String + + """"description" field predicates.""" + descriptionContainsFold: String - """created_at field predicates""" + """"created_at" field predicates.""" createdAt: Time + + """"created_at" field predicates.""" createdAtNEQ: Time + + """"created_at" field predicates.""" createdAtIn: [Time!] + + """"created_at" field predicates.""" createdAtNotIn: [Time!] + + """"created_at" field predicates.""" createdAtGT: Time + + """"created_at" field predicates.""" createdAtGTE: Time + + """"created_at" field predicates.""" createdAtLT: Time + + """"created_at" field predicates.""" createdAtLTE: Time - """updated_at field predicates""" + """"updated_at" field predicates.""" updatedAt: Time + + """"updated_at" field predicates.""" updatedAtNEQ: Time + + """"updated_at" field predicates.""" updatedAtIn: [Time!] + + """"updated_at" field predicates.""" updatedAtNotIn: [Time!] + + """"updated_at" field predicates.""" updatedAtGT: Time + + """"updated_at" field predicates.""" updatedAtGTE: Time + + """"updated_at" field predicates.""" updatedAtLT: Time + + """"updated_at" field predicates.""" updatedAtLTE: Time +} - """account edge predicates""" - hasAccount: Boolean - hasAccountWith: [AccountWhereInput!] +""" +Names the concrete telemetry-emitting technology when it is recognizable. +""" +type TechnologyIdentity { + """Short grounded summary of the recognized technology identity.""" + summary: String! - """teams edge predicates""" - hasTeams: Boolean - hasTeamsWith: [TeamWhereInput!] + """Recognized technology name or `custom` / `unknown`.""" + technologyName: String! +} + +""" +Names the concrete telemetry-emitting technology when it is recognizable. +""" +input TechnologyIdentityWhereInput { + """Negated predicates.""" + not: TechnologyIdentityWhereInput - """conversations edge predicates""" - hasConversations: Boolean - hasConversationsWith: [ConversationWhereInput!] + """Predicates that must all match.""" + and: [TechnologyIdentityWhereInput!] - """log_event_policies edge predicates""" - hasLogEventPolicies: Boolean - hasLogEventPoliciesWith: [LogEventPolicyWhereInput!] + """Predicates where at least one must match.""" + or: [TechnologyIdentityWhereInput!] + + """Whether the technology_identity JSONB value is present.""" + present: Boolean + + """Short grounded summary of the recognized technology identity.""" + summary: String + + """Short grounded summary of the recognized technology identity.""" + summaryNEQ: String + + """Short grounded summary of the recognized technology identity.""" + summaryIn: [String!] + + """Short grounded summary of the recognized technology identity.""" + summaryNotIn: [String!] + + """Short grounded summary of the recognized technology identity.""" + summaryContains: String + + """Short grounded summary of the recognized technology identity.""" + summaryHasPrefix: String + + """Short grounded summary of the recognized technology identity.""" + summaryHasSuffix: String + + """Short grounded summary of the recognized technology identity.""" + summaryEqualFold: String + + """Short grounded summary of the recognized technology identity.""" + summaryContainsFold: String + + """Short grounded summary of the recognized technology identity.""" + hasSummary: Boolean + + """Recognized technology name or `custom` / `unknown`.""" + technologyName: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameNEQ: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameIn: [String!] + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameNotIn: [String!] + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameContains: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameHasPrefix: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameHasSuffix: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameEqualFold: String + + """Recognized technology name or `custom` / `unknown`.""" + technologyNameContainsFold: String + + """Recognized technology name or `custom` / `unknown`.""" + hasTechnologyName: Boolean +} + +"""Source of log-volume observations.""" +enum TelemetrySource { + DATADOG + EDGE +} + +scalar Time + +"""Bucket granularity for returned time series.""" +enum TimeGranularity { + HOUR + DAY +} + +"""Concrete half-open time range.""" +type TimeRange { + start: Time! + end: Time! +} + +"""Concrete half-open time range input.""" +input TimeRangeInput { + start: Time! + end: Time! +} + +scalar UUID + +input ValidateDatadogApiKeyInput { + apiKey: String! + site: DatadogAccountSite! +} + +type ValidateDatadogApiKeyResult { + valid: Boolean! + error: String +} + +""" +The authenticated user making the request. +Returns null for organizationId if the user is not assigned to an organization. +""" +type Viewer { + """The user's unique identifier (WorkOS user ID).""" + id: String! + + """The user's email address.""" + email: String! + + """The user's role in the current organization.""" + role: String! + + """ + The organization ID the user is authenticated for, or null if not assigned to any organization. + """ + organizationId: UUID + + """Teams the user belongs to in the current organization.""" + teams: [Team!]! } From 7e0ba356a4ce684d85343685161f2bf3b7852f86 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Mon, 8 Jun 2026 15:31:56 -0400 Subject: [PATCH 04/20] wip: reconcile GraphQL operations with refreshed schema Migrate queries/*.graphql to the current control-plane schema: - services: updateService -> setServiceEnabled (op names preserved) - accounts/organizations/datadog: input type renames; drop removed workspace field from org bootstrap result - delete conversation/message ops (chat ephemeral), policy approve/dismiss (moved to Issue model), and workspaces (concept removed) generated.go NOT yet regenerated: genqlient is blocked on GetDatadogAccountStatus because DatadogAccountStatus was restructured from flat metrics to a nested model (health/readiness/coverage/current/preview/effective). That read-model remap + its ~8 consumers is the next unit and lands together with the regen. Tree still builds (generated.go unchanged). --- docs/plans/drop-powersync.md | 42 +++++++++++++++++++ .../graphql/gen/queries/accounts.graphql | 2 +- .../graphql/gen/queries/conversations.graphql | 20 --------- .../gen/queries/datadog_accounts.graphql | 2 +- .../gen/queries/log_event_policies.graphql | 21 ---------- .../graphql/gen/queries/messages.graphql | 9 ---- .../graphql/gen/queries/organizations.graphql | 6 +-- .../graphql/gen/queries/services.graphql | 4 +- .../graphql/gen/queries/workspaces.graphql | 13 ------ 9 files changed, 47 insertions(+), 72 deletions(-) delete mode 100644 internal/boundary/graphql/gen/queries/conversations.graphql delete mode 100644 internal/boundary/graphql/gen/queries/log_event_policies.graphql delete mode 100644 internal/boundary/graphql/gen/queries/messages.graphql delete mode 100644 internal/boundary/graphql/gen/queries/workspaces.graphql diff --git a/docs/plans/drop-powersync.md b/docs/plans/drop-powersync.md index 847da15f..5efa8267 100644 --- a/docs/plans/drop-powersync.md +++ b/docs/plans/drop-powersync.md @@ -95,6 +95,16 @@ Consequence: the **current uploader is already broken** against the live control plane (pushes `createConversation`/`createMessage` mutations that no longer exist). Rebuilding the data layer is not optional cleanup. +**Workspaces removed from the control plane (found 2026-06-08).** The +`workspaces` query *and the `Workspace` type itself* are gone from the schema — +the concept was removed, not renamed. The CLI threads workspace context through +~39 non-test files: a whole onboarding workspace-selection step +(`onboarding/workspaces/*`), onboarding gates/transitions, chat +(`input_flow`, `model`), the status bar org/workspace display, and app wiring. +Removing it is a user-facing flow change, not a mechanical edit — **needs a +product decision on the post-workspace onboarding/UX** before code. Tracked +separately from the core PowerSync removal. + **Chat is ephemeral (decided 2026-06-08).** Conversation/message history is no longer persisted across sessions — in-memory during a session is enough. So: delete all chat persistence (conversation/message GraphQL ops, uploader @@ -102,6 +112,38 @@ handlers, sqlite conversations/messages tables and query surfaces); chat state lives in-memory and streams via `internal/boundary/chat`. This removes the last thing that needed a persistent local store and locks **Option A**. +## Operation-file migration status (task #1, in progress) + +Reconciling `queries/*.graphql` against the refreshed schema (control plane up +locally). Done so far: + +- `services.graphql`: `updateService(input:{enabled})` → `setServiceEnabled(id, + enabled)`. Operation names kept (`EnableService`/`DisableService`) so the + generated method signatures don't churn. +- `accounts.graphql`: `CreateAccountInput` → `AccountCreateInput`. +- `organizations.graphql`: `CreateOrganizationInput` → `OrganizationCreateInput`; + removed the now-gone `workspace { … }` from the bootstrap result. +- `datadog_accounts.graphql`: `CreateDatadogAccountWithCredentialsInput` → + `DatadogAccountCreateInput`. +- Deleted: `conversations.graphql`, `messages.graphql` (chat ephemeral), + `log_event_policies.graphql` (approve/dismiss → Issue model), + `workspaces.graphql` (workspaces removed). + +**Blocker — the read model is deeply restructured, not renamed.** +`DatadogAccountStatus` went from ~33 flat metric fields to a nested model: +`health: StatusHealth`, `readiness: StatusReadiness`, +`coverage: DatadogAccountStatusCoverage`, `current: DatadogAccountCurrentStatus`, +`preview/effective: StatusScenario`. So `GetDatadogAccountStatus` must be +re-shaped to the nested model, and its ~8 consumers (onboarding datadog +discovery, status-bar surfaces/services, chat update handlers, the sqlite status +layer, domain types) re-mapped. This is tasks #2/#4 work fused into the regen — +genqlient won't emit `generated.go` until every operation validates, so the +client regen lands together with the read-model remap, not before it. + +Net: task #1 is not a mechanical regen; it is the front edge of the full +read-model migration. Sequence the read-model remap (datadog status + the new +issues/checks/edge queries) as the unit that unblocks the regen. + ## Consumer inventory (18 non-test importers) → replacement | Area | Files | Uses PowerSync for | Replacement under Option A | diff --git a/internal/boundary/graphql/gen/queries/accounts.graphql b/internal/boundary/graphql/gen/queries/accounts.graphql index 4eabe120..3be5bd12 100644 --- a/internal/boundary/graphql/gen/queries/accounts.graphql +++ b/internal/boundary/graphql/gen/queries/accounts.graphql @@ -11,7 +11,7 @@ query ListAccounts($organizationID: ID!) { } } -mutation CreateAccount($input: CreateAccountInput!) { +mutation CreateAccount($input: AccountCreateInput!) { createAccount(input: $input) { id name diff --git a/internal/boundary/graphql/gen/queries/conversations.graphql b/internal/boundary/graphql/gen/queries/conversations.graphql deleted file mode 100644 index ec257f27..00000000 --- a/internal/boundary/graphql/gen/queries/conversations.graphql +++ /dev/null @@ -1,20 +0,0 @@ -mutation CreateConversation($input: CreateConversationInput!) { - createConversation(input: $input) { - id - title - createdAt - updatedAt - } -} - -mutation UpdateConversation($id: ID!, $input: UpdateConversationInput!) { - updateConversation(id: $id, input: $input) { - id - title - updatedAt - } -} - -mutation DeleteConversation($id: ID!) { - deleteConversation(id: $id) -} diff --git a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql index 9a732fac..578dedde 100644 --- a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql +++ b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql @@ -14,7 +14,7 @@ query GetAccount($id: ID!) { } mutation CreateDatadogAccountWithCredentials( - $input: CreateDatadogAccountWithCredentialsInput! + $input: DatadogAccountCreateInput! ) { createDatadogAccount(input: $input) { id diff --git a/internal/boundary/graphql/gen/queries/log_event_policies.graphql b/internal/boundary/graphql/gen/queries/log_event_policies.graphql deleted file mode 100644 index 942289aa..00000000 --- a/internal/boundary/graphql/gen/queries/log_event_policies.graphql +++ /dev/null @@ -1,21 +0,0 @@ -# Mutation to approve a log event policy for enforcement -mutation ApproveLogEventPolicy($id: ID!) { - approveLogEventPolicy(id: $id) { - id - approvedAt - approvedBy - dismissedAt - dismissedBy - } -} - -# Mutation to dismiss a log event policy from pending review -mutation DismissLogEventPolicy($id: ID!) { - dismissLogEventPolicy(id: $id) { - id - dismissedAt - dismissedBy - approvedAt - approvedBy - } -} diff --git a/internal/boundary/graphql/gen/queries/messages.graphql b/internal/boundary/graphql/gen/queries/messages.graphql deleted file mode 100644 index c0ad0b80..00000000 --- a/internal/boundary/graphql/gen/queries/messages.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation CreateMessage($input: CreateMessageInput!) { - createMessage(input: $input) { - id - role - model - stopReason - createdAt - } -} diff --git a/internal/boundary/graphql/gen/queries/organizations.graphql b/internal/boundary/graphql/gen/queries/organizations.graphql index 19296d56..d287632e 100644 --- a/internal/boundary/graphql/gen/queries/organizations.graphql +++ b/internal/boundary/graphql/gen/queries/organizations.graphql @@ -12,7 +12,7 @@ query ListOrganizations { } } -mutation CreateOrganizationAndBootstrap($input: CreateOrganizationInput!) { +mutation CreateOrganizationAndBootstrap($input: OrganizationCreateInput!) { createOrganizationAndBootstrap(input: $input) { organization { id @@ -24,9 +24,5 @@ mutation CreateOrganizationAndBootstrap($input: CreateOrganizationInput!) { id name } - workspace { - id - name - } } } diff --git a/internal/boundary/graphql/gen/queries/services.graphql b/internal/boundary/graphql/gen/queries/services.graphql index 86d30e1e..b9f17d41 100644 --- a/internal/boundary/graphql/gen/queries/services.graphql +++ b/internal/boundary/graphql/gen/queries/services.graphql @@ -57,7 +57,7 @@ query GetServiceByName($name: String!) { # Mutation to enable a service for analysis mutation EnableService($serviceId: ID!) { - updateService(id: $serviceId, input: { enabled: true }) { + setServiceEnabled(id: $serviceId, enabled: true) { id name enabled @@ -66,7 +66,7 @@ mutation EnableService($serviceId: ID!) { # Mutation to disable a service mutation DisableService($serviceId: ID!) { - updateService(id: $serviceId, input: { enabled: false }) { + setServiceEnabled(id: $serviceId, enabled: false) { id name enabled diff --git a/internal/boundary/graphql/gen/queries/workspaces.graphql b/internal/boundary/graphql/gen/queries/workspaces.graphql deleted file mode 100644 index ed326f52..00000000 --- a/internal/boundary/graphql/gen/queries/workspaces.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query ListWorkspaces($accountID: ID!) { - workspaces(where: { accountID: $accountID }) { - edges { - node { - id - name - purpose - createdAt - } - } - totalCount - } -} From a0641cadae73f81afc4803710b87ca152d75279c Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 10:18:19 -0700 Subject: [PATCH 05/20] refactor: migrate GraphQL operations to refreshed control-plane schema Reconcile the genqlient operations and Go consumers with the latest control-plane API: rename create inputs (Organization/Account/Datadog), remap DatadogAccountStatus to the nested readiness/coverage model, switch service enable/disable to setServiceEnabled, and drop the removed conversation/message/workspace surfaces. Tear out the upload package and its conversation/message/policy handlers now that chat is ephemeral and writes move to inline mutations. Stub policy approve/dismiss (moved to the Issue model) and synthesize a single default workspace from the account pending the workspace->account mapping. Build, vet, and tests are green. --- internal/app/app.go | 2 - internal/app/app_onboarding_controls.go | 1 - internal/app/runtime_session_test.go | 21 +- internal/app/runtime_shutdown.go | 1 - internal/app/runtime_sync.go | 34 +- internal/boundary/graphql/account_service.go | 3 +- .../boundary/graphql/account_service_test.go | 14 +- .../boundary/graphql/apitest/mock_client.go | 68 +- .../graphql/apitest/mock_conversations.go | 41 - .../boundary/graphql/apitest/mock_messages.go | 27 - internal/boundary/graphql/client.go | 91 +- .../boundary/graphql/conversation_service.go | 127 - .../graphql/datadog_account_service.go | 39 +- .../graphql/datadog_account_service_test.go | 54 +- internal/boundary/graphql/gen/generated.go | 2325 +++-------------- .../gen/queries/datadog_accounts.graphql | 45 +- .../graphql/gen/queries/services.graphql | 34 +- internal/boundary/graphql/message_service.go | 166 -- .../boundary/graphql/message_service_test.go | 285 -- .../boundary/graphql/organization_service.go | 12 +- .../graphql/organization_service_test.go | 24 +- internal/boundary/graphql/policy_service.go | 38 +- internal/boundary/graphql/services.go | 4 - internal/boundary/graphql/workspace.go | 30 +- internal/upload/conversation_handler.go | 106 - internal/upload/conversation_handler_test.go | 277 -- internal/upload/event.go | 39 - internal/upload/handler.go | 15 - internal/upload/message_handler.go | 111 - internal/upload/message_handler_test.go | 280 -- internal/upload/policy_handler.go | 46 - internal/upload/ports.go | 21 - internal/upload/service_handler.go | 77 - internal/upload/service_handler_test.go | 170 -- internal/upload/uploader.go | 278 -- internal/upload/uploader_test.go | 471 ---- 36 files changed, 468 insertions(+), 4909 deletions(-) delete mode 100644 internal/boundary/graphql/apitest/mock_conversations.go delete mode 100644 internal/boundary/graphql/apitest/mock_messages.go delete mode 100644 internal/boundary/graphql/conversation_service.go delete mode 100644 internal/boundary/graphql/message_service.go delete mode 100644 internal/boundary/graphql/message_service_test.go delete mode 100644 internal/upload/conversation_handler.go delete mode 100644 internal/upload/conversation_handler_test.go delete mode 100644 internal/upload/event.go delete mode 100644 internal/upload/handler.go delete mode 100644 internal/upload/message_handler.go delete mode 100644 internal/upload/message_handler_test.go delete mode 100644 internal/upload/policy_handler.go delete mode 100644 internal/upload/ports.go delete mode 100644 internal/upload/service_handler.go delete mode 100644 internal/upload/service_handler_test.go delete mode 100644 internal/upload/uploader.go delete mode 100644 internal/upload/uploader_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ba25e5b2..cd83fd55 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -28,7 +28,6 @@ import ( "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/update" - "github.com/usetero/cli/internal/upload" ) // state represents the app state. @@ -70,7 +69,6 @@ type Model struct { sessionCancel context.CancelFunc sessionCtx context.Context db sqlite.DB - uploader upload.Uploader chatClient chatboundary.Client runtimeDeps usecase.RuntimeDeps toolRegistry *chattools.Registry diff --git a/internal/app/app_onboarding_controls.go b/internal/app/app_onboarding_controls.go index 5b291f79..15ffabb1 100644 --- a/internal/app/app_onboarding_controls.go +++ b/internal/app/app_onboarding_controls.go @@ -77,7 +77,6 @@ func (m *Model) restartOnboarding() tea.Cmd { m.shutdown() m.db = nil - m.uploader = nil m.chatClient = nil m.runtimeDeps = usecase.RuntimeDeps{} m.toolRegistry = nil diff --git a/internal/app/runtime_session_test.go b/internal/app/runtime_session_test.go index cfdde5e9..077c71f3 100644 --- a/internal/app/runtime_session_test.go +++ b/internal/app/runtime_session_test.go @@ -14,7 +14,6 @@ import ( "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/upload" ) type testStorage struct { @@ -33,17 +32,6 @@ func (s testStorage) Clear() error { return nil } -type testUploader struct{} - -func (testUploader) Run(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() -} - -func (testUploader) Events() <-chan upload.Event { - return nil -} - func TestOpenDatabase_ClosesPreviousDatabase(t *testing.T) { ctx := context.Background() tmp := t.TempDir() @@ -90,7 +78,6 @@ func TestShutdown_CleansRuntimeResources(t *testing.T) { syncer: syncer, sessionCancel: func() { cancelled = true }, db: db, - uploader: testUploader{}, } m.shutdown() @@ -110,9 +97,6 @@ func TestShutdown_CleansRuntimeResources(t *testing.T) { if m.db != nil { t.Fatalf("expected db to be nil") } - if m.uploader != nil { - t.Fatalf("expected uploader to be nil") - } } func TestStartSync_RequiresOpenDatabase(t *testing.T) { @@ -123,7 +107,7 @@ func TestStartSync_RequiresOpenDatabase(t *testing.T) { } } -func TestStartSync_InitializesSessionAndUploader(t *testing.T) { +func TestStartSync_InitializesSession(t *testing.T) { scope := logtest.NewScope(t) db := sqlitetest.OpenBareDB(t) @@ -174,9 +158,6 @@ func TestStartSync_InitializesSessionAndUploader(t *testing.T) { if m.sessionCancel == nil { t.Fatalf("expected session cancel to be initialized") } - if m.uploader == nil { - t.Fatalf("expected uploader to be initialized") - } if scopedAccountID != domain.AccountID("acc_123") { t.Fatalf("expected services account scope to be set, got %q", scopedAccountID) } diff --git a/internal/app/runtime_shutdown.go b/internal/app/runtime_shutdown.go index 6e75a9ec..1f33c6c8 100644 --- a/internal/app/runtime_shutdown.go +++ b/internal/app/runtime_shutdown.go @@ -17,5 +17,4 @@ func (m *Model) shutdown() { } } m.db = nil - m.uploader = nil } diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index 5c1952f9..dadb1e5b 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -2,19 +2,16 @@ package app import ( "context" - "errors" "fmt" tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/app/chat/usecase" chattools "github.com/usetero/cli/internal/app/chattools" chatboundary "github.com/usetero/cli/internal/boundary/chat" - psapi "github.com/usetero/cli/internal/boundary/powersync" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/upload" ) -// startSync starts the syncer and uploader with the open database. +// startSync starts the syncer with the open database. func (m *Model) startSync(accountID string) error { if m.db == nil { return fmt.Errorf("database not open") @@ -42,35 +39,6 @@ func (m *Model) startSync(accountID string) error { // Scope API services to the active account. m.services = m.services.WithAccountID(domain.AccountID(accountID)) - // Create PowerSync API client for write checkpoints - psClient := psapi.NewClient(m.cfg.PowerSyncEndpoint) - - // Create and start uploader - syncer := m.syncer - scope := m.scope - uploader := upload.New( - m.db, - psClient, - m.authService, - upload.MutationDeps{ - Conversations: m.services.Conversations, - Messages: m.services.Messages, - Services: m.services.Services, - Policies: m.services.Policies, - }, - scope, - upload.WithBatchCompletedHook(func(ctx context.Context) error { - return syncer.NotifyUploadCompleted(ctx) - }), - ) - m.uploader = uploader - go func() { - if err := uploader.Run(sessionCtx); err != nil && !errors.Is(err, context.Canceled) { - scope.Error("uploader error", "error", err) - } - }() - scope.Info("uploader started", "account_id", accountID) - return nil } diff --git a/internal/boundary/graphql/account_service.go b/internal/boundary/graphql/account_service.go index 20b178a8..d85cd99d 100644 --- a/internal/boundary/graphql/account_service.go +++ b/internal/boundary/graphql/account_service.go @@ -87,8 +87,7 @@ func (s *AccountService) Get(ctx context.Context, accountID domain.AccountID) (* // Create creates a new account with the given client-provided ID. func (s *AccountService) Create(ctx context.Context, input CreateAccountInput) (*domain.Account, error) { s.scope.Debug("creating account via API", "id", input.ID.String(), "organizationID", input.OrganizationID, "name", input.Name) - genInput := gen.CreateAccountInput{ - Id: ptr(input.ID.String()), + genInput := gen.AccountCreateInput{ OrganizationID: input.OrganizationID.String(), Name: input.Name, } diff --git a/internal/boundary/graphql/account_service_test.go b/internal/boundary/graphql/account_service_test.go index e3c41933..e9b4e7d2 100644 --- a/internal/boundary/graphql/account_service_test.go +++ b/internal/boundary/graphql/account_service_test.go @@ -20,9 +20,9 @@ func TestAccountService_List(t *testing.T) { ListAccountsFunc: func(ctx context.Context, orgID string) (*gen.ListAccountsResponse, error) { return &gen.ListAccountsResponse{ Accounts: gen.ListAccountsAccountsAccountConnection{ - Edges: []*gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{ - {Node: &gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-1", Name: "Production"}}, - {Node: &gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-2", Name: "Staging"}}, + Edges: []gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{ + {Node: gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-1", Name: "Production"}}, + {Node: gen.ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount{Id: "acc-2", Name: "Staging"}}, }, }, }, nil @@ -52,7 +52,7 @@ func TestAccountService_List(t *testing.T) { ListAccountsFunc: func(ctx context.Context, orgID string) (*gen.ListAccountsResponse, error) { return &gen.ListAccountsResponse{ Accounts: gen.ListAccountsAccountsAccountConnection{ - Edges: []*gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{}, + Edges: []gen.ListAccountsAccountsAccountConnectionEdgesAccountEdge{}, }, }, nil }, @@ -93,9 +93,9 @@ func TestAccountService_Create(t *testing.T) { t.Parallel() t.Run("creates account and returns domain model", func(t *testing.T) { t.Parallel() - var capturedInput gen.CreateAccountInput + var capturedInput gen.AccountCreateInput mockClient := &apitest.MockClient{ - CreateAccountFunc: func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { + CreateAccountFunc: func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { capturedInput = input return &gen.CreateAccountResponse{ CreateAccount: gen.CreateAccountCreateAccount{ @@ -127,7 +127,7 @@ func TestAccountService_Create(t *testing.T) { t.Run("propagates client errors", func(t *testing.T) { t.Parallel() mockClient := &apitest.MockClient{ - CreateAccountFunc: func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { + CreateAccountFunc: func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { return nil, errors.New("validation error") }, } diff --git a/internal/boundary/graphql/apitest/mock_client.go b/internal/boundary/graphql/apitest/mock_client.go index 80f7f0bb..db8fdf4f 100644 --- a/internal/boundary/graphql/apitest/mock_client.go +++ b/internal/boundary/graphql/apitest/mock_client.go @@ -14,22 +14,15 @@ type MockClient struct { WithAccountIDFunc func(accountID domain.AccountID) graphql.Client RawQueryFunc func(ctx context.Context, query string, variables map[string]interface{}) (map[string]interface{}, error) ListOrganizationsFunc func(ctx context.Context) (*gen.ListOrganizationsResponse, error) - CreateOrganizationAndBootstrapFunc func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) + CreateOrganizationAndBootstrapFunc func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) ListAccountsFunc func(ctx context.Context, organizationID string) (*gen.ListAccountsResponse, error) - CreateAccountFunc func(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) + CreateAccountFunc func(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) GetAccountFunc func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) ValidateDatadogApiKeyFunc func(ctx context.Context, input gen.ValidateDatadogApiKeyInput) (*gen.ValidateDatadogApiKeyResponse, error) - CreateDatadogAccountWithCredentialsFunc func(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) + CreateDatadogAccountWithCredentialsFunc func(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) GetDatadogAccountStatusFunc func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) - ListWorkspacesFunc func(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) - CreateConversationFunc func(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) - UpdateConversationFunc func(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) - DeleteConversationFunc func(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) - CreateMessageFunc func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) EnableServiceFunc func(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableServiceFunc func(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) - ApproveLogEventPolicyFunc func(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicyFunc func(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -67,7 +60,7 @@ func (m *MockClient) ListOrganizations(ctx context.Context) (*gen.ListOrganizati return nil, nil } -func (m *MockClient) CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { +func (m *MockClient) CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { if m.CreateOrganizationAndBootstrapFunc != nil { return m.CreateOrganizationAndBootstrapFunc(ctx, input) } @@ -81,7 +74,7 @@ func (m *MockClient) ListAccounts(ctx context.Context, organizationID string) (* return nil, nil } -func (m *MockClient) CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { +func (m *MockClient) CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { if m.CreateAccountFunc != nil { return m.CreateAccountFunc(ctx, input) } @@ -102,7 +95,7 @@ func (m *MockClient) ValidateDatadogApiKey(ctx context.Context, input gen.Valida return nil, nil } -func (m *MockClient) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { +func (m *MockClient) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { if m.CreateDatadogAccountWithCredentialsFunc != nil { return m.CreateDatadogAccountWithCredentialsFunc(ctx, input) } @@ -116,41 +109,6 @@ func (m *MockClient) GetDatadogAccountStatus(ctx context.Context, id string) (*g return nil, nil } -func (m *MockClient) ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) { - if m.ListWorkspacesFunc != nil { - return m.ListWorkspacesFunc(ctx, accountID) - } - return nil, nil -} - -func (m *MockClient) CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) { - if m.CreateConversationFunc != nil { - return m.CreateConversationFunc(ctx, input) - } - return nil, nil -} - -func (m *MockClient) UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) { - if m.UpdateConversationFunc != nil { - return m.UpdateConversationFunc(ctx, id, input) - } - return nil, nil -} - -func (m *MockClient) DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) { - if m.DeleteConversationFunc != nil { - return m.DeleteConversationFunc(ctx, id) - } - return nil, nil -} - -func (m *MockClient) CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - if m.CreateMessageFunc != nil { - return m.CreateMessageFunc(ctx, input) - } - return nil, nil -} - func (m *MockClient) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { if m.EnableServiceFunc != nil { return m.EnableServiceFunc(ctx, serviceID) @@ -164,17 +122,3 @@ func (m *MockClient) DisableService(ctx context.Context, serviceID string) (*gen } return nil, nil } - -func (m *MockClient) ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) { - if m.ApproveLogEventPolicyFunc != nil { - return m.ApproveLogEventPolicyFunc(ctx, policyID) - } - return nil, nil -} - -func (m *MockClient) DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) { - if m.DismissLogEventPolicyFunc != nil { - return m.DismissLogEventPolicyFunc(ctx, policyID) - } - return nil, nil -} diff --git a/internal/boundary/graphql/apitest/mock_conversations.go b/internal/boundary/graphql/apitest/mock_conversations.go deleted file mode 100644 index fc080459..00000000 --- a/internal/boundary/graphql/apitest/mock_conversations.go +++ /dev/null @@ -1,41 +0,0 @@ -package apitest - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MockConversations implements graphql.Conversations for testing. -type MockConversations struct { - CreateFunc func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) - UpdateFunc func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) - DeleteFunc func(ctx context.Context, id domain.ConversationID) error -} - -// NewMockConversations creates a MockConversations with sensible defaults. -func NewMockConversations() *MockConversations { - return &MockConversations{} -} - -func (m *MockConversations) Create(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - if m.CreateFunc != nil { - return m.CreateFunc(ctx, input) - } - return nil, nil -} - -func (m *MockConversations) Update(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - if m.UpdateFunc != nil { - return m.UpdateFunc(ctx, id, input) - } - return nil, nil -} - -func (m *MockConversations) Delete(ctx context.Context, id domain.ConversationID) error { - if m.DeleteFunc != nil { - return m.DeleteFunc(ctx, id) - } - return nil -} diff --git a/internal/boundary/graphql/apitest/mock_messages.go b/internal/boundary/graphql/apitest/mock_messages.go deleted file mode 100644 index 3a6185a0..00000000 --- a/internal/boundary/graphql/apitest/mock_messages.go +++ /dev/null @@ -1,27 +0,0 @@ -package apitest - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MockMessages is a mock implementation of graphql.Messages. -type MockMessages struct { - CreateMessageFunc func(ctx context.Context, msg *domain.Message) error -} - -var _ graphql.Messages = (*MockMessages)(nil) - -// NewMockMessages creates a MockMessages with sensible defaults. -func NewMockMessages() *MockMessages { - return &MockMessages{} -} - -func (m *MockMessages) CreateMessage(ctx context.Context, msg *domain.Message) error { - if m.CreateMessageFunc != nil { - return m.CreateMessageFunc(ctx, msg) - } - return nil -} diff --git a/internal/boundary/graphql/client.go b/internal/boundary/graphql/client.go index 4c1646fc..a48287c4 100644 --- a/internal/boundary/graphql/client.go +++ b/internal/boundary/graphql/client.go @@ -33,36 +33,21 @@ type Client interface { // Organization operations ListOrganizations(ctx context.Context) (*gen.ListOrganizationsResponse, error) - CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) + CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) // Account operations ListAccounts(ctx context.Context, organizationID string) (*gen.ListAccountsResponse, error) - CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) + CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) GetAccount(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) // Datadog operations ValidateDatadogApiKey(ctx context.Context, input gen.ValidateDatadogApiKeyInput) (*gen.ValidateDatadogApiKeyResponse, error) - CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) + CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) GetDatadogAccountStatus(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) - // Workspace operations - ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) - - // Conversation operations - CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) - UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) - DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) - - // Message operations - CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) - // Service operations EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) - - // Policy operations - ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) } // client is the concrete implementation of Client. @@ -199,7 +184,7 @@ func (c *client) ListOrganizations(ctx context.Context) (*gen.ListOrganizationsR return gen.ListOrganizations(ctx, gql) } -func (c *client) CreateOrganizationAndBootstrap(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { +func (c *client) CreateOrganizationAndBootstrap(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -217,7 +202,7 @@ func (c *client) ListAccounts(ctx context.Context, organizationID string) (*gen. return gen.ListAccounts(ctx, gql, organizationID) } -func (c *client) CreateAccount(ctx context.Context, input gen.CreateAccountInput) (*gen.CreateAccountResponse, error) { +func (c *client) CreateAccount(ctx context.Context, input gen.AccountCreateInput) (*gen.CreateAccountResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -244,7 +229,7 @@ func (c *client) ValidateDatadogApiKey(ctx context.Context, input gen.ValidateDa return gen.ValidateDatadogApiKey(ctx, gql, input) } -func (c *client) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.CreateDatadogAccountWithCredentialsInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { +func (c *client) CreateDatadogAccountWithCredentials(ctx context.Context, input gen.DatadogAccountCreateInput) (*gen.CreateDatadogAccountWithCredentialsResponse, error) { gql, err := c.gql(ctx) if err != nil { return nil, err @@ -260,52 +245,6 @@ func (c *client) GetDatadogAccountStatus(ctx context.Context, id string) (*gen.G return gen.GetDatadogAccountStatus(ctx, gql, id) } -// Workspace operations - -func (c *client) ListWorkspaces(ctx context.Context, accountID string) (*gen.ListWorkspacesResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.ListWorkspaces(ctx, gql, accountID) -} - -// Conversation operations - -func (c *client) CreateConversation(ctx context.Context, input gen.CreateConversationInput) (*gen.CreateConversationResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.CreateConversation(ctx, gql, input) -} - -func (c *client) UpdateConversation(ctx context.Context, id string, input gen.UpdateConversationInput) (*gen.UpdateConversationResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.UpdateConversation(ctx, gql, id, input) -} - -func (c *client) DeleteConversation(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.DeleteConversation(ctx, gql, id) -} - -// Message operations - -func (c *client) CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.CreateMessage(ctx, gql, input) -} - // Service operations func (c *client) EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) { @@ -323,21 +262,3 @@ func (c *client) DisableService(ctx context.Context, serviceID string) (*gen.Dis } return gen.DisableService(ctx, gql, serviceID) } - -// Policy operations - -func (c *client) ApproveLogEventPolicy(ctx context.Context, policyID string) (*gen.ApproveLogEventPolicyResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.ApproveLogEventPolicy(ctx, gql, policyID) -} - -func (c *client) DismissLogEventPolicy(ctx context.Context, policyID string) (*gen.DismissLogEventPolicyResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.DismissLogEventPolicy(ctx, gql, policyID) -} diff --git a/internal/boundary/graphql/conversation_service.go b/internal/boundary/graphql/conversation_service.go deleted file mode 100644 index 678ded93..00000000 --- a/internal/boundary/graphql/conversation_service.go +++ /dev/null @@ -1,127 +0,0 @@ -package graphql - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// CreateConversationInput contains the fields for creating a conversation. -type CreateConversationInput struct { - ID uuid.UUID - WorkspaceID domain.WorkspaceID - Title string -} - -// UpdateConversationInput contains the fields that can be updated on a conversation. -// Fields are pointers — nil means "don't change", non-nil means "set to this value". -type UpdateConversationInput struct { - Title *string -} - -// Conversations provides access to conversations. -type Conversations interface { - Create(ctx context.Context, input CreateConversationInput) (*domain.Conversation, error) - Update(ctx context.Context, id domain.ConversationID, input UpdateConversationInput) (*domain.Conversation, error) - Delete(ctx context.Context, id domain.ConversationID) error -} - -// ConversationService handles conversation-related API operations. -type ConversationService struct { - client Client - scope log.Scope -} - -// Ensure ConversationService implements Conversations. -var _ Conversations = (*ConversationService)(nil) - -// NewConversationService creates a new conversation service. -func NewConversationService(client Client, scope log.Scope) *ConversationService { - return &ConversationService{ - client: client, - scope: scope.Child("conversations"), - } -} - -// Create creates a new conversation with the given client-provided ID. -func (s *ConversationService) Create(ctx context.Context, input CreateConversationInput) (*domain.Conversation, error) { - s.scope.Debug("creating conversation via API", "id", input.ID.String(), "workspaceID", input.WorkspaceID.String(), "title", input.Title) - - genInput := gen.CreateConversationInput{ - Id: ptr(input.ID.String()), - WorkspaceID: input.WorkspaceID.String(), - Title: ptr(input.Title), - } - - resp, err := s.client.CreateConversation(ctx, genInput) - if err != nil { - s.scope.Error("failed to create conversation", "error", err) - if classified := classifyError(err); classified != nil { - return nil, fmt.Errorf("create conversation %s: %w", input.ID, classified) - } - return nil, err - } - - conversation := &domain.Conversation{ - ID: domain.ConversationID(resp.CreateConversation.Id), - WorkspaceID: input.WorkspaceID, - Title: input.Title, - } - - s.scope.Debug("created conversation via API", "id", conversation.ID) - return conversation, nil -} - -// Update updates a conversation. -func (s *ConversationService) Update(ctx context.Context, id domain.ConversationID, input UpdateConversationInput) (*domain.Conversation, error) { - s.scope.Debug("updating conversation via API", "id", id.String()) - - genInput := gen.UpdateConversationInput{} - if input.Title != nil { - if *input.Title == "" { - genInput.ClearTitle = ptr(true) - } else { - genInput.Title = input.Title - } - } - - resp, err := s.client.UpdateConversation(ctx, id.String(), genInput) - if err != nil { - s.scope.Error("failed to update conversation", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return nil, fmt.Errorf("update conversation %s: %w", id, classified) - } - return nil, err - } - - conversation := &domain.Conversation{ - ID: domain.ConversationID(resp.UpdateConversation.Id), - Title: deref(resp.UpdateConversation.Title), - } - - s.scope.Debug("updated conversation via API", "id", conversation.ID) - return conversation, nil -} - -// Delete deletes a conversation. -// Returns ErrNotFound (via errors.Is) if the conversation does not exist. -func (s *ConversationService) Delete(ctx context.Context, id domain.ConversationID) error { - s.scope.Debug("deleting conversation via API", "id", id.String()) - - _, err := s.client.DeleteConversation(ctx, id.String()) - if err != nil { - s.scope.Error("failed to delete conversation", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return errors.Join(fmt.Errorf("delete conversation %s", id), classified) - } - return err - } - - s.scope.Debug("deleted conversation via API", "id", id.String()) - return nil -} diff --git a/internal/boundary/graphql/datadog_account_service.go b/internal/boundary/graphql/datadog_account_service.go index ab90c05a..e9d51f8a 100644 --- a/internal/boundary/graphql/datadog_account_service.go +++ b/internal/boundary/graphql/datadog_account_service.go @@ -167,14 +167,11 @@ func (s *DatadogAccountService) ValidateAPIKey(ctx context.Context, input Valida // The control plane validates the credentials before creating the account. func (s *DatadogAccountService) CreateAccount(ctx context.Context, input CreateDatadogAccountInput) (*DatadogAccount, error) { s.scope.Debug("creating datadog account with credentials via API", "id", input.ID.String(), "accountID", input.AccountID, "site", input.Site) - genInput := gen.CreateDatadogAccountWithCredentialsInput{ - Attributes: gen.CreateDatadogAccountInput{ - Id: ptr(input.ID.String()), - AccountID: input.AccountID.String(), - Name: input.Name, - Site: gen.DatadogAccountSite(input.Site), - }, - Credentials: gen.CreateDatadogCredentialsInput{ + genInput := gen.DatadogAccountCreateInput{ + AccountID: input.AccountID.String(), + Name: input.Name, + Site: gen.DatadogAccountSite(input.Site), + Credentials: gen.DatadogAccountCredentialsInput{ ApiKey: input.APIKey, AppKey: input.AppKey, }, @@ -217,18 +214,20 @@ func (s *DatadogAccountService) GetStatus(ctx context.Context, datadogAccountID } result := &DatadogAccountStatus{ - Health: DatadogAccountHealth(statusNode.Health), - ReadyForUse: statusNode.ReadyForUse, - ServiceCount: statusNode.LogServiceCount, - ActiveServices: statusNode.LogActiveServices, - OkServices: statusNode.OkServices, - DisabledServices: statusNode.DisabledServices, - InactiveServices: statusNode.InactiveServices, - EventCount: statusNode.LogEventCount, - AnalyzedCount: statusNode.LogEventAnalyzedCount, - PendingPolicyCount: statusNode.PolicyPendingCount, - ApprovedPolicyCount: statusNode.PolicyApprovedCount, - DismissedPolicyCount: statusNode.PolicyDismissedCount, + Health: DatadogAccountHealth(statusNode.Health), + ReadyForUse: statusNode.Readiness.ReadyForUse, + ServiceCount: statusNode.Coverage.LogServiceCount, + ActiveServices: statusNode.Coverage.LogActiveServices, + OkServices: statusNode.Coverage.OkServices, + DisabledServices: statusNode.Coverage.DisabledServices, + InactiveServices: statusNode.Coverage.InactiveServices, + EventCount: statusNode.Coverage.LogEventCount, + AnalyzedCount: statusNode.Coverage.LogEventAnalyzedCount, + // Policy counts moved to the Issue model and are no longer on the + // Datadog account status. Wired via the issues query in a later step. + PendingPolicyCount: 0, + ApprovedPolicyCount: 0, + DismissedPolicyCount: 0, } s.scope.Debug("fetched datadog account status", diff --git a/internal/boundary/graphql/datadog_account_service_test.go b/internal/boundary/graphql/datadog_account_service_test.go index f33d1f1b..1d3d1835 100644 --- a/internal/boundary/graphql/datadog_account_service_test.go +++ b/internal/boundary/graphql/datadog_account_service_test.go @@ -20,9 +20,9 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccount{ Id: "dd-123", @@ -54,9 +54,9 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", // Empty DatadogAccount - nil pointer DatadogAccount: nil, @@ -85,7 +85,7 @@ func TestDatadogAccountService_HasAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{}, + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{}, }, }, nil }, @@ -111,9 +111,9 @@ func TestDatadogAccountService_GetAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccount{ Id: "dd-123", @@ -154,9 +154,9 @@ func TestDatadogAccountService_GetAccount(t *testing.T) { GetAccountFunc: func(ctx context.Context, accountID string) (*gen.GetAccountResponse, error) { return &gen.GetAccountResponse{ Accounts: gen.GetAccountAccountsAccountConnection{ - Edges: []*gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ + Edges: []gen.GetAccountAccountsAccountConnectionEdgesAccountEdge{ { - Node: &gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ + Node: gen.GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount{ Id: "acc-1", DatadogAccount: nil, }, @@ -287,21 +287,24 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { GetDatadogAccountStatusFunc: func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) { return &gen.GetDatadogAccountStatusResponse{ DatadogAccounts: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection{ - Edges: []*gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{ + Edges: []gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{ { - Node: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount{ + Node: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount{ Id: "dd-123", - Status: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache{ - Health: gen.DatadogAccountStatusCacheHealthOk, - ReadyForUse: true, - LogServiceCount: 10, - LogActiveServices: 8, - OkServices: 7, - DisabledServices: 1, - InactiveServices: 1, - LogEventCount: 200, - LogEventAnalyzedCount: 180, - PolicyPendingCount: 12, + Status: &gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus{ + Health: gen.StatusHealthOk, + Readiness: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness{ + ReadyForUse: true, + }, + Coverage: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage{ + LogServiceCount: 10, + LogActiveServices: 8, + OkServices: 7, + DisabledServices: 1, + InactiveServices: 1, + LogEventCount: 200, + LogEventAnalyzedCount: 180, + }, }, }, }, @@ -335,8 +338,9 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { if status.AnalyzedCount != 180 { t.Errorf("AnalyzedCount = %d, want 180", status.AnalyzedCount) } - if status.PendingPolicyCount != 12 { - t.Errorf("PendingPolicyCount = %d, want 12", status.PendingPolicyCount) + // Policy counts moved to the Issue model; status no longer carries them. + if status.PendingPolicyCount != 0 { + t.Errorf("PendingPolicyCount = %d, want 0", status.PendingPolicyCount) } }) @@ -346,7 +350,7 @@ func TestDatadogAccountService_GetStatus(t *testing.T) { GetDatadogAccountStatusFunc: func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) { return &gen.GetDatadogAccountStatusResponse{ DatadogAccounts: gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection{ - Edges: []*gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{}, + Edges: []gen.GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge{}, }, }, nil }, diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index 6ea4871a..10390d8d 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -4,96 +4,29 @@ package gen import ( "context" - "encoding/json" - "fmt" "time" "github.com/Khan/genqlient/graphql" ) -// ApproveLogEventPolicyApproveLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type ApproveLogEventPolicyApproveLogEventPolicy struct { - // Unique identifier - Id string `json:"id"` - // When this policy was approved by a user - ApprovedAt *time.Time `json:"approvedAt"` - // User ID who approved this policy - ApprovedBy *string `json:"approvedBy"` - // When this policy was dismissed by a user - DismissedAt *time.Time `json:"dismissedAt"` - // User ID who dismissed this policy - DismissedBy *string `json:"dismissedBy"` -} - -// GetId returns ApproveLogEventPolicyApproveLogEventPolicy.Id, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetId() string { return v.Id } - -// GetApprovedAt returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedAt, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedAt() *time.Time { return v.ApprovedAt } - -// GetApprovedBy returns ApproveLogEventPolicyApproveLogEventPolicy.ApprovedBy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetApprovedBy() *string { return v.ApprovedBy } - -// GetDismissedAt returns ApproveLogEventPolicyApproveLogEventPolicy.DismissedAt, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetDismissedAt() *time.Time { - return v.DismissedAt -} - -// GetDismissedBy returns ApproveLogEventPolicyApproveLogEventPolicy.DismissedBy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetDismissedBy() *string { return v.DismissedBy } - -// ApproveLogEventPolicyResponse is returned by ApproveLogEventPolicy on success. -type ApproveLogEventPolicyResponse struct { - // Approve a log event policy, enabling it for enforcement. - // Clears any previous dismissal. - ApproveLogEventPolicy ApproveLogEventPolicyApproveLogEventPolicy `json:"approveLogEventPolicy"` -} - -// GetApproveLogEventPolicy returns ApproveLogEventPolicyResponse.ApproveLogEventPolicy, and is useful for accessing the field via an interface. -func (v *ApproveLogEventPolicyResponse) GetApproveLogEventPolicy() ApproveLogEventPolicyApproveLogEventPolicy { - return v.ApproveLogEventPolicy -} - -// A content block in a message. Exactly one of the typed fields should be set. -type ContentBlockInput struct { - Type ContentBlockType `json:"type"` - Text *TextBlockInput `json:"text"` - Thinking *ThinkingBlockInput `json:"thinking"` - ToolUse *ToolUseInput `json:"toolUse"` - ToolResult *ToolResultInput `json:"toolResult"` +// Account creation input. +type AccountCreateInput struct { + // Parent organization this account belongs to + OrganizationID string `json:"organizationID"` + // Human-readable name within the organization + Name string `json:"name"` + // Short account key used in display IDs. Auto-set from name when omitted. + DisplayKey *string `json:"displayKey"` } -// GetType returns ContentBlockInput.Type, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetType() ContentBlockType { return v.Type } - -// GetText returns ContentBlockInput.Text, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetText() *TextBlockInput { return v.Text } +// GetOrganizationID returns AccountCreateInput.OrganizationID, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetOrganizationID() string { return v.OrganizationID } -// GetThinking returns ContentBlockInput.Thinking, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetThinking() *ThinkingBlockInput { return v.Thinking } +// GetName returns AccountCreateInput.Name, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetName() string { return v.Name } -// GetToolUse returns ContentBlockInput.ToolUse, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetToolUse() *ToolUseInput { return v.ToolUse } - -// GetToolResult returns ContentBlockInput.ToolResult, and is useful for accessing the field via an interface. -func (v *ContentBlockInput) GetToolResult() *ToolResultInput { return v.ToolResult } - -// The type of content block. -type ContentBlockType string - -const ( - ContentBlockTypeText ContentBlockType = "text" - ContentBlockTypeThinking ContentBlockType = "thinking" - ContentBlockTypeToolUse ContentBlockType = "tool_use" - ContentBlockTypeToolResult ContentBlockType = "tool_result" -) - -var AllContentBlockType = []ContentBlockType{ - ContentBlockTypeText, - ContentBlockTypeThinking, - ContentBlockTypeToolUse, - ContentBlockTypeToolResult, -} +// GetDisplayKey returns AccountCreateInput.DisplayKey, and is useful for accessing the field via an interface. +func (v *AccountCreateInput) GetDisplayKey() *string { return v.DisplayKey } // CreateAccountCreateAccount includes the requested fields of the GraphQL type Account. type CreateAccountCreateAccount struct { @@ -114,146 +47,24 @@ func (v *CreateAccountCreateAccount) GetName() string { return v.Name } // GetCreatedAt returns CreateAccountCreateAccount.CreatedAt, and is useful for accessing the field via an interface. func (v *CreateAccountCreateAccount) GetCreatedAt() time.Time { return v.CreatedAt } -// CreateAccountInput is used for create Account object. -// Input was generated by ent. -type CreateAccountInput struct { - // Human-readable name within the organization - Name string `json:"name"` - OrganizationID string `json:"organizationID"` - DatadogAccountID *string `json:"datadogAccountID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetName returns CreateAccountInput.Name, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetName() string { return v.Name } - -// GetOrganizationID returns CreateAccountInput.OrganizationID, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetOrganizationID() string { return v.OrganizationID } - -// GetDatadogAccountID returns CreateAccountInput.DatadogAccountID, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetDatadogAccountID() *string { return v.DatadogAccountID } - -// GetId returns CreateAccountInput.Id, and is useful for accessing the field via an interface. -func (v *CreateAccountInput) GetId() *string { return v.Id } - // CreateAccountResponse is returned by CreateAccount on success. type CreateAccountResponse struct { + // Create account. CreateAccount CreateAccountCreateAccount `json:"createAccount"` } // GetCreateAccount returns CreateAccountResponse.CreateAccount, and is useful for accessing the field via an interface. func (v *CreateAccountResponse) GetCreateAccount() CreateAccountCreateAccount { return v.CreateAccount } -// CreateConversationCreateConversation includes the requested fields of the GraphQL type Conversation. -type CreateConversationCreateConversation struct { - // Unique identifier - Id string `json:"id"` - // AI-generated title, set after first exchange - Title *string `json:"title"` - // When the conversation was created - CreatedAt time.Time `json:"createdAt"` - // When the conversation was last updated - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetId returns CreateConversationCreateConversation.Id, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetId() string { return v.Id } - -// GetTitle returns CreateConversationCreateConversation.Title, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetTitle() *string { return v.Title } - -// GetCreatedAt returns CreateConversationCreateConversation.CreatedAt, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetCreatedAt() time.Time { return v.CreatedAt } - -// GetUpdatedAt returns CreateConversationCreateConversation.UpdatedAt, and is useful for accessing the field via an interface. -func (v *CreateConversationCreateConversation) GetUpdatedAt() time.Time { return v.UpdatedAt } - -// CreateConversationInput is used for create Conversation object. -// Input was generated by ent. -type CreateConversationInput struct { - // AI-generated title, set after first exchange - Title *string `json:"title"` - WorkspaceID string `json:"workspaceID"` - ViewID *string `json:"viewID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a conversation with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetTitle returns CreateConversationInput.Title, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetTitle() *string { return v.Title } - -// GetWorkspaceID returns CreateConversationInput.WorkspaceID, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetWorkspaceID() string { return v.WorkspaceID } - -// GetViewID returns CreateConversationInput.ViewID, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetViewID() *string { return v.ViewID } - -// GetId returns CreateConversationInput.Id, and is useful for accessing the field via an interface. -func (v *CreateConversationInput) GetId() *string { return v.Id } - -// CreateConversationResponse is returned by CreateConversation on success. -type CreateConversationResponse struct { - // Create a new conversation in a workspace. - // The conversation is owned by the authenticated user. - CreateConversation CreateConversationCreateConversation `json:"createConversation"` -} - -// GetCreateConversation returns CreateConversationResponse.CreateConversation, and is useful for accessing the field via an interface. -func (v *CreateConversationResponse) GetCreateConversation() CreateConversationCreateConversation { - return v.CreateConversation -} - -// CreateDatadogAccountInput is used for create DatadogAccount object. -// Input was generated by ent. -type CreateDatadogAccountInput struct { - // Display name for this Datadog account - Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. - Site DatadogAccountSite `json:"site"` - // Cost per GB of log data ingested (USD). NULL = using Datadog's published rate - // ($0.10/GB). Set to override with actual contract rate. - CostPerGBIngested *float64 `json:"costPerGBIngested"` - // Fraction of the API rate limit to consume (0.0-1.0). NULL = default (0.8 = - // 80%). Leaves headroom for the customer's own API usage. - RateLimitUtilization *float64 `json:"rateLimitUtilization"` - AccountID string `json:"accountID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` -} - -// GetName returns CreateDatadogAccountInput.Name, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetName() string { return v.Name } - -// GetSite returns CreateDatadogAccountInput.Site, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetSite() DatadogAccountSite { return v.Site } - -// GetCostPerGBIngested returns CreateDatadogAccountInput.CostPerGBIngested, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetCostPerGBIngested() *float64 { return v.CostPerGBIngested } - -// GetRateLimitUtilization returns CreateDatadogAccountInput.RateLimitUtilization, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetRateLimitUtilization() *float64 { return v.RateLimitUtilization } - -// GetAccountID returns CreateDatadogAccountInput.AccountID, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetAccountID() string { return v.AccountID } - -// GetId returns CreateDatadogAccountInput.Id, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountInput) GetId() *string { return v.Id } - // CreateDatadogAccountWithCredentialsCreateDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. type CreateDatadogAccountWithCredentialsCreateDatadogAccount struct { // Unique identifier of the Datadog configuration Id string `json:"id"` // Display name for this Datadog account Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. Site DatadogAccountSite `json:"site"` // When the Datadog account was created CreatedAt time.Time `json:"createdAt"` @@ -282,23 +93,9 @@ func (v *CreateDatadogAccountWithCredentialsCreateDatadogAccount) GetUpdatedAt() return v.UpdatedAt } -type CreateDatadogAccountWithCredentialsInput struct { - Attributes CreateDatadogAccountInput `json:"attributes"` - Credentials CreateDatadogCredentialsInput `json:"credentials"` -} - -// GetAttributes returns CreateDatadogAccountWithCredentialsInput.Attributes, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountWithCredentialsInput) GetAttributes() CreateDatadogAccountInput { - return v.Attributes -} - -// GetCredentials returns CreateDatadogAccountWithCredentialsInput.Credentials, and is useful for accessing the field via an interface. -func (v *CreateDatadogAccountWithCredentialsInput) GetCredentials() CreateDatadogCredentialsInput { - return v.Credentials -} - // CreateDatadogAccountWithCredentialsResponse is returned by CreateDatadogAccountWithCredentials on success. type CreateDatadogAccountWithCredentialsResponse struct { + // Create datadogaccount. CreateDatadogAccount CreateDatadogAccountWithCredentialsCreateDatadogAccount `json:"createDatadogAccount"` } @@ -307,97 +104,10 @@ func (v *CreateDatadogAccountWithCredentialsResponse) GetCreateDatadogAccount() return v.CreateDatadogAccount } -type CreateDatadogCredentialsInput struct { - ApiKey string `json:"apiKey"` - AppKey string `json:"appKey"` -} - -// GetApiKey returns CreateDatadogCredentialsInput.ApiKey, and is useful for accessing the field via an interface. -func (v *CreateDatadogCredentialsInput) GetApiKey() string { return v.ApiKey } - -// GetAppKey returns CreateDatadogCredentialsInput.AppKey, and is useful for accessing the field via an interface. -func (v *CreateDatadogCredentialsInput) GetAppKey() string { return v.AppKey } - -// CreateMessageCreateMessage includes the requested fields of the GraphQL type Message. -type CreateMessageCreateMessage struct { - // Unique identifier - Id string `json:"id"` - // Who sent this message. user: human-originated, assistant: AI-originated. - Role MessageRole `json:"role"` - // AI model that produced this message. Null for user messages. - Model *string `json:"model"` - // Why the assistant stopped generating. end_turn: completed response, tool_use: - // paused to call a tool. Null for user messages. - StopReason *MessageStopReason `json:"stopReason"` - // When the message was created - CreatedAt time.Time `json:"createdAt"` -} - -// GetId returns CreateMessageCreateMessage.Id, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetId() string { return v.Id } - -// GetRole returns CreateMessageCreateMessage.Role, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetRole() MessageRole { return v.Role } - -// GetModel returns CreateMessageCreateMessage.Model, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetModel() *string { return v.Model } - -// GetStopReason returns CreateMessageCreateMessage.StopReason, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetStopReason() *MessageStopReason { return v.StopReason } - -// GetCreatedAt returns CreateMessageCreateMessage.CreatedAt, and is useful for accessing the field via an interface. -func (v *CreateMessageCreateMessage) GetCreatedAt() time.Time { return v.CreatedAt } - -// CreateMessageInput is used for create Message object. -// Input was generated by ent. -type CreateMessageInput struct { - // Who sent this message. user: human-originated, assistant: AI-originated. - Role MessageRole `json:"role"` - // Why the assistant stopped generating. end_turn: completed response, tool_use: - // paused to call a tool. Null for user messages. - StopReason *MessageStopReason `json:"stopReason"` - // AI model that produced this message. Null for user messages. - Model *string `json:"model"` - ConversationID string `json:"conversationID"` - // Optional client-provided UUID for offline-first sync. - // If provided and a message with this ID exists, returns the existing record. - Id *string `json:"id"` - // Array of typed content blocks. - Content []ContentBlockInput `json:"content"` -} - -// GetRole returns CreateMessageInput.Role, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetRole() MessageRole { return v.Role } - -// GetStopReason returns CreateMessageInput.StopReason, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetStopReason() *MessageStopReason { return v.StopReason } - -// GetModel returns CreateMessageInput.Model, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetModel() *string { return v.Model } - -// GetConversationID returns CreateMessageInput.ConversationID, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetConversationID() string { return v.ConversationID } - -// GetId returns CreateMessageInput.Id, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetId() *string { return v.Id } - -// GetContent returns CreateMessageInput.Content, and is useful for accessing the field via an interface. -func (v *CreateMessageInput) GetContent() []ContentBlockInput { return v.Content } - -// CreateMessageResponse is returned by CreateMessage on success. -type CreateMessageResponse struct { - // Create a new message in a conversation. - CreateMessage CreateMessageCreateMessage `json:"createMessage"` -} - -// GetCreateMessage returns CreateMessageResponse.CreateMessage, and is useful for accessing the field via an interface. -func (v *CreateMessageResponse) GetCreateMessage() CreateMessageCreateMessage { return v.CreateMessage } - // CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult includes the requested fields of the GraphQL type OrganizationBootstrapResult. type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult struct { Organization CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultOrganization `json:"organization"` Account CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount `json:"account"` - Workspace CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace `json:"workspace"` } // GetOrganization returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult.Organization, and is useful for accessing the field via an interface. @@ -410,11 +120,6 @@ func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizatio return v.Account } -// GetWorkspace returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult.Workspace, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult) GetWorkspace() CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace { - return v.Workspace -} - // CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount includes the requested fields of the GraphQL type Account. type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultAccount struct { // Unique identifier of the account @@ -465,26 +170,9 @@ func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizatio return v.WorkosOrganizationID } -// CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace includes the requested fields of the GraphQL type Workspace. -type CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace struct { - // Unique identifier of the workspace - Id string `json:"id"` - // Human-readable name within the account - Name string `json:"name"` -} - -// GetId returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace.Id, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace) GetId() string { - return v.Id -} - -// GetName returns CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace.Name, and is useful for accessing the field via an interface. -func (v *CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace) GetName() string { - return v.Name -} - // CreateOrganizationAndBootstrapResponse is returned by CreateOrganizationAndBootstrap on success. type CreateOrganizationAndBootstrapResponse struct { + // Create a new organization and its default account using the additive runtime. CreateOrganizationAndBootstrap CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult `json:"createOrganizationAndBootstrap"` } @@ -493,23 +181,46 @@ func (v *CreateOrganizationAndBootstrapResponse) GetCreateOrganizationAndBootstr return v.CreateOrganizationAndBootstrap } -// CreateOrganizationInput is used for create Organization object. -// Input was generated by ent. -type CreateOrganizationInput struct { - // Human-readable name, unique across the system +// DatadogAccount creation input. +type DatadogAccountCreateInput struct { + // Parent account this configuration belongs to + AccountID string `json:"accountID"` + // Display name for this Datadog account Name string `json:"name"` - // Optional client-provided UUID for offline-first sync. - // If provided and a record with this ID exists, returns the existing record. - Id *string `json:"id"` + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. + Site DatadogAccountSite `json:"site"` + // Datadog API credentials. Stored in the secret store, not Postgres. + Credentials DatadogAccountCredentialsInput `json:"credentials"` +} + +// GetAccountID returns DatadogAccountCreateInput.AccountID, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetAccountID() string { return v.AccountID } + +// GetName returns DatadogAccountCreateInput.Name, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetName() string { return v.Name } + +// GetSite returns DatadogAccountCreateInput.Site, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetSite() DatadogAccountSite { return v.Site } + +// GetCredentials returns DatadogAccountCreateInput.Credentials, and is useful for accessing the field via an interface. +func (v *DatadogAccountCreateInput) GetCredentials() DatadogAccountCredentialsInput { + return v.Credentials } -// GetName returns CreateOrganizationInput.Name, and is useful for accessing the field via an interface. -func (v *CreateOrganizationInput) GetName() string { return v.Name } +// Datadog credentials for account creation. +type DatadogAccountCredentialsInput struct { + ApiKey string `json:"apiKey"` + AppKey string `json:"appKey"` +} -// GetId returns CreateOrganizationInput.Id, and is useful for accessing the field via an interface. -func (v *CreateOrganizationInput) GetId() *string { return v.Id } +// GetApiKey returns DatadogAccountCredentialsInput.ApiKey, and is useful for accessing the field via an interface. +func (v *DatadogAccountCredentialsInput) GetApiKey() string { return v.ApiKey } + +// GetAppKey returns DatadogAccountCredentialsInput.AppKey, and is useful for accessing the field via an interface. +func (v *DatadogAccountCredentialsInput) GetAppKey() string { return v.AppKey } -// DatadogAccountSite is enum for the field site type DatadogAccountSite string const ( @@ -532,44 +243,19 @@ var AllDatadogAccountSite = []DatadogAccountSite{ DatadogAccountSiteAp2, } -// DatadogAccountStatusCacheHealth is enum for the field health -type DatadogAccountStatusCacheHealth string - -const ( - DatadogAccountStatusCacheHealthDisabled DatadogAccountStatusCacheHealth = "DISABLED" - DatadogAccountStatusCacheHealthInactive DatadogAccountStatusCacheHealth = "INACTIVE" - DatadogAccountStatusCacheHealthError DatadogAccountStatusCacheHealth = "ERROR" - DatadogAccountStatusCacheHealthOk DatadogAccountStatusCacheHealth = "OK" -) - -var AllDatadogAccountStatusCacheHealth = []DatadogAccountStatusCacheHealth{ - DatadogAccountStatusCacheHealthDisabled, - DatadogAccountStatusCacheHealthInactive, - DatadogAccountStatusCacheHealthError, - DatadogAccountStatusCacheHealthOk, -} - -// DeleteConversationResponse is returned by DeleteConversation on success. -type DeleteConversationResponse struct { - // Delete a conversation and all its messages. - DeleteConversation bool `json:"deleteConversation"` -} - -// GetDeleteConversation returns DeleteConversationResponse.DeleteConversation, and is useful for accessing the field via an interface. -func (v *DeleteConversationResponse) GetDeleteConversation() bool { return v.DeleteConversation } - // DisableServiceResponse is returned by DisableService on success. type DisableServiceResponse struct { - UpdateService DisableServiceUpdateService `json:"updateService"` + // Set service enabled state. + SetServiceEnabled DisableServiceSetServiceEnabledService `json:"setServiceEnabled"` } -// GetUpdateService returns DisableServiceResponse.UpdateService, and is useful for accessing the field via an interface. -func (v *DisableServiceResponse) GetUpdateService() DisableServiceUpdateService { - return v.UpdateService +// GetSetServiceEnabled returns DisableServiceResponse.SetServiceEnabled, and is useful for accessing the field via an interface. +func (v *DisableServiceResponse) GetSetServiceEnabled() DisableServiceSetServiceEnabledService { + return v.SetServiceEnabled } -// DisableServiceUpdateService includes the requested fields of the GraphQL type Service. -type DisableServiceUpdateService struct { +// DisableServiceSetServiceEnabledService includes the requested fields of the GraphQL type Service. +type DisableServiceSetServiceEnabledService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') @@ -578,68 +264,28 @@ type DisableServiceUpdateService struct { Enabled bool `json:"enabled"` } -// GetId returns DisableServiceUpdateService.Id, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetId() string { return v.Id } - -// GetName returns DisableServiceUpdateService.Name, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetName() string { return v.Name } +// GetId returns DisableServiceSetServiceEnabledService.Id, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetId() string { return v.Id } -// GetEnabled returns DisableServiceUpdateService.Enabled, and is useful for accessing the field via an interface. -func (v *DisableServiceUpdateService) GetEnabled() bool { return v.Enabled } +// GetName returns DisableServiceSetServiceEnabledService.Name, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetName() string { return v.Name } -// DismissLogEventPolicyDismissLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type DismissLogEventPolicyDismissLogEventPolicy struct { - // Unique identifier - Id string `json:"id"` - // When this policy was dismissed by a user - DismissedAt *time.Time `json:"dismissedAt"` - // User ID who dismissed this policy - DismissedBy *string `json:"dismissedBy"` - // When this policy was approved by a user - ApprovedAt *time.Time `json:"approvedAt"` - // User ID who approved this policy - ApprovedBy *string `json:"approvedBy"` -} - -// GetId returns DismissLogEventPolicyDismissLogEventPolicy.Id, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetId() string { return v.Id } - -// GetDismissedAt returns DismissLogEventPolicyDismissLogEventPolicy.DismissedAt, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedAt() *time.Time { - return v.DismissedAt -} - -// GetDismissedBy returns DismissLogEventPolicyDismissLogEventPolicy.DismissedBy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetDismissedBy() *string { return v.DismissedBy } - -// GetApprovedAt returns DismissLogEventPolicyDismissLogEventPolicy.ApprovedAt, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetApprovedAt() *time.Time { return v.ApprovedAt } - -// GetApprovedBy returns DismissLogEventPolicyDismissLogEventPolicy.ApprovedBy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyDismissLogEventPolicy) GetApprovedBy() *string { return v.ApprovedBy } - -// DismissLogEventPolicyResponse is returned by DismissLogEventPolicy on success. -type DismissLogEventPolicyResponse struct { - // Dismiss a log event policy, hiding it from pending review. - // Clears any previous approval. - DismissLogEventPolicy DismissLogEventPolicyDismissLogEventPolicy `json:"dismissLogEventPolicy"` -} - -// GetDismissLogEventPolicy returns DismissLogEventPolicyResponse.DismissLogEventPolicy, and is useful for accessing the field via an interface. -func (v *DismissLogEventPolicyResponse) GetDismissLogEventPolicy() DismissLogEventPolicyDismissLogEventPolicy { - return v.DismissLogEventPolicy -} +// GetEnabled returns DisableServiceSetServiceEnabledService.Enabled, and is useful for accessing the field via an interface. +func (v *DisableServiceSetServiceEnabledService) GetEnabled() bool { return v.Enabled } // EnableServiceResponse is returned by EnableService on success. type EnableServiceResponse struct { - UpdateService EnableServiceUpdateService `json:"updateService"` + // Set service enabled state. + SetServiceEnabled EnableServiceSetServiceEnabledService `json:"setServiceEnabled"` } -// GetUpdateService returns EnableServiceResponse.UpdateService, and is useful for accessing the field via an interface. -func (v *EnableServiceResponse) GetUpdateService() EnableServiceUpdateService { return v.UpdateService } +// GetSetServiceEnabled returns EnableServiceResponse.SetServiceEnabled, and is useful for accessing the field via an interface. +func (v *EnableServiceResponse) GetSetServiceEnabled() EnableServiceSetServiceEnabledService { + return v.SetServiceEnabled +} -// EnableServiceUpdateService includes the requested fields of the GraphQL type Service. -type EnableServiceUpdateService struct { +// EnableServiceSetServiceEnabledService includes the requested fields of the GraphQL type Service. +type EnableServiceSetServiceEnabledService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') @@ -648,40 +294,32 @@ type EnableServiceUpdateService struct { Enabled bool `json:"enabled"` } -// GetId returns EnableServiceUpdateService.Id, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetId() string { return v.Id } +// GetId returns EnableServiceSetServiceEnabledService.Id, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetId() string { return v.Id } -// GetName returns EnableServiceUpdateService.Name, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetName() string { return v.Name } +// GetName returns EnableServiceSetServiceEnabledService.Name, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetName() string { return v.Name } -// GetEnabled returns EnableServiceUpdateService.Enabled, and is useful for accessing the field via an interface. -func (v *EnableServiceUpdateService) GetEnabled() bool { return v.Enabled } +// GetEnabled returns EnableServiceSetServiceEnabledService.Enabled, and is useful for accessing the field via an interface. +func (v *EnableServiceSetServiceEnabledService) GetEnabled() bool { return v.Enabled } // GetAccountAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type GetAccountAccountsAccountConnection struct { - // A list of edges. - Edges []*GetAccountAccountsAccountConnectionEdgesAccountEdge `json:"edges"` + Edges []GetAccountAccountsAccountConnectionEdgesAccountEdge `json:"edges"` } // GetEdges returns GetAccountAccountsAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetAccountAccountsAccountConnection) GetEdges() []*GetAccountAccountsAccountConnectionEdgesAccountEdge { +func (v *GetAccountAccountsAccountConnection) GetEdges() []GetAccountAccountsAccountConnectionEdgesAccountEdge { return v.Edges } // GetAccountAccountsAccountConnectionEdgesAccountEdge includes the requested fields of the GraphQL type AccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type GetAccountAccountsAccountConnectionEdgesAccountEdge struct { - // The item at the end of the edge. - Node *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` + Node GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` } // GetNode returns GetAccountAccountsAccountConnectionEdgesAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *GetAccountAccountsAccountConnectionEdgesAccountEdge) GetNode() *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount { +func (v *GetAccountAccountsAccountConnectionEdgesAccountEdge) GetNode() GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccount { return v.Node } @@ -707,9 +345,9 @@ type GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAccoun Id string `json:"id"` // Display name for this Datadog account Name string `json:"name"` - // Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: - // us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: - // ap1.datadoghq.com, AP2: ap2.datadoghq.com. + // Datadog regional site. Values: US1 = datadoghq.com.; US3 = us3.datadoghq.com.; + // US5 = us5.datadoghq.com.; EU1 = datadoghq.eu.; US1_FED = ddog-gov.com.; AP1 = + // ap1.datadoghq.com.; AP2 = ap2.datadoghq.com. Site DatadogAccountSite `json:"site"` } @@ -730,7 +368,7 @@ func (v *GetAccountAccountsAccountConnectionEdgesAccountEdgeNodeAccountDatadogAc // GetAccountResponse is returned by GetAccount on success. type GetAccountResponse struct { - // Query accounts. Accounts belong to an organization and contain services and workspaces. + // Query Accounts records in your account. Accounts GetAccountAccountsAccountConnection `json:"accounts"` } @@ -738,30 +376,22 @@ type GetAccountResponse struct { func (v *GetAccountResponse) GetAccounts() GetAccountAccountsAccountConnection { return v.Accounts } // GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection struct { - // A list of edges. - Edges []*GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` + Edges []GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` } // GetEdges returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection) GetEdges() []*GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection) GetEdges() []GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { return v.Edges } // GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge includes the requested fields of the GraphQL type DatadogAccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge struct { - // The item at the end of the edge. - Node *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` + Node GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` } // GetNode returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { return v.Node } @@ -769,10 +399,10 @@ func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesData type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount struct { // Unique identifier of the Datadog configuration Id string `json:"id"` - // Status of this Datadog account in the discovery pipeline. + // Status of this Datadog account in the catalog pipeline. // Derived from the status of all services discovered from this account. - // Returns null if cache has not been populated yet. - Status *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache `json:"status"` + // Returns null if the status view has no row yet. + Status *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus `json:"status"` } // GetId returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Id, and is useful for accessing the field via an interface. @@ -781,222 +411,91 @@ func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesData } // GetStatus returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Status, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache { +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus { return v.Status } -// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache includes the requested fields of the GraphQL type DatadogAccountStatusCache. -type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache struct { - // Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - Health DatadogAccountStatusCacheHealth `json:"health"` - ReadyForUse bool `json:"readyForUse"` - LogEventCount int `json:"logEventCount"` - LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` - LogServiceCount int `json:"logServiceCount"` - LogActiveServices int `json:"logActiveServices"` - DisabledServices int `json:"disabledServices"` - InactiveServices int `json:"inactiveServices"` - OkServices int `json:"okServices"` - PolicyPendingCount int `json:"policyPendingCount"` - PolicyApprovedCount int `json:"policyApprovedCount"` - PolicyDismissedCount int `json:"policyDismissedCount"` - ServiceVolumePerHour *float64 `json:"serviceVolumePerHour"` - ServiceCostPerHourVolumeUsd *float64 `json:"serviceCostPerHourVolumeUsd"` - LogEventVolumePerHour *float64 `json:"logEventVolumePerHour"` - LogEventBytesPerHour *float64 `json:"logEventBytesPerHour"` - LogEventCostPerHourBytesUsd *float64 `json:"logEventCostPerHourBytesUsd"` - LogEventCostPerHourVolumeUsd *float64 `json:"logEventCostPerHourVolumeUsd"` - LogEventCostPerHourUsd *float64 `json:"logEventCostPerHourUsd"` - EstimatedVolumeReductionPerHour *float64 `json:"estimatedVolumeReductionPerHour"` - EstimatedBytesReductionPerHour *float64 `json:"estimatedBytesReductionPerHour"` - EstimatedCostReductionPerHourBytesUsd *float64 `json:"estimatedCostReductionPerHourBytesUsd"` - EstimatedCostReductionPerHourVolumeUsd *float64 `json:"estimatedCostReductionPerHourVolumeUsd"` - EstimatedCostReductionPerHourUsd *float64 `json:"estimatedCostReductionPerHourUsd"` - ObservedVolumePerHourBefore *float64 `json:"observedVolumePerHourBefore"` - ObservedVolumePerHourAfter *float64 `json:"observedVolumePerHourAfter"` - ObservedBytesPerHourBefore *float64 `json:"observedBytesPerHourBefore"` - ObservedBytesPerHourAfter *float64 `json:"observedBytesPerHourAfter"` - ObservedCostPerHourBeforeBytesUsd *float64 `json:"observedCostPerHourBeforeBytesUsd"` - ObservedCostPerHourBeforeVolumeUsd *float64 `json:"observedCostPerHourBeforeVolumeUsd"` - ObservedCostPerHourBeforeUsd *float64 `json:"observedCostPerHourBeforeUsd"` - ObservedCostPerHourAfterBytesUsd *float64 `json:"observedCostPerHourAfterBytesUsd"` - ObservedCostPerHourAfterVolumeUsd *float64 `json:"observedCostPerHourAfterVolumeUsd"` - ObservedCostPerHourAfterUsd *float64 `json:"observedCostPerHourAfterUsd"` -} - -// GetHealth returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.Health, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetHealth() DatadogAccountStatusCacheHealth { +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus includes the requested fields of the GraphQL type DatadogAccountStatus. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus struct { + Health StatusHealth `json:"health"` + Readiness GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness `json:"readiness"` + Coverage GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage `json:"coverage"` +} + +// GetHealth returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Health, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetHealth() StatusHealth { return v.Health } -// GetReadyForUse returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ReadyForUse, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetReadyForUse() bool { - return v.ReadyForUse +// GetReadiness returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Readiness, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetReadiness() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness { + return v.Readiness +} + +// GetCoverage returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Coverage, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCoverage() GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage { + return v.Coverage +} + +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage includes the requested fields of the GraphQL type DatadogAccountStatusCoverage. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage struct { + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + LogServiceCount int `json:"logServiceCount"` + LogActiveServices int `json:"logActiveServices"` + DisabledServices int `json:"disabledServices"` + InactiveServices int `json:"inactiveServices"` + OkServices int `json:"okServices"` } -// GetLogEventCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCount() int { +// GetLogEventCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventCount() int { return v.LogEventCount } -// GetLogEventAnalyzedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventAnalyzedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventAnalyzedCount() int { +// GetLogEventAnalyzedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventAnalyzedCount() int { return v.LogEventAnalyzedCount } -// GetLogServiceCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogServiceCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogServiceCount() int { +// GetLogServiceCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogServiceCount, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogServiceCount() int { return v.LogServiceCount } -// GetLogActiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogActiveServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogActiveServices() int { +// GetLogActiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogActiveServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogActiveServices() int { return v.LogActiveServices } -// GetDisabledServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.DisabledServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetDisabledServices() int { +// GetDisabledServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.DisabledServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetDisabledServices() int { return v.DisabledServices } -// GetInactiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.InactiveServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetInactiveServices() int { +// GetInactiveServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.InactiveServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetInactiveServices() int { return v.InactiveServices } -// GetOkServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.OkServices, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetOkServices() int { +// GetOkServices returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.OkServices, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetOkServices() int { return v.OkServices } -// GetPolicyPendingCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyPendingCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyPendingCount() int { - return v.PolicyPendingCount -} - -// GetPolicyApprovedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyApprovedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyApprovedCount() int { - return v.PolicyApprovedCount -} - -// GetPolicyDismissedCount returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.PolicyDismissedCount, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetPolicyDismissedCount() int { - return v.PolicyDismissedCount -} - -// GetServiceVolumePerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ServiceVolumePerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetServiceVolumePerHour() *float64 { - return v.ServiceVolumePerHour -} - -// GetServiceCostPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ServiceCostPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetServiceCostPerHourVolumeUsd() *float64 { - return v.ServiceCostPerHourVolumeUsd -} - -// GetLogEventVolumePerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventVolumePerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventVolumePerHour() *float64 { - return v.LogEventVolumePerHour -} - -// GetLogEventBytesPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventBytesPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventBytesPerHour() *float64 { - return v.LogEventBytesPerHour -} - -// GetLogEventCostPerHourBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourBytesUsd() *float64 { - return v.LogEventCostPerHourBytesUsd -} - -// GetLogEventCostPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourVolumeUsd() *float64 { - return v.LogEventCostPerHourVolumeUsd +// GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness includes the requested fields of the GraphQL type StatusReadiness. +type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness struct { + ReadyForUse bool `json:"readyForUse"` } -// GetLogEventCostPerHourUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.LogEventCostPerHourUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetLogEventCostPerHourUsd() *float64 { - return v.LogEventCostPerHourUsd -} - -// GetEstimatedVolumeReductionPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedVolumeReductionPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedVolumeReductionPerHour() *float64 { - return v.EstimatedVolumeReductionPerHour -} - -// GetEstimatedBytesReductionPerHour returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedBytesReductionPerHour, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedBytesReductionPerHour() *float64 { - return v.EstimatedBytesReductionPerHour -} - -// GetEstimatedCostReductionPerHourBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourBytesUsd() *float64 { - return v.EstimatedCostReductionPerHourBytesUsd -} - -// GetEstimatedCostReductionPerHourVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourVolumeUsd() *float64 { - return v.EstimatedCostReductionPerHourVolumeUsd -} - -// GetEstimatedCostReductionPerHourUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.EstimatedCostReductionPerHourUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetEstimatedCostReductionPerHourUsd() *float64 { - return v.EstimatedCostReductionPerHourUsd -} - -// GetObservedVolumePerHourBefore returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedVolumePerHourBefore, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedVolumePerHourBefore() *float64 { - return v.ObservedVolumePerHourBefore -} - -// GetObservedVolumePerHourAfter returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedVolumePerHourAfter, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedVolumePerHourAfter() *float64 { - return v.ObservedVolumePerHourAfter -} - -// GetObservedBytesPerHourBefore returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedBytesPerHourBefore, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedBytesPerHourBefore() *float64 { - return v.ObservedBytesPerHourBefore -} - -// GetObservedBytesPerHourAfter returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedBytesPerHourAfter, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedBytesPerHourAfter() *float64 { - return v.ObservedBytesPerHourAfter -} - -// GetObservedCostPerHourBeforeBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeBytesUsd() *float64 { - return v.ObservedCostPerHourBeforeBytesUsd -} - -// GetObservedCostPerHourBeforeVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeVolumeUsd() *float64 { - return v.ObservedCostPerHourBeforeVolumeUsd -} - -// GetObservedCostPerHourBeforeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourBeforeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourBeforeUsd() *float64 { - return v.ObservedCostPerHourBeforeUsd -} - -// GetObservedCostPerHourAfterBytesUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterBytesUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterBytesUsd() *float64 { - return v.ObservedCostPerHourAfterBytesUsd -} - -// GetObservedCostPerHourAfterVolumeUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterVolumeUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterVolumeUsd() *float64 { - return v.ObservedCostPerHourAfterVolumeUsd -} - -// GetObservedCostPerHourAfterUsd returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache.ObservedCostPerHourAfterUsd, and is useful for accessing the field via an interface. -func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusDatadogAccountStatusCache) GetObservedCostPerHourAfterUsd() *float64 { - return v.ObservedCostPerHourAfterUsd +// GetReadyForUse returns GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness.ReadyForUse, and is useful for accessing the field via an interface. +func (v *GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness) GetReadyForUse() bool { + return v.ReadyForUse } // GetDatadogAccountStatusResponse is returned by GetDatadogAccountStatus on success. type GetDatadogAccountStatusResponse struct { - // Query connected Datadog accounts. + // Query DatadogAccounts records in your account. DatadogAccounts GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection `json:"datadogAccounts"` } @@ -1007,7 +506,7 @@ func (v *GetDatadogAccountStatusResponse) GetDatadogAccounts() GetDatadogAccount // GetServiceByNameResponse is returned by GetServiceByName on success. type GetServiceByNameResponse struct { - // Query services in your system. + // Query Services records in your account. Services GetServiceByNameServicesServiceConnection `json:"services"` } @@ -1017,30 +516,22 @@ func (v *GetServiceByNameResponse) GetServices() GetServiceByNameServicesService } // GetServiceByNameServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type GetServiceByNameServicesServiceConnection struct { - // A list of edges. - Edges []*GetServiceByNameServicesServiceConnectionEdgesServiceEdge `json:"edges"` + Edges []GetServiceByNameServicesServiceConnectionEdgesServiceEdge `json:"edges"` } // GetEdges returns GetServiceByNameServicesServiceConnection.Edges, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnection) GetEdges() []*GetServiceByNameServicesServiceConnectionEdgesServiceEdge { +func (v *GetServiceByNameServicesServiceConnection) GetEdges() []GetServiceByNameServicesServiceConnectionEdgesServiceEdge { return v.Edges } // GetServiceByNameServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type GetServiceByNameServicesServiceConnectionEdgesServiceEdge struct { - // The item at the end of the edge. - Node *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` + Node GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } // GetNode returns GetServiceByNameServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdge) GetNode() *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService { +func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdge) GetNode() GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService { return v.Node } @@ -1050,8 +541,6 @@ type GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService struct Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` // Whether log analysis and policy generation is active for this service Enabled bool `json:"enabled"` // When the service was created @@ -1070,11 +559,6 @@ func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) G return v.Name } -// GetDescription returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Description, and is useful for accessing the field via an interface. -func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetDescription() string { - return v.Description -} - // GetEnabled returns GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { return v.Enabled @@ -1090,500 +574,41 @@ func (v *GetServiceByNameServicesServiceConnectionEdgesServiceEdgeNodeService) G return v.UpdatedAt } -// GetServiceNode includes the requested fields of the GraphQL interface Node. -// -// GetServiceNode is implemented by the following types: -// GetServiceNodeAccount -// GetServiceNodeConversation -// GetServiceNodeConversationContext -// GetServiceNodeDatadogAccount -// GetServiceNodeDatadogAccountStatusCache -// GetServiceNodeDatadogLogIndex -// GetServiceNodeEdgeApiKey -// GetServiceNodeEdgeInstance -// GetServiceNodeLogEvent -// GetServiceNodeLogEventField -// GetServiceNodeLogEventPolicy -// GetServiceNodeLogEventPolicyCategoryStatusCache -// GetServiceNodeLogEventPolicyStatusCache -// GetServiceNodeLogEventStatusCache -// GetServiceNodeLogSample -// GetServiceNodeMessage -// GetServiceNodeOrganization -// GetServiceNodeService -// GetServiceNodeServiceStatusCache -// GetServiceNodeTeam -// GetServiceNodeView -// GetServiceNodeViewFavorite -// GetServiceNodeWorkspace -// The GraphQL type's documentation follows. -// -// An object with an ID. -// Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) -type GetServiceNode interface { - implementsGraphQLInterfaceGetServiceNode() - // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). - GetTypename() *string -} - -func (v *GetServiceNodeAccount) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeConversation) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeConversationContext) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogAccount) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogAccountStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeDatadogLogIndex) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeEdgeApiKey) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeEdgeInstance) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEvent) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventField) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventPolicy) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventPolicyCategoryStatusCache) implementsGraphQLInterfaceGetServiceNode() { -} -func (v *GetServiceNodeLogEventPolicyStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogEventStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeLogSample) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeMessage) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeOrganization) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeService) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeServiceStatusCache) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeTeam) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeView) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeViewFavorite) implementsGraphQLInterfaceGetServiceNode() {} -func (v *GetServiceNodeWorkspace) implementsGraphQLInterfaceGetServiceNode() {} - -func __unmarshalGetServiceNode(b []byte, v *GetServiceNode) error { - if string(b) == "null" { - return nil - } - - var tn struct { - TypeName string `json:"__typename"` - } - err := json.Unmarshal(b, &tn) - if err != nil { - return err - } - - switch tn.TypeName { - case "Account": - *v = new(GetServiceNodeAccount) - return json.Unmarshal(b, *v) - case "Conversation": - *v = new(GetServiceNodeConversation) - return json.Unmarshal(b, *v) - case "ConversationContext": - *v = new(GetServiceNodeConversationContext) - return json.Unmarshal(b, *v) - case "DatadogAccount": - *v = new(GetServiceNodeDatadogAccount) - return json.Unmarshal(b, *v) - case "DatadogAccountStatusCache": - *v = new(GetServiceNodeDatadogAccountStatusCache) - return json.Unmarshal(b, *v) - case "DatadogLogIndex": - *v = new(GetServiceNodeDatadogLogIndex) - return json.Unmarshal(b, *v) - case "EdgeApiKey": - *v = new(GetServiceNodeEdgeApiKey) - return json.Unmarshal(b, *v) - case "EdgeInstance": - *v = new(GetServiceNodeEdgeInstance) - return json.Unmarshal(b, *v) - case "LogEvent": - *v = new(GetServiceNodeLogEvent) - return json.Unmarshal(b, *v) - case "LogEventField": - *v = new(GetServiceNodeLogEventField) - return json.Unmarshal(b, *v) - case "LogEventPolicy": - *v = new(GetServiceNodeLogEventPolicy) - return json.Unmarshal(b, *v) - case "LogEventPolicyCategoryStatusCache": - *v = new(GetServiceNodeLogEventPolicyCategoryStatusCache) - return json.Unmarshal(b, *v) - case "LogEventPolicyStatusCache": - *v = new(GetServiceNodeLogEventPolicyStatusCache) - return json.Unmarshal(b, *v) - case "LogEventStatusCache": - *v = new(GetServiceNodeLogEventStatusCache) - return json.Unmarshal(b, *v) - case "LogSample": - *v = new(GetServiceNodeLogSample) - return json.Unmarshal(b, *v) - case "Message": - *v = new(GetServiceNodeMessage) - return json.Unmarshal(b, *v) - case "Organization": - *v = new(GetServiceNodeOrganization) - return json.Unmarshal(b, *v) - case "Service": - *v = new(GetServiceNodeService) - return json.Unmarshal(b, *v) - case "ServiceStatusCache": - *v = new(GetServiceNodeServiceStatusCache) - return json.Unmarshal(b, *v) - case "Team": - *v = new(GetServiceNodeTeam) - return json.Unmarshal(b, *v) - case "View": - *v = new(GetServiceNodeView) - return json.Unmarshal(b, *v) - case "ViewFavorite": - *v = new(GetServiceNodeViewFavorite) - return json.Unmarshal(b, *v) - case "Workspace": - *v = new(GetServiceNodeWorkspace) - return json.Unmarshal(b, *v) - case "": - return fmt.Errorf( - "response was missing Node.__typename") - default: - return fmt.Errorf( - `unexpected concrete type for GetServiceNode: "%v"`, tn.TypeName) - } -} - -func __marshalGetServiceNode(v *GetServiceNode) ([]byte, error) { - - var typename string - switch v := (*v).(type) { - case *GetServiceNodeAccount: - typename = "Account" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeAccount - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeConversation: - typename = "Conversation" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeConversation - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeConversationContext: - typename = "ConversationContext" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeConversationContext - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogAccount: - typename = "DatadogAccount" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogAccount - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogAccountStatusCache: - typename = "DatadogAccountStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogAccountStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeDatadogLogIndex: - typename = "DatadogLogIndex" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeDatadogLogIndex - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeEdgeApiKey: - typename = "EdgeApiKey" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeEdgeApiKey - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeEdgeInstance: - typename = "EdgeInstance" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeEdgeInstance - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEvent: - typename = "LogEvent" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEvent - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventField: - typename = "LogEventField" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventField - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicy: - typename = "LogEventPolicy" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicy - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicyCategoryStatusCache: - typename = "LogEventPolicyCategoryStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicyCategoryStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventPolicyStatusCache: - typename = "LogEventPolicyStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventPolicyStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogEventStatusCache: - typename = "LogEventStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogEventStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeLogSample: - typename = "LogSample" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeLogSample - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeMessage: - typename = "Message" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeMessage - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeOrganization: - typename = "Organization" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeOrganization - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeService: - typename = "Service" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeService - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeServiceStatusCache: - typename = "ServiceStatusCache" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeServiceStatusCache - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeTeam: - typename = "Team" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeTeam - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeView: - typename = "View" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeView - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeViewFavorite: - typename = "ViewFavorite" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeViewFavorite - }{typename, v} - return json.Marshal(result) - case *GetServiceNodeWorkspace: - typename = "Workspace" - - result := struct { - TypeName string `json:"__typename"` - *GetServiceNodeWorkspace - }{typename, v} - return json.Marshal(result) - case nil: - return []byte("null"), nil - default: - return nil, fmt.Errorf( - `unexpected concrete type for GetServiceNode: "%T"`, v) - } -} - -// GetServiceNodeAccount includes the requested fields of the GraphQL type Account. -type GetServiceNodeAccount struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeAccount.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeAccount) GetTypename() *string { return v.Typename } - -// GetServiceNodeConversation includes the requested fields of the GraphQL type Conversation. -type GetServiceNodeConversation struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeConversation.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeConversation) GetTypename() *string { return v.Typename } - -// GetServiceNodeConversationContext includes the requested fields of the GraphQL type ConversationContext. -type GetServiceNodeConversationContext struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeConversationContext.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeConversationContext) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. -type GetServiceNodeDatadogAccount struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeDatadogAccount.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogAccount) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogAccountStatusCache includes the requested fields of the GraphQL type DatadogAccountStatusCache. -type GetServiceNodeDatadogAccountStatusCache struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeDatadogAccountStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogAccountStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeDatadogLogIndex includes the requested fields of the GraphQL type DatadogLogIndex. -type GetServiceNodeDatadogLogIndex struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeDatadogLogIndex.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeDatadogLogIndex) GetTypename() *string { return v.Typename } - -// GetServiceNodeEdgeApiKey includes the requested fields of the GraphQL type EdgeApiKey. -type GetServiceNodeEdgeApiKey struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeEdgeApiKey.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeEdgeApiKey) GetTypename() *string { return v.Typename } - -// GetServiceNodeEdgeInstance includes the requested fields of the GraphQL type EdgeInstance. -type GetServiceNodeEdgeInstance struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeEdgeInstance.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeEdgeInstance) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogEvent includes the requested fields of the GraphQL type LogEvent. -type GetServiceNodeLogEvent struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeLogEvent.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEvent) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogEventField includes the requested fields of the GraphQL type LogEventField. -type GetServiceNodeLogEventField struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeLogEventField.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventField) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. -type GetServiceNodeLogEventPolicy struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeLogEventPolicy.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicy) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogEventPolicyCategoryStatusCache includes the requested fields of the GraphQL type LogEventPolicyCategoryStatusCache. -type GetServiceNodeLogEventPolicyCategoryStatusCache struct { - Typename *string `json:"__typename"` -} - -// GetTypename returns GetServiceNodeLogEventPolicyCategoryStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicyCategoryStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogEventPolicyStatusCache includes the requested fields of the GraphQL type LogEventPolicyStatusCache. -type GetServiceNodeLogEventPolicyStatusCache struct { - Typename *string `json:"__typename"` +// GetServiceResponse is returned by GetService on success. +type GetServiceResponse struct { + // Query Services records in your account. + Services GetServiceServicesServiceConnection `json:"services"` } -// GetTypename returns GetServiceNodeLogEventPolicyStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventPolicyStatusCache) GetTypename() *string { return v.Typename } +// GetServices returns GetServiceResponse.Services, and is useful for accessing the field via an interface. +func (v *GetServiceResponse) GetServices() GetServiceServicesServiceConnection { return v.Services } -// GetServiceNodeLogEventStatusCache includes the requested fields of the GraphQL type LogEventStatusCache. -type GetServiceNodeLogEventStatusCache struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type GetServiceServicesServiceConnection struct { + Edges []GetServiceServicesServiceConnectionEdgesServiceEdge `json:"edges"` } -// GetTypename returns GetServiceNodeLogEventStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogEventStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeLogSample includes the requested fields of the GraphQL type LogSample. -type GetServiceNodeLogSample struct { - Typename *string `json:"__typename"` +// GetEdges returns GetServiceServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnection) GetEdges() []GetServiceServicesServiceConnectionEdgesServiceEdge { + return v.Edges } -// GetTypename returns GetServiceNodeLogSample.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeLogSample) GetTypename() *string { return v.Typename } - -// GetServiceNodeMessage includes the requested fields of the GraphQL type Message. -type GetServiceNodeMessage struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type GetServiceServicesServiceConnectionEdgesServiceEdge struct { + Node GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } -// GetTypename returns GetServiceNodeMessage.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeMessage) GetTypename() *string { return v.Typename } - -// GetServiceNodeOrganization includes the requested fields of the GraphQL type Organization. -type GetServiceNodeOrganization struct { - Typename *string `json:"__typename"` +// GetNode returns GetServiceServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdge) GetNode() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService { + return v.Node } -// GetTypename returns GetServiceNodeOrganization.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeOrganization) GetTypename() *string { return v.Typename } - -// GetServiceNodeService includes the requested fields of the GraphQL type Service. -type GetServiceNodeService struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService struct { // Unique identifier of the service Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` // Whether log analysis and policy generation is active for this service Enabled bool `json:"enabled"` // When the service was created @@ -1591,206 +616,115 @@ type GetServiceNodeService struct { // When the service was last updated UpdatedAt time.Time `json:"updatedAt"` // Account this service belongs to - Account GetServiceNodeServiceAccount `json:"account"` + Account GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount `json:"account"` // Log event types produced by this service - LogEvents []GetServiceNodeServiceLogEventsLogEvent `json:"logEvents"` + LogEvents GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection `json:"logEvents"` } -// GetTypename returns GetServiceNodeService.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetTypename() *string { return v.Typename } - -// GetId returns GetServiceNodeService.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetId() string { return v.Id } +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { return v.Id } -// GetName returns GetServiceNodeService.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetName() string { return v.Name } - -// GetDescription returns GetServiceNodeService.Description, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetDescription() string { return v.Description } +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name +} -// GetEnabled returns GetServiceNodeService.Enabled, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetEnabled() bool { return v.Enabled } +// GetEnabled returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { + return v.Enabled +} -// GetCreatedAt returns GetServiceNodeService.CreatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetCreatedAt() time.Time { return v.CreatedAt } +// GetCreatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { + return v.CreatedAt +} -// GetUpdatedAt returns GetServiceNodeService.UpdatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetUpdatedAt() time.Time { return v.UpdatedAt } +// GetUpdatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { + return v.UpdatedAt +} -// GetAccount returns GetServiceNodeService.Account, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetAccount() GetServiceNodeServiceAccount { return v.Account } +// GetAccount returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.Account, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetAccount() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount { + return v.Account +} -// GetLogEvents returns GetServiceNodeService.LogEvents, and is useful for accessing the field via an interface. -func (v *GetServiceNodeService) GetLogEvents() []GetServiceNodeServiceLogEventsLogEvent { +// GetLogEvents returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService.LogEvents, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeService) GetLogEvents() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection { return v.LogEvents } -// GetServiceNodeServiceAccount includes the requested fields of the GraphQL type Account. -type GetServiceNodeServiceAccount struct { +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount includes the requested fields of the GraphQL type Account. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount struct { // Unique identifier of the account Id string `json:"id"` // Human-readable name within the organization Name string `json:"name"` } -// GetId returns GetServiceNodeServiceAccount.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceAccount) GetId() string { return v.Id } - -// GetName returns GetServiceNodeServiceAccount.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceAccount) GetName() string { return v.Name } - -// GetServiceNodeServiceLogEventsLogEvent includes the requested fields of the GraphQL type LogEvent. -type GetServiceNodeServiceLogEventsLogEvent struct { - // Unique identifier of the log event - Id string `json:"id"` - // Snake_case identifier unique per service, e.g. nginx_access_log - Name string `json:"name"` - // What the event is and what data instances carry. Helps engineers decide whether to look here. - Description string `json:"description"` - // When the log event was created - CreatedAt time.Time `json:"createdAt"` -} - -// GetId returns GetServiceNodeServiceLogEventsLogEvent.Id, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetId() string { return v.Id } - -// GetName returns GetServiceNodeServiceLogEventsLogEvent.Name, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetName() string { return v.Name } - -// GetDescription returns GetServiceNodeServiceLogEventsLogEvent.Description, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetDescription() string { return v.Description } - -// GetCreatedAt returns GetServiceNodeServiceLogEventsLogEvent.CreatedAt, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceLogEventsLogEvent) GetCreatedAt() time.Time { return v.CreatedAt } - -// GetServiceNodeServiceStatusCache includes the requested fields of the GraphQL type ServiceStatusCache. -type GetServiceNodeServiceStatusCache struct { - Typename *string `json:"__typename"` +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount) GetId() string { + return v.Id } -// GetTypename returns GetServiceNodeServiceStatusCache.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeServiceStatusCache) GetTypename() *string { return v.Typename } - -// GetServiceNodeTeam includes the requested fields of the GraphQL type Team. -type GetServiceNodeTeam struct { - Typename *string `json:"__typename"` +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceAccount) GetName() string { + return v.Name } -// GetTypename returns GetServiceNodeTeam.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeTeam) GetTypename() *string { return v.Typename } - -// GetServiceNodeView includes the requested fields of the GraphQL type View. -type GetServiceNodeView struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection includes the requested fields of the GraphQL type LogEventConnection. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection struct { + Edges []GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge `json:"edges"` } -// GetTypename returns GetServiceNodeView.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeView) GetTypename() *string { return v.Typename } - -// GetServiceNodeViewFavorite includes the requested fields of the GraphQL type ViewFavorite. -type GetServiceNodeViewFavorite struct { - Typename *string `json:"__typename"` +// GetEdges returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnection) GetEdges() []GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge { + return v.Edges } -// GetTypename returns GetServiceNodeViewFavorite.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeViewFavorite) GetTypename() *string { return v.Typename } - -// GetServiceNodeWorkspace includes the requested fields of the GraphQL type Workspace. -type GetServiceNodeWorkspace struct { - Typename *string `json:"__typename"` +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge includes the requested fields of the GraphQL type LogEventEdge. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge struct { + Node GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent `json:"node"` } -// GetTypename returns GetServiceNodeWorkspace.Typename, and is useful for accessing the field via an interface. -func (v *GetServiceNodeWorkspace) GetTypename() *string { return v.Typename } - -// GetServiceResponse is returned by GetService on success. -type GetServiceResponse struct { - // Fetches an object given its ID. - Node *GetServiceNode `json:"-"` +// GetNode returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge.Node, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdge) GetNode() GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent { + return v.Node } -// GetNode returns GetServiceResponse.Node, and is useful for accessing the field via an interface. -func (v *GetServiceResponse) GetNode() *GetServiceNode { return v.Node } - -func (v *GetServiceResponse) UnmarshalJSON(b []byte) error { - - if string(b) == "null" { - return nil - } - - var firstPass struct { - *GetServiceResponse - Node json.RawMessage `json:"node"` - graphql.NoUnmarshalJSON - } - firstPass.GetServiceResponse = v - - err := json.Unmarshal(b, &firstPass) - if err != nil { - return err - } - - { - dst := &v.Node - src := firstPass.Node - if len(src) != 0 && string(src) != "null" { - *dst = new(GetServiceNode) - err = __unmarshalGetServiceNode( - src, *dst) - if err != nil { - return fmt.Errorf( - "unable to unmarshal GetServiceResponse.Node: %w", err) - } - } - } - return nil +// GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent includes the requested fields of the GraphQL type LogEvent. +type GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent struct { + // Unique identifier of the log event + Id string `json:"id"` + // Snake_case identifier unique per service, e.g. nginx_access_log + Name string `json:"name"` + // When the log event was created + CreatedAt time.Time `json:"createdAt"` } -type __premarshalGetServiceResponse struct { - Node json.RawMessage `json:"node"` +// GetId returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Id, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetId() string { + return v.Id } -func (v *GetServiceResponse) MarshalJSON() ([]byte, error) { - premarshaled, err := v.__premarshalJSON() - if err != nil { - return nil, err - } - return json.Marshal(premarshaled) +// GetName returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Name, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetName() string { + return v.Name } -func (v *GetServiceResponse) __premarshalJSON() (*__premarshalGetServiceResponse, error) { - var retval __premarshalGetServiceResponse - - { - - dst := &retval.Node - src := v.Node - if src != nil { - var err error - *dst, err = __marshalGetServiceNode( - src) - if err != nil { - return nil, fmt.Errorf( - "unable to marshal GetServiceResponse.Node: %w", err) - } - } - } - return &retval, nil +// GetCreatedAt returns GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.CreatedAt, and is useful for accessing the field via an interface. +func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetCreatedAt() time.Time { + return v.CreatedAt } // ListAccountsAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type ListAccountsAccountsAccountConnection struct { - // A list of edges. - Edges []*ListAccountsAccountsAccountConnectionEdgesAccountEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` + Edges []ListAccountsAccountsAccountConnectionEdgesAccountEdge `json:"edges"` + TotalCount int `json:"totalCount"` } // GetEdges returns ListAccountsAccountsAccountConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListAccountsAccountsAccountConnection) GetEdges() []*ListAccountsAccountsAccountConnectionEdgesAccountEdge { +func (v *ListAccountsAccountsAccountConnection) GetEdges() []ListAccountsAccountsAccountConnectionEdgesAccountEdge { return v.Edges } @@ -1798,16 +732,12 @@ func (v *ListAccountsAccountsAccountConnection) GetEdges() []*ListAccountsAccoun func (v *ListAccountsAccountsAccountConnection) GetTotalCount() int { return v.TotalCount } // ListAccountsAccountsAccountConnectionEdgesAccountEdge includes the requested fields of the GraphQL type AccountEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type ListAccountsAccountsAccountConnectionEdgesAccountEdge struct { - // The item at the end of the edge. - Node *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` + Node ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount `json:"node"` } // GetNode returns ListAccountsAccountsAccountConnectionEdgesAccountEdge.Node, and is useful for accessing the field via an interface. -func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdge) GetNode() *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount { +func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdge) GetNode() ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount { return v.Node } @@ -1838,7 +768,7 @@ func (v *ListAccountsAccountsAccountConnectionEdgesAccountEdgeNodeAccount) GetCr // ListAccountsResponse is returned by ListAccounts on success. type ListAccountsResponse struct { - // Query accounts. Accounts belong to an organization and contain services and workspaces. + // Query Accounts records in your account. Accounts ListAccountsAccountsAccountConnection `json:"accounts"` } @@ -1846,18 +776,13 @@ type ListAccountsResponse struct { func (v *ListAccountsResponse) GetAccounts() ListAccountsAccountsAccountConnection { return v.Accounts } // ListOrganizationsOrganizationsOrganizationConnection includes the requested fields of the GraphQL type OrganizationConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type ListOrganizationsOrganizationsOrganizationConnection struct { - // A list of edges. - Edges []*ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` + Edges []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` + TotalCount int `json:"totalCount"` } // GetEdges returns ListOrganizationsOrganizationsOrganizationConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListOrganizationsOrganizationsOrganizationConnection) GetEdges() []*ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge { +func (v *ListOrganizationsOrganizationsOrganizationConnection) GetEdges() []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge { return v.Edges } @@ -1867,16 +792,12 @@ func (v *ListOrganizationsOrganizationsOrganizationConnection) GetTotalCount() i } // ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge includes the requested fields of the GraphQL type OrganizationEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge struct { - // The item at the end of the edge. - Node *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization `json:"node"` + Node ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization `json:"node"` } // GetNode returns ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge.Node, and is useful for accessing the field via an interface. -func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge) GetNode() *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization { +func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge) GetNode() ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization { return v.Node } @@ -1914,7 +835,7 @@ func (v *ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEd // ListOrganizationsResponse is returned by ListOrganizations on success. type ListOrganizationsResponse struct { - // Query organizations. An organization is the top-level container that holds accounts. + // Query Organizations records in your account. Organizations ListOrganizationsOrganizationsOrganizationConnection `json:"organizations"` } @@ -1925,7 +846,7 @@ func (v *ListOrganizationsResponse) GetOrganizations() ListOrganizationsOrganiza // ListServicesResponse is returned by ListServices on success. type ListServicesResponse struct { - // Query services in your system. + // Query Services records in your account. Services ListServicesServicesServiceConnection `json:"services"` } @@ -1933,18 +854,13 @@ type ListServicesResponse struct { func (v *ListServicesResponse) GetServices() ListServicesServicesServiceConnection { return v.Services } // ListServicesServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. type ListServicesServicesServiceConnection struct { - // A list of edges. - Edges []*ListServicesServicesServiceConnectionEdgesServiceEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` + Edges []ListServicesServicesServiceConnectionEdgesServiceEdge `json:"edges"` + TotalCount int `json:"totalCount"` } // GetEdges returns ListServicesServicesServiceConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnection) GetEdges() []*ListServicesServicesServiceConnectionEdgesServiceEdge { +func (v *ListServicesServicesServiceConnection) GetEdges() []ListServicesServicesServiceConnectionEdgesServiceEdge { return v.Edges } @@ -1952,16 +868,12 @@ func (v *ListServicesServicesServiceConnection) GetEdges() []*ListServicesServic func (v *ListServicesServicesServiceConnection) GetTotalCount() int { return v.TotalCount } // ListServicesServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. type ListServicesServicesServiceConnectionEdgesServiceEdge struct { - // The item at the end of the edge. - Node *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` + Node ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` } // GetNode returns ListServicesServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdge) GetNode() *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService { +func (v *ListServicesServicesServiceConnectionEdgesServiceEdge) GetNode() ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService { return v.Node } @@ -1971,259 +883,63 @@ type ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService struct { Id string `json:"id"` // Service identifier in telemetry (e.g., 'checkout-service') Name string `json:"name"` - // AI-generated description of what this service does and its telemetry characteristics - Description string `json:"description"` // Whether log analysis and policy generation is active for this service Enabled bool `json:"enabled"` - // When the service was created - CreatedAt time.Time `json:"createdAt"` - // When the service was last updated - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetId returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { - return v.Id -} - -// GetName returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { - return v.Name -} - -// GetDescription returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Description, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetDescription() string { - return v.Description -} - -// GetEnabled returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { - return v.Enabled -} - -// GetCreatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { - return v.CreatedAt -} - -// GetUpdatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. -func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { - return v.UpdatedAt -} - -// ListWorkspacesResponse is returned by ListWorkspaces on success. -type ListWorkspacesResponse struct { - // Query workspaces. Workspaces are used to analyze and classify telemetry. - Workspaces ListWorkspacesWorkspacesWorkspaceConnection `json:"workspaces"` -} - -// GetWorkspaces returns ListWorkspacesResponse.Workspaces, and is useful for accessing the field via an interface. -func (v *ListWorkspacesResponse) GetWorkspaces() ListWorkspacesWorkspacesWorkspaceConnection { - return v.Workspaces -} - -// ListWorkspacesWorkspacesWorkspaceConnection includes the requested fields of the GraphQL type WorkspaceConnection. -// The GraphQL type's documentation follows. -// -// A connection to a list of items. -type ListWorkspacesWorkspacesWorkspaceConnection struct { - // A list of edges. - Edges []*ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge `json:"edges"` - // Identifies the total count of items in the connection. - TotalCount int `json:"totalCount"` -} - -// GetEdges returns ListWorkspacesWorkspacesWorkspaceConnection.Edges, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnection) GetEdges() []*ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge { - return v.Edges -} - -// GetTotalCount returns ListWorkspacesWorkspacesWorkspaceConnection.TotalCount, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnection) GetTotalCount() int { return v.TotalCount } - -// ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge includes the requested fields of the GraphQL type WorkspaceEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge struct { - // The item at the end of the edge. - Node *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace `json:"node"` -} - -// GetNode returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge.Node, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdge) GetNode() *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace { - return v.Node -} - -// ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace includes the requested fields of the GraphQL type Workspace. -type ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace struct { - // Unique identifier of the workspace - Id string `json:"id"` - // Human-readable name within the account - Name string `json:"name"` - // Primary purpose determining evaluation strategy. observability: performance - // and reliability, security: threat detection, compliance: regulatory requirements. - Purpose WorkspacePurpose `json:"purpose"` - // When the workspace was created - CreatedAt time.Time `json:"createdAt"` -} - -// GetId returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Id, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetId() string { - return v.Id -} - -// GetName returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Name, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetName() string { - return v.Name -} - -// GetPurpose returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.Purpose, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetPurpose() WorkspacePurpose { - return v.Purpose -} - -// GetCreatedAt returns ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace.CreatedAt, and is useful for accessing the field via an interface. -func (v *ListWorkspacesWorkspacesWorkspaceConnectionEdgesWorkspaceEdgeNodeWorkspace) GetCreatedAt() time.Time { - return v.CreatedAt -} - -// MessageRole is enum for the field role -type MessageRole string - -const ( - MessageRoleUser MessageRole = "user" - MessageRoleAssistant MessageRole = "assistant" -) - -var AllMessageRole = []MessageRole{ - MessageRoleUser, - MessageRoleAssistant, -} - -// MessageStopReason is enum for the field stop_reason -type MessageStopReason string - -const ( - MessageStopReasonEndTurn MessageStopReason = "end_turn" - MessageStopReasonToolUse MessageStopReason = "tool_use" -) - -var AllMessageStopReason = []MessageStopReason{ - MessageStopReasonEndTurn, - MessageStopReasonToolUse, -} - -// Text content from the user or assistant. -type TextBlockInput struct { - Content string `json:"content"` -} - -// GetContent returns TextBlockInput.Content, and is useful for accessing the field via an interface. -func (v *TextBlockInput) GetContent() string { return v.Content } - -// The AI's internal reasoning (extended thinking). -type ThinkingBlockInput struct { - Content string `json:"content"` -} - -// GetContent returns ThinkingBlockInput.Content, and is useful for accessing the field via an interface. -func (v *ThinkingBlockInput) GetContent() string { return v.Content } - -// The result of a tool call. -type ToolResultInput struct { - // The ID of the tool call this result is for. - ToolUseId string `json:"toolUseId"` - // Whether the tool execution resulted in an error. - IsError bool `json:"isError"` - // Human-readable error message when isError is true. - Error *string `json:"error"` - // Structured result data (JSON object). - Content *map[string]any `json:"content"` -} - -// GetToolUseId returns ToolResultInput.ToolUseId, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetToolUseId() string { return v.ToolUseId } - -// GetIsError returns ToolResultInput.IsError, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetIsError() bool { return v.IsError } - -// GetError returns ToolResultInput.Error, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetError() *string { return v.Error } - -// GetContent returns ToolResultInput.Content, and is useful for accessing the field via an interface. -func (v *ToolResultInput) GetContent() *map[string]any { return v.Content } - -// A tool call from the assistant. -type ToolUseInput struct { - // Unique identifier for this tool call. - Id string `json:"id"` - // The name of the tool being called. - Name string `json:"name"` - // The input parameters for the tool (JSON object). - Input map[string]any `json:"input"` + // When the service was created + CreatedAt time.Time `json:"createdAt"` + // When the service was last updated + UpdatedAt time.Time `json:"updatedAt"` } -// GetId returns ToolUseInput.Id, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetId() string { return v.Id } - -// GetName returns ToolUseInput.Name, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetName() string { return v.Name } - -// GetInput returns ToolUseInput.Input, and is useful for accessing the field via an interface. -func (v *ToolUseInput) GetInput() map[string]any { return v.Input } - -// UpdateConversationInput is used for update Conversation object. -// Input was generated by ent. -type UpdateConversationInput struct { - // AI-generated title, set after first exchange - Title *string `json:"title"` - ClearTitle *bool `json:"clearTitle"` - ViewID *string `json:"viewID"` - ClearView *bool `json:"clearView"` +// GetId returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { + return v.Id } -// GetTitle returns UpdateConversationInput.Title, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetTitle() *string { return v.Title } - -// GetClearTitle returns UpdateConversationInput.ClearTitle, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetClearTitle() *bool { return v.ClearTitle } - -// GetViewID returns UpdateConversationInput.ViewID, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetViewID() *string { return v.ViewID } +// GetName returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name +} -// GetClearView returns UpdateConversationInput.ClearView, and is useful for accessing the field via an interface. -func (v *UpdateConversationInput) GetClearView() *bool { return v.ClearView } +// GetEnabled returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.Enabled, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetEnabled() bool { + return v.Enabled +} -// UpdateConversationResponse is returned by UpdateConversation on success. -type UpdateConversationResponse struct { - // Update a conversation (e.g., set title). - UpdateConversation UpdateConversationUpdateConversation `json:"updateConversation"` +// GetCreatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.CreatedAt, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetCreatedAt() time.Time { + return v.CreatedAt } -// GetUpdateConversation returns UpdateConversationResponse.UpdateConversation, and is useful for accessing the field via an interface. -func (v *UpdateConversationResponse) GetUpdateConversation() UpdateConversationUpdateConversation { - return v.UpdateConversation +// GetUpdatedAt returns ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService.UpdatedAt, and is useful for accessing the field via an interface. +func (v *ListServicesServicesServiceConnectionEdgesServiceEdgeNodeService) GetUpdatedAt() time.Time { + return v.UpdatedAt } -// UpdateConversationUpdateConversation includes the requested fields of the GraphQL type Conversation. -type UpdateConversationUpdateConversation struct { - // Unique identifier - Id string `json:"id"` - // AI-generated title, set after first exchange - Title *string `json:"title"` - // When the conversation was last updated - UpdatedAt time.Time `json:"updatedAt"` +// Organization creation input. +type OrganizationCreateInput struct { + // Human-readable name, unique across the system + Name string `json:"name"` } -// GetId returns UpdateConversationUpdateConversation.Id, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetId() string { return v.Id } +// GetName returns OrganizationCreateInput.Name, and is useful for accessing the field via an interface. +func (v *OrganizationCreateInput) GetName() string { return v.Name } -// GetTitle returns UpdateConversationUpdateConversation.Title, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetTitle() *string { return v.Title } +type StatusHealth string + +const ( + StatusHealthDisabled StatusHealth = "DISABLED" + StatusHealthInactive StatusHealth = "INACTIVE" + StatusHealthError StatusHealth = "ERROR" + StatusHealthOk StatusHealth = "OK" +) -// GetUpdatedAt returns UpdateConversationUpdateConversation.UpdatedAt, and is useful for accessing the field via an interface. -func (v *UpdateConversationUpdateConversation) GetUpdatedAt() time.Time { return v.UpdatedAt } +var AllStatusHealth = []StatusHealth{ + StatusHealthDisabled, + StatusHealthInactive, + StatusHealthError, + StatusHealthOk, +} type ValidateDatadogApiKeyInput struct { ApiKey string `json:"apiKey"` @@ -2238,6 +954,7 @@ func (v *ValidateDatadogApiKeyInput) GetSite() DatadogAccountSite { return v.Sit // ValidateDatadogApiKeyResponse is returned by ValidateDatadogApiKey on success. type ValidateDatadogApiKeyResponse struct { + // Validate a Datadog API key against a Datadog site. ValidateDatadogApiKey ValidateDatadogApiKeyValidateDatadogApiKeyValidateDatadogApiKeyResult `json:"validateDatadogApiKey"` } @@ -2262,78 +979,31 @@ func (v *ValidateDatadogApiKeyValidateDatadogApiKeyValidateDatadogApiKeyResult) return v.Error } -// WorkspacePurpose is enum for the field purpose -type WorkspacePurpose string - -const ( - WorkspacePurposeObservability WorkspacePurpose = "observability" - WorkspacePurposeSecurity WorkspacePurpose = "security" - WorkspacePurposeCompliance WorkspacePurpose = "compliance" -) - -var AllWorkspacePurpose = []WorkspacePurpose{ - WorkspacePurposeObservability, - WorkspacePurposeSecurity, - WorkspacePurposeCompliance, -} - -// __ApproveLogEventPolicyInput is used internally by genqlient -type __ApproveLogEventPolicyInput struct { - Id string `json:"id"` -} - -// GetId returns __ApproveLogEventPolicyInput.Id, and is useful for accessing the field via an interface. -func (v *__ApproveLogEventPolicyInput) GetId() string { return v.Id } - // __CreateAccountInput is used internally by genqlient type __CreateAccountInput struct { - Input CreateAccountInput `json:"input"` + Input AccountCreateInput `json:"input"` } // GetInput returns __CreateAccountInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateAccountInput) GetInput() CreateAccountInput { return v.Input } - -// __CreateConversationInput is used internally by genqlient -type __CreateConversationInput struct { - Input CreateConversationInput `json:"input"` -} - -// GetInput returns __CreateConversationInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateConversationInput) GetInput() CreateConversationInput { return v.Input } +func (v *__CreateAccountInput) GetInput() AccountCreateInput { return v.Input } // __CreateDatadogAccountWithCredentialsInput is used internally by genqlient type __CreateDatadogAccountWithCredentialsInput struct { - Input CreateDatadogAccountWithCredentialsInput `json:"input"` + Input DatadogAccountCreateInput `json:"input"` } // GetInput returns __CreateDatadogAccountWithCredentialsInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateDatadogAccountWithCredentialsInput) GetInput() CreateDatadogAccountWithCredentialsInput { +func (v *__CreateDatadogAccountWithCredentialsInput) GetInput() DatadogAccountCreateInput { return v.Input } -// __CreateMessageInput is used internally by genqlient -type __CreateMessageInput struct { - Input CreateMessageInput `json:"input"` -} - -// GetInput returns __CreateMessageInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateMessageInput) GetInput() CreateMessageInput { return v.Input } - // __CreateOrganizationAndBootstrapInput is used internally by genqlient type __CreateOrganizationAndBootstrapInput struct { - Input CreateOrganizationInput `json:"input"` + Input OrganizationCreateInput `json:"input"` } // GetInput returns __CreateOrganizationAndBootstrapInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateOrganizationAndBootstrapInput) GetInput() CreateOrganizationInput { return v.Input } - -// __DeleteConversationInput is used internally by genqlient -type __DeleteConversationInput struct { - Id string `json:"id"` -} - -// GetId returns __DeleteConversationInput.Id, and is useful for accessing the field via an interface. -func (v *__DeleteConversationInput) GetId() string { return v.Id } +func (v *__CreateOrganizationAndBootstrapInput) GetInput() OrganizationCreateInput { return v.Input } // __DisableServiceInput is used internally by genqlient type __DisableServiceInput struct { @@ -2343,14 +1013,6 @@ type __DisableServiceInput struct { // GetServiceId returns __DisableServiceInput.ServiceId, and is useful for accessing the field via an interface. func (v *__DisableServiceInput) GetServiceId() string { return v.ServiceId } -// __DismissLogEventPolicyInput is used internally by genqlient -type __DismissLogEventPolicyInput struct { - Id string `json:"id"` -} - -// GetId returns __DismissLogEventPolicyInput.Id, and is useful for accessing the field via an interface. -func (v *__DismissLogEventPolicyInput) GetId() string { return v.Id } - // __EnableServiceInput is used internally by genqlient type __EnableServiceInput struct { ServiceId string `json:"serviceId"` @@ -2399,26 +1061,6 @@ type __ListAccountsInput struct { // GetOrganizationID returns __ListAccountsInput.OrganizationID, and is useful for accessing the field via an interface. func (v *__ListAccountsInput) GetOrganizationID() string { return v.OrganizationID } -// __ListWorkspacesInput is used internally by genqlient -type __ListWorkspacesInput struct { - AccountID string `json:"accountID"` -} - -// GetAccountID returns __ListWorkspacesInput.AccountID, and is useful for accessing the field via an interface. -func (v *__ListWorkspacesInput) GetAccountID() string { return v.AccountID } - -// __UpdateConversationInput is used internally by genqlient -type __UpdateConversationInput struct { - Id string `json:"id"` - Input UpdateConversationInput `json:"input"` -} - -// GetId returns __UpdateConversationInput.Id, and is useful for accessing the field via an interface. -func (v *__UpdateConversationInput) GetId() string { return v.Id } - -// GetInput returns __UpdateConversationInput.Input, and is useful for accessing the field via an interface. -func (v *__UpdateConversationInput) GetInput() UpdateConversationInput { return v.Input } - // __ValidateDatadogApiKeyInput is used internally by genqlient type __ValidateDatadogApiKeyInput struct { Input ValidateDatadogApiKeyInput `json:"input"` @@ -2427,48 +1069,9 @@ type __ValidateDatadogApiKeyInput struct { // GetInput returns __ValidateDatadogApiKeyInput.Input, and is useful for accessing the field via an interface. func (v *__ValidateDatadogApiKeyInput) GetInput() ValidateDatadogApiKeyInput { return v.Input } -// The mutation executed by ApproveLogEventPolicy. -const ApproveLogEventPolicy_Operation = ` -mutation ApproveLogEventPolicy ($id: ID!) { - approveLogEventPolicy(id: $id) { - id - approvedAt - approvedBy - dismissedAt - dismissedBy - } -} -` - -// Mutation to approve a log event policy for enforcement -func ApproveLogEventPolicy( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *ApproveLogEventPolicyResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "ApproveLogEventPolicy", - Query: ApproveLogEventPolicy_Operation, - Variables: &__ApproveLogEventPolicyInput{ - Id: id, - }, - } - - data_ = &ApproveLogEventPolicyResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateAccount. const CreateAccount_Operation = ` -mutation CreateAccount ($input: CreateAccountInput!) { +mutation CreateAccount ($input: AccountCreateInput!) { createAccount(input: $input) { id name @@ -2480,7 +1083,7 @@ mutation CreateAccount ($input: CreateAccountInput!) { func CreateAccount( ctx_ context.Context, client_ graphql.Client, - input CreateAccountInput, + input AccountCreateInput, ) (data_ *CreateAccountResponse, err_ error) { req_ := &graphql.Request{ OpName: "CreateAccount", @@ -2502,46 +1105,9 @@ func CreateAccount( return data_, err_ } -// The mutation executed by CreateConversation. -const CreateConversation_Operation = ` -mutation CreateConversation ($input: CreateConversationInput!) { - createConversation(input: $input) { - id - title - createdAt - updatedAt - } -} -` - -func CreateConversation( - ctx_ context.Context, - client_ graphql.Client, - input CreateConversationInput, -) (data_ *CreateConversationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "CreateConversation", - Query: CreateConversation_Operation, - Variables: &__CreateConversationInput{ - Input: input, - }, - } - - data_ = &CreateConversationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateDatadogAccountWithCredentials. const CreateDatadogAccountWithCredentials_Operation = ` -mutation CreateDatadogAccountWithCredentials ($input: CreateDatadogAccountWithCredentialsInput!) { +mutation CreateDatadogAccountWithCredentials ($input: DatadogAccountCreateInput!) { createDatadogAccount(input: $input) { id name @@ -2555,7 +1121,7 @@ mutation CreateDatadogAccountWithCredentials ($input: CreateDatadogAccountWithCr func CreateDatadogAccountWithCredentials( ctx_ context.Context, client_ graphql.Client, - input CreateDatadogAccountWithCredentialsInput, + input DatadogAccountCreateInput, ) (data_ *CreateDatadogAccountWithCredentialsResponse, err_ error) { req_ := &graphql.Request{ OpName: "CreateDatadogAccountWithCredentials", @@ -2577,47 +1143,9 @@ func CreateDatadogAccountWithCredentials( return data_, err_ } -// The mutation executed by CreateMessage. -const CreateMessage_Operation = ` -mutation CreateMessage ($input: CreateMessageInput!) { - createMessage(input: $input) { - id - role - model - stopReason - createdAt - } -} -` - -func CreateMessage( - ctx_ context.Context, - client_ graphql.Client, - input CreateMessageInput, -) (data_ *CreateMessageResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "CreateMessage", - Query: CreateMessage_Operation, - Variables: &__CreateMessageInput{ - Input: input, - }, - } - - data_ = &CreateMessageResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateOrganizationAndBootstrap. const CreateOrganizationAndBootstrap_Operation = ` -mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { +mutation CreateOrganizationAndBootstrap ($input: OrganizationCreateInput!) { createOrganizationAndBootstrap(input: $input) { organization { id @@ -2629,10 +1157,6 @@ mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { id name } - workspace { - id - name - } } } ` @@ -2640,7 +1164,7 @@ mutation CreateOrganizationAndBootstrap ($input: CreateOrganizationInput!) { func CreateOrganizationAndBootstrap( ctx_ context.Context, client_ graphql.Client, - input CreateOrganizationInput, + input OrganizationCreateInput, ) (data_ *CreateOrganizationAndBootstrapResponse, err_ error) { req_ := &graphql.Request{ OpName: "CreateOrganizationAndBootstrap", @@ -2662,42 +1186,10 @@ func CreateOrganizationAndBootstrap( return data_, err_ } -// The mutation executed by DeleteConversation. -const DeleteConversation_Operation = ` -mutation DeleteConversation ($id: ID!) { - deleteConversation(id: $id) -} -` - -func DeleteConversation( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *DeleteConversationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "DeleteConversation", - Query: DeleteConversation_Operation, - Variables: &__DeleteConversationInput{ - Id: id, - }, - } - - data_ = &DeleteConversationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by DisableService. const DisableService_Operation = ` mutation DisableService ($serviceId: ID!) { - updateService(id: $serviceId, input: {enabled:false}) { + setServiceEnabled(id: $serviceId, enabled: false) { id name enabled @@ -2731,49 +1223,10 @@ func DisableService( return data_, err_ } -// The mutation executed by DismissLogEventPolicy. -const DismissLogEventPolicy_Operation = ` -mutation DismissLogEventPolicy ($id: ID!) { - dismissLogEventPolicy(id: $id) { - id - dismissedAt - dismissedBy - approvedAt - approvedBy - } -} -` - -// Mutation to dismiss a log event policy from pending review -func DismissLogEventPolicy( - ctx_ context.Context, - client_ graphql.Client, - id string, -) (data_ *DismissLogEventPolicyResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "DismissLogEventPolicy", - Query: DismissLogEventPolicy_Operation, - Variables: &__DismissLogEventPolicyInput{ - Id: id, - }, - } - - data_ = &DismissLogEventPolicyResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by EnableService. const EnableService_Operation = ` mutation EnableService ($serviceId: ID!) { - updateService(id: $serviceId, input: {enabled:true}) { + setServiceEnabled(id: $serviceId, enabled: true) { id name enabled @@ -2859,39 +1312,18 @@ query GetDatadogAccountStatus ($id: ID!) { id status { health - readyForUse - logEventCount - logEventAnalyzedCount - logServiceCount - logActiveServices - disabledServices - inactiveServices - okServices - policyPendingCount - policyApprovedCount - policyDismissedCount - serviceVolumePerHour - serviceCostPerHourVolumeUsd - logEventVolumePerHour - logEventBytesPerHour - logEventCostPerHourBytesUsd - logEventCostPerHourVolumeUsd - logEventCostPerHourUsd - estimatedVolumeReductionPerHour - estimatedBytesReductionPerHour - estimatedCostReductionPerHourBytesUsd - estimatedCostReductionPerHourVolumeUsd - estimatedCostReductionPerHourUsd - observedVolumePerHourBefore - observedVolumePerHourAfter - observedBytesPerHourBefore - observedBytesPerHourAfter - observedCostPerHourBeforeBytesUsd - observedCostPerHourBeforeVolumeUsd - observedCostPerHourBeforeUsd - observedCostPerHourAfterBytesUsd - observedCostPerHourAfterVolumeUsd - observedCostPerHourAfterUsd + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } } } } @@ -2927,24 +1359,27 @@ func GetDatadogAccountStatus( // The query executed by GetService. const GetService_Operation = ` query GetService ($id: ID!) { - node(id: $id) { - __typename - ... on Service { - id - name - description - enabled - createdAt - updatedAt - account { - id - name - } - logEvents { + services(where: {id:$id}, first: 1) { + edges { + node { id name - description + enabled createdAt + updatedAt + account { + id + name + } + logEvents { + edges { + node { + id + name + createdAt + } + } + } } } } @@ -2985,7 +1420,6 @@ query GetServiceByName ($name: String!) { node { id name - description enabled createdAt updatedAt @@ -3108,7 +1542,6 @@ query ListServices { node { id name - description enabled createdAt updatedAt @@ -3141,86 +1574,6 @@ func ListServices( return data_, err_ } -// The query executed by ListWorkspaces. -const ListWorkspaces_Operation = ` -query ListWorkspaces ($accountID: ID!) { - workspaces(where: {accountID:$accountID}) { - edges { - node { - id - name - purpose - createdAt - } - } - totalCount - } -} -` - -func ListWorkspaces( - ctx_ context.Context, - client_ graphql.Client, - accountID string, -) (data_ *ListWorkspacesResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "ListWorkspaces", - Query: ListWorkspaces_Operation, - Variables: &__ListWorkspacesInput{ - AccountID: accountID, - }, - } - - data_ = &ListWorkspacesResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - -// The mutation executed by UpdateConversation. -const UpdateConversation_Operation = ` -mutation UpdateConversation ($id: ID!, $input: UpdateConversationInput!) { - updateConversation(id: $id, input: $input) { - id - title - updatedAt - } -} -` - -func UpdateConversation( - ctx_ context.Context, - client_ graphql.Client, - id string, - input UpdateConversationInput, -) (data_ *UpdateConversationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "UpdateConversation", - Query: UpdateConversation_Operation, - Variables: &__UpdateConversationInput{ - Id: id, - Input: input, - }, - } - - data_ = &UpdateConversationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by ValidateDatadogApiKey. const ValidateDatadogApiKey_Operation = ` mutation ValidateDatadogApiKey ($input: ValidateDatadogApiKeyInput!) { diff --git a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql index 578dedde..1d1078a9 100644 --- a/internal/boundary/graphql/gen/queries/datadog_accounts.graphql +++ b/internal/boundary/graphql/gen/queries/datadog_accounts.graphql @@ -39,39 +39,18 @@ query GetDatadogAccountStatus($id: ID!) { id status { health - readyForUse - logEventCount - logEventAnalyzedCount - logServiceCount - logActiveServices - disabledServices - inactiveServices - okServices - policyPendingCount - policyApprovedCount - policyDismissedCount - serviceVolumePerHour - serviceCostPerHourVolumeUsd - logEventVolumePerHour - logEventBytesPerHour - logEventCostPerHourBytesUsd - logEventCostPerHourVolumeUsd - logEventCostPerHourUsd - estimatedVolumeReductionPerHour - estimatedBytesReductionPerHour - estimatedCostReductionPerHourBytesUsd - estimatedCostReductionPerHourVolumeUsd - estimatedCostReductionPerHourUsd - observedVolumePerHourBefore - observedVolumePerHourAfter - observedBytesPerHourBefore - observedBytesPerHourAfter - observedCostPerHourBeforeBytesUsd - observedCostPerHourBeforeVolumeUsd - observedCostPerHourBeforeUsd - observedCostPerHourAfterBytesUsd - observedCostPerHourAfterVolumeUsd - observedCostPerHourAfterUsd + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } } } } diff --git a/internal/boundary/graphql/gen/queries/services.graphql b/internal/boundary/graphql/gen/queries/services.graphql index b9f17d41..bfb1e56a 100644 --- a/internal/boundary/graphql/gen/queries/services.graphql +++ b/internal/boundary/graphql/gen/queries/services.graphql @@ -5,7 +5,6 @@ query ListServices { node { id name - description enabled createdAt updatedAt @@ -17,23 +16,27 @@ query ListServices { # Query to get detailed information about a specific service by ID query GetService($id: ID!) { - node(id: $id) { - ... on Service { - id - name - description - enabled - createdAt - updatedAt - account { - id - name - } - logEvents { + services(where: { id: $id }, first: 1) { + edges { + node { id name - description + enabled createdAt + updatedAt + account { + id + name + } + logEvents { + edges { + node { + id + name + createdAt + } + } + } } } } @@ -46,7 +49,6 @@ query GetServiceByName($name: String!) { node { id name - description enabled createdAt updatedAt diff --git a/internal/boundary/graphql/message_service.go b/internal/boundary/graphql/message_service.go deleted file mode 100644 index 9ad6e05f..00000000 --- a/internal/boundary/graphql/message_service.go +++ /dev/null @@ -1,166 +0,0 @@ -package graphql - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// Messages persists messages to the control plane for durability. -// This is separate from the Chat API - it only handles persistence, -// not inference. -type Messages interface { - CreateMessage(ctx context.Context, msg *domain.Message) error -} - -// MessageService handles message persistence via GraphQL. -type MessageService struct { - client Client - scope log.Scope -} - -var _ Messages = (*MessageService)(nil) - -// NewMessageService creates a new message service. -func NewMessageService(client Client, scope log.Scope) *MessageService { - return &MessageService{ - client: client, - scope: scope.Child("messages"), - } -} - -// CreateMessage persists a message to the control plane. -func (s *MessageService) CreateMessage(ctx context.Context, msg *domain.Message) error { - s.scope.Debug("persisting message", - "id", msg.ID.String(), - "conversationID", msg.ConversationID.String(), - "role", msg.Role, - ) - - content, err := toContentBlockInputs(msg.Content) - if err != nil { - return fmt.Errorf("convert content blocks: %w", err) - } - - input := gen.CreateMessageInput{ - Id: ptr(msg.ID.String()), - ConversationID: msg.ConversationID.String(), - Role: toMessageRole(msg.Role), - Content: content, - Model: ptr(msg.Model), - StopReason: toStopReason(msg.StopReason), - } - - _, err = s.client.CreateMessage(ctx, input) - if err != nil { - return fmt.Errorf("create message: %w", err) - } - - return nil -} - -func toMessageRole(role domain.Role) gen.MessageRole { - switch role { - case domain.RoleUser: - return gen.MessageRoleUser - case domain.RoleAssistant: - return gen.MessageRoleAssistant - default: - return gen.MessageRoleUser - } -} - -func toStopReason(reason string) *gen.MessageStopReason { - switch reason { - case "end_turn": - return ptr(gen.MessageStopReasonEndTurn) - case "tool_use": - return ptr(gen.MessageStopReasonToolUse) - default: - return nil // Don't send stopReason for user messages - } -} - -func toContentBlockInputs(blocks []domain.Block) ([]gen.ContentBlockInput, error) { - inputs := make([]gen.ContentBlockInput, 0, len(blocks)) - - for _, block := range blocks { - input, err := toContentBlockInput(block) - if err != nil { - return nil, err - } - inputs = append(inputs, input) - } - - return inputs, nil -} - -func toContentBlockInput(block domain.Block) (gen.ContentBlockInput, error) { - switch block.Type { - case domain.BlockTypeText: - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeText, - Text: &gen.TextBlockInput{ - Content: block.Text.Content, - }, - }, nil - - case domain.BlockTypeThinking: - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeThinking, - Thinking: &gen.ThinkingBlockInput{ - Content: block.Thinking.Content, - }, - }, nil - - case domain.BlockTypeToolUse: - input, err := rawJSONToMap(block.ToolUse.Input) - if err != nil { - return gen.ContentBlockInput{}, fmt.Errorf("tool_use block: %w", err) - } - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeToolUse, - ToolUse: &gen.ToolUseInput{ - Id: block.ToolUse.ID, - Name: block.ToolUse.Name, - Input: input, - }, - }, nil - - case domain.BlockTypeToolResult: - var content *map[string]any - if block.ToolResult.Content != nil { - content = &block.ToolResult.Content - } - toolResult := &gen.ToolResultInput{ - ToolUseId: block.ToolResult.ToolUseID, - IsError: block.ToolResult.IsError, - Content: content, - } - if block.ToolResult.Error != "" { - toolResult.Error = ptr(block.ToolResult.Error) - } - return gen.ContentBlockInput{ - Type: gen.ContentBlockTypeToolResult, - ToolResult: toolResult, - }, nil - - default: - return gen.ContentBlockInput{}, fmt.Errorf("unknown block type: %s", block.Type) - } -} - -func rawJSONToMap(data json.RawMessage) (map[string]any, error) { - if len(data) == 0 { - return nil, nil - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - return m, nil -} diff --git a/internal/boundary/graphql/message_service_test.go b/internal/boundary/graphql/message_service_test.go deleted file mode 100644 index 51ee88fd..00000000 --- a/internal/boundary/graphql/message_service_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package graphql_test - -import ( - "context" - "encoding/json" - "testing" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/boundary/graphql/gen" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" -) - -func TestMessageService_CreateMessage(t *testing.T) { - t.Parallel() - - t.Run("sends text block with correct content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - domain.NewTextBlock("Hello, world!"), - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if captured.Id == nil || *captured.Id != "msg-123" { - t.Errorf("Id = %v, want %q", captured.Id, "msg-123") - } - if captured.ConversationID != "conv-456" { - t.Errorf("ConversationID = %q, want %q", captured.ConversationID, "conv-456") - } - if captured.Role != gen.MessageRoleUser { - t.Errorf("Role = %v, want %v", captured.Role, gen.MessageRoleUser) - } - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - if captured.Content[0].Type != gen.ContentBlockTypeText { - t.Errorf("Content[0].Type = %v, want %v", captured.Content[0].Type, gen.ContentBlockTypeText) - } - if captured.Content[0].Text.Content != "Hello, world!" { - t.Errorf("Content[0].Text.Content = %q, want %q", captured.Content[0].Text.Content, "Hello, world!") - } - }) - - t.Run("sends assistant message with model and stop reason", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Model: "claude-3", - StopReason: "end_turn", - Content: []domain.Block{ - domain.NewTextBlock("Hello!"), - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if captured.Role != gen.MessageRoleAssistant { - t.Errorf("Role = %v, want %v", captured.Role, gen.MessageRoleAssistant) - } - if captured.Model == nil || *captured.Model != "claude-3" { - t.Errorf("Model = %v, want %q", captured.Model, "claude-3") - } - if captured.StopReason == nil || *captured.StopReason != gen.MessageStopReasonEndTurn { - t.Errorf("StopReason = %v, want %v", captured.StopReason, gen.MessageStopReasonEndTurn) - } - }) - - t.Run("sends tool_use block with raw input", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql": "SELECT * FROM logs"}`), - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if block.Type != gen.ContentBlockTypeToolUse { - t.Errorf("Type = %v, want %v", block.Type, gen.ContentBlockTypeToolUse) - } - if block.ToolUse.Id != "tool-1" { - t.Errorf("ToolUse.Id = %q, want %q", block.ToolUse.Id, "tool-1") - } - if block.ToolUse.Name != "query" { - t.Errorf("ToolUse.Name = %q, want %q", block.ToolUse.Name, "query") - } - sql, ok := block.ToolUse.Input["sql"].(string) - if !ok || sql != "SELECT * FROM logs" { - t.Errorf("ToolUse.Input[sql] = %v, want %q", block.ToolUse.Input["sql"], "SELECT * FROM logs") - } - }) - - t.Run("rejects unknown block types", func(t *testing.T) { - t.Parallel() - - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - t.Error("CreateMessage should not be called") - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleAssistant, - Content: []domain.Block{ - {Type: "unknown_type"}, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err == nil { - t.Fatal("CreateMessage() expected error for unknown block type, got nil") - } - }) - - t.Run("sends tool_result with raw content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{ - "columns": []any{"id", "name"}, - "rows": []any{[]any{"1", "foo"}}, - }, - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if block.Type != gen.ContentBlockTypeToolResult { - t.Errorf("Type = %v, want %v", block.Type, gen.ContentBlockTypeToolResult) - } - if block.ToolResult.ToolUseId != "tool-1" { - t.Errorf("ToolResult.ToolUseId = %q, want %q", block.ToolResult.ToolUseId, "tool-1") - } - if block.ToolResult.Content == nil { - t.Fatal("ToolResult.Content is nil") - } - cols, ok := (*block.ToolResult.Content)["columns"].([]any) - if !ok || len(cols) != 2 { - t.Errorf("ToolResult.Content[columns] = %v, want [id, name]", (*block.ToolResult.Content)["columns"]) - } - }) - - t.Run("sends tool_result error without content", func(t *testing.T) { - t.Parallel() - - var captured gen.CreateMessageInput - mockClient := &apitest.MockClient{ - CreateMessageFunc: func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) { - captured = input - return &gen.CreateMessageResponse{}, nil - }, - } - - svc := graphql.NewMessageService(mockClient, logtest.NewScope(t)) - - msg := &domain.Message{ - ID: "msg-123", - ConversationID: "conv-456", - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - IsError: true, - Error: "something went wrong", - }, - }, - }, - } - - err := svc.CreateMessage(context.Background(), msg) - if err != nil { - t.Fatalf("CreateMessage() error = %v", err) - } - - if len(captured.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(captured.Content)) - } - block := captured.Content[0] - if !block.ToolResult.IsError { - t.Error("ToolResult.IsError = false, want true") - } - if block.ToolResult.Error == nil || *block.ToolResult.Error != "something went wrong" { - t.Errorf("ToolResult.Error = %v, want %q", block.ToolResult.Error, "something went wrong") - } - }) -} diff --git a/internal/boundary/graphql/organization_service.go b/internal/boundary/graphql/organization_service.go index 03066e0c..c256058d 100644 --- a/internal/boundary/graphql/organization_service.go +++ b/internal/boundary/graphql/organization_service.go @@ -38,11 +38,10 @@ func NewOrganizationService(client Client, scope log.Scope) *OrganizationService } } -// OrganizationBootstrapResult contains the organization, account, and workspace created during bootstrap. +// OrganizationBootstrapResult contains the organization and account created during bootstrap. type OrganizationBootstrapResult struct { Organization *domain.Organization Account *domain.Account - Workspace *domain.Workspace } // List fetches all organizations for the user. @@ -71,8 +70,7 @@ func (s *OrganizationService) List(ctx context.Context) ([]domain.Organization, // Create creates a new organization with bootstrapped account and workspace. func (s *OrganizationService) Create(ctx context.Context, input CreateOrganizationInput) (*OrganizationBootstrapResult, error) { s.scope.Debug("creating organization with bootstrap via API", "id", input.ID.String(), "name", input.Name) - genInput := gen.CreateOrganizationInput{ - Id: ptr(input.ID.String()), + genInput := gen.OrganizationCreateInput{ Name: input.Name, } @@ -93,15 +91,9 @@ func (s *OrganizationService) Create(ctx context.Context, input CreateOrganizati Name: resp.CreateOrganizationAndBootstrap.Account.Name, } - workspace := &domain.Workspace{ - ID: domain.WorkspaceID(resp.CreateOrganizationAndBootstrap.Workspace.Id), - Name: resp.CreateOrganizationAndBootstrap.Workspace.Name, - } - result := &OrganizationBootstrapResult{ Organization: org, Account: account, - Workspace: workspace, } s.scope.Debug("created organization via API", "id", org.ID, "name", org.Name, "accountID", account.ID) diff --git a/internal/boundary/graphql/organization_service_test.go b/internal/boundary/graphql/organization_service_test.go index 7cf35db8..4cc68b7b 100644 --- a/internal/boundary/graphql/organization_service_test.go +++ b/internal/boundary/graphql/organization_service_test.go @@ -20,13 +20,13 @@ func TestOrganizationService_List(t *testing.T) { ListOrganizationsFunc: func(ctx context.Context) (*gen.ListOrganizationsResponse, error) { return &gen.ListOrganizationsResponse{ Organizations: gen.ListOrganizationsOrganizationsOrganizationConnection{ - Edges: []*gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{ - {Node: &gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ + Edges: []gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{ + {Node: gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ Id: "org-1", Name: "Acme Corp", WorkosOrganizationID: "org_workos_123", }}, - {Node: &gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ + {Node: gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdgeNodeOrganization{ Id: "org-2", Name: "Beta Inc", WorkosOrganizationID: "org_workos_456", @@ -60,7 +60,7 @@ func TestOrganizationService_List(t *testing.T) { ListOrganizationsFunc: func(ctx context.Context) (*gen.ListOrganizationsResponse, error) { return &gen.ListOrganizationsResponse{ Organizations: gen.ListOrganizationsOrganizationsOrganizationConnection{ - Edges: []*gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{}, + Edges: []gen.ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge{}, }, }, nil }, @@ -101,9 +101,9 @@ func TestOrganizationService_Create(t *testing.T) { t.Parallel() t.Run("creates organization and returns bootstrap result", func(t *testing.T) { t.Parallel() - var capturedInput gen.CreateOrganizationInput + var capturedInput gen.OrganizationCreateInput mockClient := &apitest.MockClient{ - CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { + CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { capturedInput = input return &gen.CreateOrganizationAndBootstrapResponse{ CreateOrganizationAndBootstrap: gen.CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResult{ @@ -116,10 +116,6 @@ func TestOrganizationService_Create(t *testing.T) { Id: "acc-new", Name: "Default Account", }, - Workspace: gen.CreateOrganizationAndBootstrapCreateOrganizationAndBootstrapOrganizationBootstrapResultWorkspace{ - Id: "ws-new", - Name: "Default Workspace", - }, }, }, nil }, @@ -138,12 +134,6 @@ func TestOrganizationService_Create(t *testing.T) { if result.Account.ID != "acc-new" || result.Account.Name != "Default Account" { t.Errorf("account = %+v, want ID=acc-new, Name=Default Account", result.Account) } - if result.Workspace.ID != "ws-new" || result.Workspace.Name != "Default Workspace" { - t.Errorf("workspace = %+v, want ID=ws-new, Name=Default Workspace", result.Workspace) - } - if capturedInput.Id == nil || *capturedInput.Id != testID.String() { - t.Errorf("input.Id = %v, want %q", capturedInput.Id, testID.String()) - } if capturedInput.Name != "New Org" { t.Errorf("input.Name = %q, want %q", capturedInput.Name, "New Org") } @@ -152,7 +142,7 @@ func TestOrganizationService_Create(t *testing.T) { t.Run("propagates client errors", func(t *testing.T) { t.Parallel() mockClient := &apitest.MockClient{ - CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.CreateOrganizationInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { + CreateOrganizationAndBootstrapFunc: func(ctx context.Context, input gen.OrganizationCreateInput) (*gen.CreateOrganizationAndBootstrapResponse, error) { return nil, errors.New("validation error") }, } diff --git a/internal/boundary/graphql/policy_service.go b/internal/boundary/graphql/policy_service.go index edc67220..cdc77b88 100644 --- a/internal/boundary/graphql/policy_service.go +++ b/internal/boundary/graphql/policy_service.go @@ -31,35 +31,17 @@ func NewPolicyService(client Client, scope log.Scope) *PolicyService { } // ApprovePolicy approves a log event policy. -func (s *PolicyService) ApprovePolicy(ctx context.Context, id string) error { - s.scope.Debug("approving policy via API", "id", id) - - _, err := s.client.ApproveLogEventPolicy(ctx, id) - if err != nil { - s.scope.Error("failed to approve policy", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return fmt.Errorf("approve policy %s: %w", id, classified) - } - return err - } - - s.scope.Debug("approved policy via API", "id", id) - return nil +// +// TODO(drop-powersync): the policy lifecycle moved to the Issue model in the +// control plane (createLogEventPolicy / ignoreIssue). Re-wire as an inline +// mutation in the writes step (task #5). +func (s *PolicyService) ApprovePolicy(_ context.Context, id string) error { + return fmt.Errorf("approve policy %s: not wired — moved to the issue model", id) } // DismissPolicy dismisses a log event policy. -func (s *PolicyService) DismissPolicy(ctx context.Context, id string) error { - s.scope.Debug("dismissing policy via API", "id", id) - - _, err := s.client.DismissLogEventPolicy(ctx, id) - if err != nil { - s.scope.Error("failed to dismiss policy", "error", err, "id", id) - if classified := classifyError(err); classified != nil { - return fmt.Errorf("dismiss policy %s: %w", id, classified) - } - return err - } - - s.scope.Debug("dismissed policy via API", "id", id) - return nil +// +// TODO(drop-powersync): see ApprovePolicy — moved to the Issue model. +func (s *PolicyService) DismissPolicy(_ context.Context, id string) error { + return fmt.Errorf("dismiss policy %s: not wired — moved to the issue model", id) } diff --git a/internal/boundary/graphql/services.go b/internal/boundary/graphql/services.go index 5bfc8c46..18caa838 100644 --- a/internal/boundary/graphql/services.go +++ b/internal/boundary/graphql/services.go @@ -17,8 +17,6 @@ type ServiceSet struct { Accounts Accounts Workspaces Workspaces DatadogAccounts DatadogAccounts - Conversations Conversations - Messages Messages Services Services Policies Policies } @@ -48,8 +46,6 @@ func newServiceSetWithScope(client Client, scope log.Scope) ServiceSet { Accounts: NewAccountService(client, scope), Workspaces: NewWorkspaceService(client, scope), DatadogAccounts: NewDatadogAccountService(client, scope), - Conversations: NewConversationService(client, scope), - Messages: NewMessageService(client, scope), Services: NewServiceService(client, scope), Policies: NewPolicyService(client, scope), } diff --git a/internal/boundary/graphql/workspace.go b/internal/boundary/graphql/workspace.go index 266a19bd..b1f6bc2f 100644 --- a/internal/boundary/graphql/workspace.go +++ b/internal/boundary/graphql/workspace.go @@ -29,24 +29,14 @@ func NewWorkspaceService(client Client, scope log.Scope) *WorkspaceService { } } -// List fetches all workspaces for an account. -func (s *WorkspaceService) List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - s.scope.Debug("fetching workspaces from API", "accountID", accountID) - resp, err := s.client.ListWorkspaces(ctx, accountID.String()) - if err != nil { - s.scope.Error("failed to fetch workspaces", "error", err, "accountID", accountID) - return nil, err - } - - // Convert GraphQL response to domain model - workspaces := make([]domain.Workspace, len(resp.Workspaces.Edges)) - for i, edge := range resp.Workspaces.Edges { - workspaces[i] = domain.Workspace{ - ID: domain.WorkspaceID(edge.Node.Id), - Name: edge.Node.Name, - } - } - - s.scope.Debug("fetched workspaces from API", "count", len(workspaces)) - return workspaces, nil +// List returns the workspaces for an account. +// +// TODO(drop-powersync): workspaces were removed from the control plane — the +// account is the working context now. As an interim, this returns a single +// synthetic workspace mirroring the account so the onboarding selection step is +// a no-op auto-select. The full workspace→account rename is task #7. +func (s *WorkspaceService) List(_ context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { + return []domain.Workspace{ + {ID: domain.WorkspaceID(accountID.String()), Name: "Default"}, + }, nil } diff --git a/internal/upload/conversation_handler.go b/internal/upload/conversation_handler.go deleted file mode 100644 index 8c5cb695..00000000 --- a/internal/upload/conversation_handler.go +++ /dev/null @@ -1,106 +0,0 @@ -package upload - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// Idempotent operation handling: -// - DELETE returning "not found" is success (resource already gone) -// - CREATE returning "already exists" is success (resource already there) - -// conversationHandler handles uploading conversations to the GraphQL API. -type conversationHandler struct { - conversations graphql.Conversations - scope log.Scope -} - -func newConversationHandler(conversations graphql.Conversations, scope log.Scope) *conversationHandler { - return &conversationHandler{ - conversations: conversations, - scope: scope.Child("conversations"), - } -} - -func (h *conversationHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - // Conversation handler doesn't emit events currently - _ = emit - - switch entry.Op { - case db.OpPut: - return h.handlePut(ctx, entry) - case db.OpPatch: - return h.handlePatch(ctx, entry) - case db.OpDelete: - return h.handleDelete(ctx, entry) - default: - h.scope.Warn("unknown conversation op", "op", entry.Op) - return nil - } -} - -func (h *conversationHandler) handlePut(ctx context.Context, entry *db.CrudEntry) error { - workspaceID, _ := entry.Data["workspace_id"].(string) - title, _ := entry.Data["title"].(string) - - id, err := uuid.Parse(entry.RowID) - if err != nil { - return fmt.Errorf("invalid conversation ID %q: %w", entry.RowID, err) - } - - _, err = h.conversations.Create(ctx, graphql.CreateConversationInput{ - ID: id, - WorkspaceID: domain.WorkspaceID(workspaceID), - Title: title, - }) - if err != nil { - // Already exists is fine - the conversation is there, which is what we wanted - if errors.Is(err, graphql.ErrAlreadyExists) { - h.scope.Debug("conversation already exists, skipping", "id", entry.RowID) - return nil - } - return fmt.Errorf("create conversation: %w", err) - } - - h.scope.Debug("uploaded conversation", "id", entry.RowID) - return nil -} - -func (h *conversationHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - var input graphql.UpdateConversationInput - - if titleVal, ok := entry.Data["title"]; ok { - title, _ := titleVal.(string) - input.Title = &title - } - - _, err := h.conversations.Update(ctx, domain.ConversationID(entry.RowID), input) - if err != nil { - return fmt.Errorf("update conversation: %w", err) - } - - h.scope.Debug("updated conversation", "id", entry.RowID) - return nil -} - -func (h *conversationHandler) handleDelete(ctx context.Context, entry *db.CrudEntry) error { - err := h.conversations.Delete(ctx, domain.ConversationID(entry.RowID)) - if err != nil { - // Not found is fine - the conversation is gone, which is what we wanted - if errors.Is(err, graphql.ErrNotFound) { - h.scope.Debug("conversation already deleted, skipping", "id", entry.RowID) - return nil - } - return fmt.Errorf("delete conversation: %w", err) - } - - h.scope.Debug("deleted conversation", "id", entry.RowID) - return nil -} diff --git a/internal/upload/conversation_handler_test.go b/internal/upload/conversation_handler_test.go deleted file mode 100644 index f426621d..00000000 --- a/internal/upload/conversation_handler_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package upload - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" -) - -func noopEmitter() Emitter { - return func(Event) {} -} - -func TestConversationHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("PUT creates conversation", func(t *testing.T) { - t.Parallel() - - testID := uuid.New() - var calledWith struct { - id uuid.UUID - workspaceID domain.WorkspaceID - title string - } - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - calledWith.id = input.ID - calledWith.workspaceID = input.WorkspaceID - calledWith.title = input.Title - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: testID.String(), - Data: map[string]any{ - "workspace_id": "ws-1", - "title": "Test Conversation", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if calledWith.id != testID { - t.Errorf("Create called with id = %v, want %v", calledWith.id, testID) - } - if calledWith.workspaceID != "ws-1" { - t.Errorf("Create called with workspaceID = %q, want %q", calledWith.workspaceID, "ws-1") - } - if calledWith.title != "Test Conversation" { - t.Errorf("Create called with title = %q, want %q", calledWith.title, "Test Conversation") - } - }) - - t.Run("PUT returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return nil, errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: uuid.New().String(), - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("PATCH updates conversation", func(t *testing.T) { - t.Parallel() - - var calledWith struct { - id domain.ConversationID - title string - } - - mock := &apitest.MockConversations{ - UpdateFunc: func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - calledWith.id = id - if input.Title != nil { - calledWith.title = *input.Title - } - return &domain.Conversation{ID: id, Title: calledWith.title}, nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "conv-1", - Data: map[string]any{ - "title": "Updated Title", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if calledWith.id != "conv-1" { - t.Errorf("Update called with id = %q, want %q", calledWith.id, "conv-1") - } - if calledWith.title != "Updated Title" { - t.Errorf("Update called with title = %q, want %q", calledWith.title, "Updated Title") - } - }) - - t.Run("PATCH returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - UpdateFunc: func(ctx context.Context, id domain.ConversationID, input graphql.UpdateConversationInput) (*domain.Conversation, error) { - return nil, errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("DELETE deletes conversation", func(t *testing.T) { - t.Parallel() - - var deletedID domain.ConversationID - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - deletedID = id - return nil - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if deletedID != "conv-1" { - t.Errorf("Delete called with id = %q, want %q", deletedID, "conv-1") - } - }) - - t.Run("DELETE returns error on failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - return errors.New("network error") - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("DELETE succeeds when resource not found", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - DeleteFunc: func(ctx context.Context, id domain.ConversationID) error { - // Service layer returns wrapped ErrNotFound - return fmt.Errorf("delete conversation %s: %w", id, graphql.ErrNotFound) - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for not found (idempotent delete)", err) - } - }) - - t.Run("PUT succeeds when resource already exists", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - // Service layer returns wrapped ErrAlreadyExists - return nil, fmt.Errorf("create conversation %s: %w", input.ID, graphql.ErrAlreadyExists) - }, - } - - h := newConversationHandler(mock, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: uuid.New().String(), - Data: map[string]any{ - "workspace_id": "ws-1", - "title": "Test", - }, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for already exists (idempotent create)", err) - } - }) - - t.Run("unknown op returns nil", func(t *testing.T) { - t.Parallel() - - h := newConversationHandler(apitest.NewMockConversations(), logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: "UNKNOWN", - RowID: "conv-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unknown op", err) - } - }) -} diff --git a/internal/upload/event.go b/internal/upload/event.go deleted file mode 100644 index 0e4e13e3..00000000 --- a/internal/upload/event.go +++ /dev/null @@ -1,39 +0,0 @@ -package upload - -import ( - "time" - - "github.com/usetero/cli/internal/sqlite" -) - -// Event is the interface for all upload events. -// Handlers can define their own event types that implement this interface. -type Event interface { - uploadEvent() -} - -// Core upload events - emitted by the uploader itself - -// SyncingEvent is emitted when entries are being processed. -type SyncingEvent struct { - ProcessedCount int -} - -func (SyncingEvent) uploadEvent() {} - -// StalledEvent is emitted when upload is blocked on a failing entry. -type StalledEvent struct { - Error error - Table sqlite.Table - RowID string - StalledFor time.Duration -} - -func (StalledEvent) uploadEvent() {} - -// RecoveredEvent is emitted when upload recovers from a stalled state. -type RecoveredEvent struct { - StalledFor time.Duration -} - -func (RecoveredEvent) uploadEvent() {} diff --git a/internal/upload/handler.go b/internal/upload/handler.go deleted file mode 100644 index 9560abbd..00000000 --- a/internal/upload/handler.go +++ /dev/null @@ -1,15 +0,0 @@ -package upload - -import ( - "context" - - "github.com/usetero/cli/internal/powersync/db" -) - -// Handler processes CRUD entries for a specific table. -type Handler interface { - Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error -} - -// Emitter is a function that handlers use to emit events. -type Emitter func(Event) diff --git a/internal/upload/message_handler.go b/internal/upload/message_handler.go deleted file mode 100644 index 62992b9c..00000000 --- a/internal/upload/message_handler.go +++ /dev/null @@ -1,111 +0,0 @@ -package upload - -// Message Upload Flow -// -// The upload queue handles DURABILITY ONLY. It persists messages to the -// control plane via GraphQL so they survive device loss and sync across devices. -// -// The upload queue does NOT call the Chat API. The TUI calls the Chat API -// directly with the full message history and streams the response. -// -// Flow: -// 1. User submits message in TUI -// 2. TUI writes user message to SQLite (creates ps_crud PUT entry) -// 3. TUI calls Chat API directly, streams response -// 4. TUI writes assistant message to SQLite as deltas arrive -// 5. Upload queue (background) persists messages to GraphQL -// -// For user messages: upload on PUT (initial create) -// For assistant messages: upload when complete (entry contains stop_reason) - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/sqlite" -) - -// messageHandler persists messages to the control plane via GraphQL. -type messageHandler struct { - messages messageMutations - db sqlite.DB - scope log.Scope -} - -func newMessageHandler(messages messageMutations, db sqlite.DB, scope log.Scope) *messageHandler { - return &messageHandler{ - messages: messages, - db: db, - scope: scope.Child("messages"), - } -} - -// Handle persists a message to the control plane. -func (h *messageHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - switch entry.Op { - case db.OpPut, db.OpPatch: - return h.handlePutOrPatch(ctx, entry) - case db.OpDelete: - // Messages are deleted via conversation deletion, not individually. - h.scope.Debug("skipping message DELETE", "id", entry.RowID) - return nil - default: - h.scope.Warn("unknown message op", "op", entry.Op) - return nil - } -} - -func (h *messageHandler) handlePutOrPatch(ctx context.Context, entry *db.CrudEntry) error { - msg, err := h.db.Messages().Get(ctx, domain.MessageID(entry.RowID)) - if err != nil { - // Message may have been deleted, skip - h.scope.Debug("message not found, skipping", "id", entry.RowID, "error", err) - return nil - } - - switch msg.Role { - case domain.RoleUser: - // User messages: persist on PUT (initial create) - if entry.Op == db.OpPut { - return h.persistUserMessage(ctx, entry, msg) - } - h.scope.Debug("skipping user message PATCH", "id", entry.RowID) - return nil - - case domain.RoleAssistant: - // Assistant messages: persist when complete (entry sets stop_reason) - _, hasStopReason := entry.Data["stop_reason"] - if !hasStopReason { - h.scope.Debug("skipping assistant message (incomplete)", "id", entry.RowID, "op", entry.Op) - return nil - } - return h.persistAssistantMessage(ctx, msg) - - default: - h.scope.Warn("unknown message role", "role", msg.Role) - return nil - } -} - -func (h *messageHandler) persistUserMessage(ctx context.Context, entry *db.CrudEntry, msg *domain.Message) error { - err := h.messages.CreateMessage(ctx, msg) - if err != nil { - return fmt.Errorf("persist user message: %w", err) - } - - h.scope.Debug("persisted user message", "id", entry.RowID) - return nil -} - -func (h *messageHandler) persistAssistantMessage(ctx context.Context, msg *domain.Message) error { - err := h.messages.CreateMessage(ctx, msg) - if err != nil { - return fmt.Errorf("persist assistant message: %w", err) - } - - h.scope.Debug("persisted assistant message", "id", msg.ID) - return nil -} diff --git a/internal/upload/message_handler_test.go b/internal/upload/message_handler_test.go deleted file mode 100644 index 3c62c154..00000000 --- a/internal/upload/message_handler_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package upload - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" -) - -func TestMessageHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("persists user message on PUT", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - var captured *domain.Message - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - captured = msg - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "user", - "conversation_id": "conv-1", - "account_id": "acc-1", - "content": `[{"type":"text","text":{"content":"Hello"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if captured == nil { - t.Fatal("expected CreateMessage to be called") - } - if captured.ID != "msg-1" { - t.Errorf("id = %q, want %q", captured.ID, "msg-1") - } - if captured.ConversationID != "conv-1" { - t.Errorf("conversationID = %q, want %q", captured.ConversationID, "conv-1") - } - if captured.Role != domain.RoleUser { - t.Errorf("role = %q, want %q", captured.Role, domain.RoleUser) - } - }) - - t.Run("skips user message on PATCH", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - called := false - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - called = true - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "content": `[{"type":"text","text":{"content":"Hello updated"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if called { - t.Error("expected CreateMessage to not be called on PATCH for user message") - } - }) - - t.Run("persists assistant message when stop_reason is set", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, model, stop_reason, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hi"}}]', 'assistant', 'claude-3', 'end_turn', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - var captured *domain.Message - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - captured = msg - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "stop_reason": "end_turn", - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if captured == nil { - t.Fatal("expected CreateMessage to be called") - } - if captured.ID != "msg-1" { - t.Errorf("id = %q, want %q", captured.ID, "msg-1") - } - if captured.Model != "claude-3" { - t.Errorf("model = %q, want %q", captured.Model, "claude-3") - } - if captured.StopReason != "end_turn" { - t.Errorf("stopReason = %q, want %q", captured.StopReason, "end_turn") - } - }) - - t.Run("skips assistant message without stop_reason", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, model, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[]', 'assistant', 'claude-3', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - called := false - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - called = true - return nil - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "msg-1", - Data: map[string]any{ - "content": `[{"type":"text","text":{"content":"partial"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if called { - t.Error("expected CreateMessage to not be called without stop_reason") - } - }) - - t.Run("returns error when persist fails", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[{"type":"text","text":{"content":"Hello"}}]', 'user', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - mock := &apitest.MockMessages{ - CreateMessageFunc: func(ctx context.Context, msg *domain.Message) error { - return errors.New("network error") - }, - } - - h := newMessageHandler(mock, testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "user", - "conversation_id": "conv-1", - "content": `[{"type":"text","text":{"content":"Hello"}}]`, - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("skips DELETE", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - h := newMessageHandler(apitest.NewMockMessages(), testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "msg-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil", err) - } - }) - - t.Run("skips unknown role", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - _, err := testDB.Exec(context.Background(), - `INSERT INTO messages (id, account_id, conversation_id, content, role, created_at) - VALUES ('msg-1', 'acc-1', 'conv-1', '[]', 'system', '2024-01-01T00:00:00Z')`) - if err != nil { - t.Fatalf("insert message: %v", err) - } - - h := newMessageHandler(apitest.NewMockMessages(), testDB, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "msg-1", - Data: map[string]any{ - "role": "system", - }, - } - - err = h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil", err) - } - }) -} diff --git a/internal/upload/policy_handler.go b/internal/upload/policy_handler.go deleted file mode 100644 index 1e6aee4e..00000000 --- a/internal/upload/policy_handler.go +++ /dev/null @@ -1,46 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// policyHandler handles uploading log event policy mutations to the GraphQL API. -type policyHandler struct { - policies graphql.Policies - scope log.Scope -} - -func newPolicyHandler(policies graphql.Policies, scope log.Scope) *policyHandler { - return &policyHandler{ - policies: policies, - scope: scope.Child("policies"), - } -} - -func (h *policyHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - _ = emit - - switch entry.Op { - case db.OpPatch: - return h.handlePatch(ctx, entry) - default: - h.scope.Warn("unsupported policy op, dropping", "op", entry.Op, "rowId", entry.RowID) - return nil - } -} - -func (h *policyHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - if _, ok := entry.Data["approved_at"]; ok { - return h.policies.ApprovePolicy(ctx, entry.RowID) - } - if _, ok := entry.Data["dismissed_at"]; ok { - return h.policies.DismissPolicy(ctx, entry.RowID) - } - - h.scope.Warn("no mutation for patched fields, dropping", "rowId", entry.RowID, "fields", fieldNames(entry.Data)) - return nil -} diff --git a/internal/upload/ports.go b/internal/upload/ports.go deleted file mode 100644 index 74285281..00000000 --- a/internal/upload/ports.go +++ /dev/null @@ -1,21 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" -) - -// MutationDeps groups GraphQL mutation dependencies needed by the uploader. -type MutationDeps struct { - Conversations graphql.Conversations - Messages graphql.Messages - Services graphql.Services - Policies graphql.Policies -} - -// Local ports used by upload handlers. -type messageMutations interface { - CreateMessage(ctx context.Context, message *domain.Message) error -} diff --git a/internal/upload/service_handler.go b/internal/upload/service_handler.go deleted file mode 100644 index f4886d06..00000000 --- a/internal/upload/service_handler.go +++ /dev/null @@ -1,77 +0,0 @@ -package upload - -import ( - "context" - - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" -) - -// serviceHandler handles uploading service mutations to the GraphQL API. -// Services are server-owned — the client only patches them (e.g. enable/disable). -type serviceHandler struct { - services graphql.Services - scope log.Scope -} - -func newServiceHandler(services graphql.Services, scope log.Scope) *serviceHandler { - return &serviceHandler{ - services: services, - scope: scope.Child("services"), - } -} - -func (h *serviceHandler) Handle(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - _ = emit - - switch entry.Op { - case db.OpPatch: - return h.handlePatch(ctx, entry) - default: - h.scope.Warn("unsupported service op, dropping", "op", entry.Op, "rowId", entry.RowID) - return nil - } -} - -func (h *serviceHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { - if enabledVal, ok := entry.Data["enabled"]; ok { - id := domain.ServiceID(entry.RowID) - if toBool(enabledVal) { - return h.services.EnableService(ctx, id) - } - return h.services.DisableService(ctx, id) - } - - // No mutation available for the patched fields - h.scope.Warn("no mutation for patched fields, dropping", "rowId", entry.RowID, "fields", fieldNames(entry.Data)) - return nil -} - -// toBool converts a value from the CRUD entry data to a boolean. -// SQLite stores booleans as integers (0/1), and JSON decoding may -// produce float64 or bool depending on the source. -func toBool(v any) bool { - switch val := v.(type) { - case bool: - return val - case float64: - return val != 0 - case int64: - return val != 0 - case int: - return val != 0 - default: - return false - } -} - -// fieldNames returns the keys of a map for logging. -func fieldNames(data map[string]any) []string { - names := make([]string, 0, len(data)) - for k := range data { - names = append(names, k) - } - return names -} diff --git a/internal/upload/service_handler_test.go b/internal/upload/service_handler_test.go deleted file mode 100644 index 48101354..00000000 --- a/internal/upload/service_handler_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package upload - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" -) - -func TestServiceHandler_Handle(t *testing.T) { - t.Parallel() - - t.Run("PATCH enabled=true calls EnableService", func(t *testing.T) { - t.Parallel() - - var enabledID domain.ServiceID - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - enabledID = serviceID - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"enabled": true}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if enabledID != "svc-1" { - t.Errorf("EnableService called with %q, want %q", enabledID, "svc-1") - } - }) - - t.Run("PATCH enabled=false calls DisableService", func(t *testing.T) { - t.Parallel() - - var disabledID domain.ServiceID - mock := &apitest.MockAPIServiceServices{ - DisableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - disabledID = serviceID - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-2", - Data: map[string]any{"enabled": false}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if disabledID != "svc-2" { - t.Errorf("DisableService called with %q, want %q", disabledID, "svc-2") - } - }) - - t.Run("PATCH enabled as int 1 calls EnableService", func(t *testing.T) { - t.Parallel() - - var called bool - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - called = true - return nil - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-3", - Data: map[string]any{"enabled": float64(1)}, // JSON decodes numbers as float64 - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - if !called { - t.Error("expected EnableService to be called") - } - }) - - t.Run("PATCH returns error on API failure", func(t *testing.T) { - t.Parallel() - - mock := &apitest.MockAPIServiceServices{ - EnableServiceFunc: func(ctx context.Context, serviceID domain.ServiceID) error { - return errors.New("network error") - }, - } - - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"enabled": true}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err == nil { - t.Error("Handle() expected error, got nil") - } - }) - - t.Run("PATCH without enabled field drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPatch, - RowID: "svc-1", - Data: map[string]any{"name": "updated-name"}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unhandled fields", err) - } - }) - - t.Run("PUT drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpPut, - RowID: "svc-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unsupported op", err) - } - }) - - t.Run("DELETE drops silently", func(t *testing.T) { - t.Parallel() - - mock := apitest.NewMockAPIServiceServices() - h := newServiceHandler(mock, logtest.NewScope(t)) - entry := &db.CrudEntry{ - Op: db.OpDelete, - RowID: "svc-1", - Data: map[string]any{}, - } - - err := h.Handle(context.Background(), entry, noopEmitter()) - if err != nil { - t.Errorf("Handle() error = %v, want nil for unsupported op", err) - } - }) -} diff --git a/internal/upload/uploader.go b/internal/upload/uploader.go deleted file mode 100644 index e5ccf689..00000000 --- a/internal/upload/uploader.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package upload handles uploading local changes to the backend. -package upload - -import ( - "context" - "fmt" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/sqlite" -) - -const ( - defaultPollInterval = 100 * time.Millisecond - defaultRetryDelay = 1 * time.Second - defaultMaxRetries = 3 -) - -// TokenRefresher provides access tokens for authentication. -type TokenRefresher interface { - GetAccessToken(ctx context.Context) (string, error) -} - -// Uploader watches the CRUD queue and uploads changes to the backend. -type Uploader interface { - Run(ctx context.Context) error - Events() <-chan Event -} - -// BatchCompletedHook is called after a batch is atomically completed in SQLite. -// Errors are logged and do not fail the upload cycle. -type BatchCompletedHook func(ctx context.Context) error - -// Option configures uploader behavior. -type Option func(*uploader) - -// WithBatchCompletedHook installs a callback invoked after each successful batch completion. -func WithBatchCompletedHook(hook BatchCompletedHook) Option { - return func(u *uploader) { - u.batchCompletedHook = hook - } -} - -// uploader is the concrete implementation of Uploader. -type uploader struct { - db sqlite.DB - queue *db.CrudQueue - client psapi.Client - tokenRefresher TokenRefresher - handlers map[sqlite.Table]Handler - scope log.Scope - - // Configuration - pollInterval time.Duration - retryDelay time.Duration - maxRetries int - - // Event channel for status updates - events chan Event - - // State tracking - stalledSince *time.Time - stalledEntry *db.CrudEntry - - batchCompletedHook BatchCompletedHook -} - -// New creates a new uploader. -func New( - database sqlite.DB, - client psapi.Client, - tokenRefresher TokenRefresher, - mutations MutationDeps, - scope log.Scope, - opts ...Option, -) Uploader { - scope = scope.Child("upload") - u := &uploader{ - db: database, - queue: db.NewCrudQueue(database), - client: client, - tokenRefresher: tokenRefresher, - handlers: map[sqlite.Table]Handler{ - sqlite.TableConversations: newConversationHandler(mutations.Conversations, scope), - sqlite.TableMessages: newMessageHandler(mutations.Messages, database, scope), - sqlite.TableServices: newServiceHandler(mutations.Services, scope), - sqlite.TableLogEventPolicies: newPolicyHandler(mutations.Policies, scope), - }, - scope: scope, - pollInterval: defaultPollInterval, - retryDelay: defaultRetryDelay, - maxRetries: defaultMaxRetries, - events: make(chan Event, 10), - } - for _, opt := range opts { - opt(u) - } - return u -} - -// Events returns the channel for receiving upload status events. -func (u *uploader) Events() <-chan Event { - return u.events -} - -// Run starts the upload loop. It blocks until the context is cancelled. -func (u *uploader) Run(ctx context.Context) error { - u.scope.Info("upload loop started") - defer func() { - u.scope.Info("upload loop stopped") - close(u.events) - }() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Process all pending entries - processed, err := u.uploadAll(ctx) - if err != nil { - u.handleError(ctx, err) - u.wait(ctx, u.retryDelay) - continue - } - - // Clear stalled state on success - if u.stalledSince != nil { - stalledFor := time.Since(*u.stalledSince) - u.scope.Info("upload queue recovered", "stalledFor", stalledFor.Round(time.Second)) - u.emit(ctx, RecoveredEvent{StalledFor: stalledFor}) - u.stalledSince = nil - u.stalledEntry = nil - } - - if processed > 0 { - u.emit(ctx, SyncingEvent{ProcessedCount: processed}) - continue - } - - u.wait(ctx, u.pollInterval) - } -} - -// uploadAll uploads the next pending CRUD transaction following the PowerSync protocol: -// 1. Read entries from queue -// 2. Upload each to backend -// 3. Fetch write checkpoint -// 4. Complete batch atomically -func (u *uploader) uploadAll(ctx context.Context) (int, error) { - entries, err := u.queue.GetNextTransaction(ctx) - if err != nil { - return 0, fmt.Errorf("get next transaction: %w", err) - } - if len(entries) == 0 { - return 0, nil - } - - token, err := u.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return 0, fmt.Errorf("get access token: %w", err) - } - u.client.SetToken(token) - - // Upload each entry to backend - emit := u.emitter(ctx) - for i := range entries { - if err := u.uploadEntry(ctx, &entries[i], emit); err != nil { - return 0, err - } - } - - // Get write checkpoint from PowerSync server - clientID, err := db.GetClientID(ctx, u.db) - if err != nil { - return 0, fmt.Errorf("get client id: %w", err) - } - - checkpoint, err := u.client.GetWriteCheckpoint(ctx, clientID) - if err != nil { - return 0, fmt.Errorf("get write checkpoint: %w", err) - } - - // Complete batch atomically - lastID := entries[len(entries)-1].ID - if err := db.CompleteBatch(ctx, u.db, lastID, checkpoint); err != nil { - return 0, fmt.Errorf("complete batch: %w", err) - } - if u.batchCompletedHook != nil { - if err := u.batchCompletedHook(ctx); err != nil { - u.scope.Warn("batch completion hook failed", "error", err) - } - } - - u.scope.Debug("completed batch", "count", len(entries), "checkpoint", checkpoint) - return len(entries), nil -} - -// uploadEntry uploads a single entry with retries. -func (u *uploader) uploadEntry(ctx context.Context, entry *db.CrudEntry, emit Emitter) error { - handler, ok := u.handlers[entry.Table] - if !ok { - u.scope.Warn("no handler for table, skipping", "table", entry.Table, "rowId", entry.RowID) - return nil - } - - var lastErr error - for attempt := 0; attempt <= u.maxRetries; attempt++ { - if attempt > 0 { - u.scope.Debug("retrying upload", "table", entry.Table, "rowId", entry.RowID, "attempt", attempt) - u.wait(ctx, u.retryDelay*time.Duration(attempt)) - } - - err := handler.Handle(ctx, entry, emit) - if err == nil { - u.scope.Debug("uploaded entry", "table", entry.Table, "rowId", entry.RowID, "op", entry.Op) - return nil - } - - lastErr = err - u.scope.Warn("upload failed", "table", entry.Table, "rowId", entry.RowID, "attempt", attempt, "error", err) - } - - u.stalledEntry = entry - return lastErr -} - -func (u *uploader) handleError(ctx context.Context, err error) { - if u.stalledSince == nil { - now := time.Now() - u.stalledSince = &now - u.scope.Warn("upload queue stalled", "error", err) - } - - u.emit(ctx, StalledEvent{ - Error: err, - Table: u.stalledTable(), - RowID: u.stalledRowID(), - StalledFor: time.Since(*u.stalledSince), - }) -} - -func (u *uploader) stalledTable() sqlite.Table { - if u.stalledEntry != nil { - return u.stalledEntry.Table - } - return "" -} - -func (u *uploader) stalledRowID() string { - if u.stalledEntry != nil { - return u.stalledEntry.RowID - } - return "" -} - -func (u *uploader) emit(ctx context.Context, event Event) { - select { - case u.events <- event: - case <-ctx.Done(): - default: - } -} - -func (u *uploader) emitter(ctx context.Context) Emitter { - return func(event Event) { u.emit(ctx, event) } -} - -func (u *uploader) wait(ctx context.Context, d time.Duration) { - select { - case <-ctx.Done(): - case <-time.After(d): - } -} diff --git a/internal/upload/uploader_test.go b/internal/upload/uploader_test.go deleted file mode 100644 index 6e459349..00000000 --- a/internal/upload/uploader_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package upload_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - psapitest "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/upload" -) - -func TestUploader_Run(t *testing.T) { - t.Parallel() - - t.Run("returns on context cancellation", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := uploader.Run(ctx) - if !errors.Is(err, context.Canceled) { - t.Errorf("Run() error = %v, want context.Canceled", err) - } - }) - - t.Run("closes event channel on exit", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - cancel() - <-done - - // Drain any pending events, then verify channel is closed - for { - _, ok := <-uploader.Events() - if !ok { - break - } - } - }) - - t.Run("processes entry and completes batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - select { - case event := <-uploader.Events(): - syncEvent, ok := event.(upload.SyncingEvent) - if !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } else if syncEvent.ProcessedCount != 1 { - t.Errorf("expected ProcessedCount=1, got %d", syncEvent.ProcessedCount) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - - queue := db.NewCrudQueue(testDB) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Error("entry should be deleted after successful upload") - } - }) - - t.Run("processes one transaction per upload cycle", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID1 := uuid.New().String() - convID2 := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID1+`","data":{"workspace_id":"ws-1","title":"First"}}`) - dbtest.InsertCrudEntry(t, testDB, 2, nil, `{"op":"PUT","type":"conversations","id":"`+convID2+`","data":{"workspace_id":"ws-1","title":"Second"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - syncEvent, ok := event.(upload.SyncingEvent) - if !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - if syncEvent.ProcessedCount != 1 { - t.Fatalf("expected ProcessedCount=1 for single transaction cycle, got %d", syncEvent.ProcessedCount) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) - - t.Run("invokes batch completed hook after successful batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - hookCalled := make(chan struct{}, 1) - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - upload.WithBatchCompletedHook(func(context.Context) error { - select { - case hookCalled <- struct{}{}: - default: - } - return nil - }), - ) - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case <-hookCalled: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for batch completed hook") - } - - cancel() - <-done - }) - - t.Run("batch completed hook error is non-fatal", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - upload.WithBatchCompletedHook(func(context.Context) error { - return errors.New("hook failed") - }), - ) - - runCtx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) - - t.Run("skips unknown tables and completes batch", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"unknown_table","id":"row-1","data":{}}`) - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: apitest.NewMockConversations(), - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - - queue := db.NewCrudQueue(testDB) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Error("unknown table entry should be deleted after batch completes") - } - }) - - t.Run("emits stalled event on failure and recovered on success", func(t *testing.T) { - t.Parallel() - - testDB := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := testDB.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, testDB, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Test"}}`) - - callCount := 0 - conversations := &apitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - callCount++ - if callCount <= 4 { - return nil, errors.New("temporary error") - } - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - testDB, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: apitest.NewMockMessages(), - Services: apitest.NewMockAPIServiceServices(), - Policies: apitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - done := make(chan error) - go func() { - done <- uploader.Run(ctx) - }() - - // Stalled event after retries exhausted - select { - case event := <-uploader.Events(): - stalledEvent, ok := event.(upload.StalledEvent) - if !ok { - t.Errorf("expected StalledEvent, got %T", event) - } else { - if stalledEvent.Error == nil { - t.Error("expected error in stalled event") - } - if stalledEvent.Table != "conversations" { - t.Errorf("expected table=conversations, got %s", stalledEvent.Table) - } - } - case <-time.After(10 * time.Second): - t.Fatal("timeout waiting for stalled event") - } - - // Recovered event after success - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.RecoveredEvent); !ok { - t.Errorf("expected RecoveredEvent, got %T", event) - } - case <-time.After(10 * time.Second): - t.Fatal("timeout waiting for recovered event") - } - - // Syncing event - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Errorf("expected SyncingEvent, got %T", event) - } - case <-time.After(time.Second): - t.Fatal("timeout waiting for syncing event") - } - - cancel() - <-done - }) -} From c5c7ddab18f38f20e2274fdbc16d681e62b5ede2 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 10:25:54 -0700 Subject: [PATCH 06/20] feat: add GraphQL reads for issues, checks, and edge instances Add genqlient operations, domain types, and services for the product surfaces that previously read from the local PowerSync projection: - GetIssueSummary: active-issue count plus server-computed priority facet - ListChecks: product-check catalog with account-scoped posture and per-domain (cost/compliance) counts - ListEdgeInstances: edge fleet with total and last-sync recency All counts come from control-plane aggregates; the CLI never sums rows locally. Wires the new services into ServiceSet with table tests. --- .../boundary/graphql/apitest/mock_client.go | 24 + internal/boundary/graphql/check_service.go | 79 +++ .../boundary/graphql/check_service_test.go | 95 ++++ internal/boundary/graphql/client.go | 31 ++ .../boundary/graphql/edge_instance_service.go | 62 +++ .../graphql/edge_instance_service_test.go | 81 +++ internal/boundary/graphql/gen/generated.go | 473 ++++++++++++++++++ .../graphql/gen/queries/checks.graphql | 33 ++ .../gen/queries/edge_instances.graphql | 16 + .../graphql/gen/queries/issues.graphql | 19 + internal/boundary/graphql/issue_service.go | 60 +++ .../boundary/graphql/issue_service_test.go | 67 +++ internal/boundary/graphql/services.go | 6 + internal/domain/check.go | 43 ++ internal/domain/edge_instance.go | 32 ++ internal/domain/issue.go | 26 + 16 files changed, 1147 insertions(+) create mode 100644 internal/boundary/graphql/check_service.go create mode 100644 internal/boundary/graphql/check_service_test.go create mode 100644 internal/boundary/graphql/edge_instance_service.go create mode 100644 internal/boundary/graphql/edge_instance_service_test.go create mode 100644 internal/boundary/graphql/gen/queries/checks.graphql create mode 100644 internal/boundary/graphql/gen/queries/edge_instances.graphql create mode 100644 internal/boundary/graphql/gen/queries/issues.graphql create mode 100644 internal/boundary/graphql/issue_service.go create mode 100644 internal/boundary/graphql/issue_service_test.go create mode 100644 internal/domain/check.go create mode 100644 internal/domain/edge_instance.go create mode 100644 internal/domain/issue.go diff --git a/internal/boundary/graphql/apitest/mock_client.go b/internal/boundary/graphql/apitest/mock_client.go index db8fdf4f..f909de77 100644 --- a/internal/boundary/graphql/apitest/mock_client.go +++ b/internal/boundary/graphql/apitest/mock_client.go @@ -23,6 +23,9 @@ type MockClient struct { GetDatadogAccountStatusFunc func(ctx context.Context, id string) (*gen.GetDatadogAccountStatusResponse, error) EnableServiceFunc func(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableServiceFunc func(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) + GetIssueSummaryFunc func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListChecksFunc func(ctx context.Context) (*gen.ListChecksResponse, error) + ListEdgeInstancesFunc func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -122,3 +125,24 @@ func (m *MockClient) DisableService(ctx context.Context, serviceID string) (*gen } return nil, nil } + +func (m *MockClient) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + if m.GetIssueSummaryFunc != nil { + return m.GetIssueSummaryFunc(ctx) + } + return nil, nil +} + +func (m *MockClient) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { + if m.ListChecksFunc != nil { + return m.ListChecksFunc(ctx) + } + return nil, nil +} + +func (m *MockClient) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + if m.ListEdgeInstancesFunc != nil { + return m.ListEdgeInstancesFunc(ctx) + } + return nil, nil +} diff --git a/internal/boundary/graphql/check_service.go b/internal/boundary/graphql/check_service.go new file mode 100644 index 00000000..28dd5216 --- /dev/null +++ b/internal/boundary/graphql/check_service.go @@ -0,0 +1,79 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// Checks provides access to the code-defined product check catalog. +type Checks interface { + List(ctx context.Context) (domain.CheckCatalog, error) +} + +// CheckService reads product checks and their account-scoped posture from the +// control plane. +type CheckService struct { + client Client + scope log.Scope +} + +var _ Checks = (*CheckService)(nil) + +// NewCheckService creates a new check service. +func NewCheckService(client Client, scope log.Scope) *CheckService { + return &CheckService{ + client: client, + scope: scope.Child("checks"), + } +} + +// List fetches all product checks with their posture and the server-computed +// per-domain counts. +func (s *CheckService) List(ctx context.Context) (domain.CheckCatalog, error) { + s.scope.Debug("fetching checks from API") + resp, err := s.client.ListChecks(ctx) + if err != nil { + s.scope.Error("failed to fetch checks", "error", err) + return domain.CheckCatalog{}, err + } + + catalog := domain.CheckCatalog{ + Total: int64(resp.Checks.TotalCount), + Checks: make([]domain.Check, 0, len(resp.Checks.Edges)), + ByDomain: make(map[domain.CheckDomain]int64), + } + for _, edge := range resp.Checks.Edges { + node := edge.Node + catalog.Checks = append(catalog.Checks, domain.Check{ + ID: node.Id, + Name: node.Name, + Domain: checkDomain(node.Domain), + OpenFindingCount: int64(node.Posture.OpenFindingCount), + PendingFindingCount: int64(node.Posture.PendingFindingCount), + EscalatedFindingCount: int64(node.Posture.EscalatedFindingCount), + ActiveIssueCount: int64(node.Posture.ActiveIssueCount), + AffectedServiceCount: int64(node.Posture.AffectedServiceCount), + CurrentCostPerHour: node.Posture.Current.TotalUsdPerHour, + }) + } + for _, bucket := range resp.Checks.Facets.Domains.Buckets { + catalog.ByDomain[checkDomain(bucket.Value)] = int64(bucket.Count) + } + + s.scope.Debug("fetched checks", log.Int("count", len(catalog.Checks))) + return catalog, nil +} + +func checkDomain(d gen.FindingCheckDomain) domain.CheckDomain { + switch d { + case gen.FindingCheckDomainCost: + return domain.CheckDomainCost + case gen.FindingCheckDomainCompliance: + return domain.CheckDomainCompliance + default: + return domain.CheckDomain(d) + } +} diff --git a/internal/boundary/graphql/check_service_test.go b/internal/boundary/graphql/check_service_test.go new file mode 100644 index 00000000..580c580c --- /dev/null +++ b/internal/boundary/graphql/check_service_test.go @@ -0,0 +1,95 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestCheckService_List(t *testing.T) { + t.Parallel() + t.Run("maps checks, posture, and domain facets", func(t *testing.T) { + t.Parallel() + cost := 12.5 + mockClient := &apitest.MockClient{ + ListChecksFunc: func(ctx context.Context) (*gen.ListChecksResponse, error) { + return &gen.ListChecksResponse{ + Checks: gen.ListChecksChecksCheckConnection{ + TotalCount: 2, + Edges: []gen.ListChecksChecksCheckConnectionEdgesCheckEdge{ + {Node: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck{ + Id: "chk-1", + Name: "Debug noise", + Domain: gen.FindingCheckDomainCost, + Posture: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture{ + OpenFindingCount: 5, + PendingFindingCount: 3, + ActiveIssueCount: 2, + AffectedServiceCount: 4, + Current: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals{ + TotalUsdPerHour: &cost, + }, + }, + }}, + {Node: gen.ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck{ + Id: "chk-2", + Name: "PII exposure", + Domain: gen.FindingCheckDomainCompliance, + }}, + }, + Facets: gen.ListChecksChecksCheckConnectionFacetsCheckFacets{ + Domains: gen.ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet{ + Buckets: []gen.ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket{ + {Value: gen.FindingCheckDomainCost, Count: 1}, + {Value: gen.FindingCheckDomainCompliance, Count: 1}, + }, + }, + }, + }, + }, nil + }, + } + + svc := graphql.NewCheckService(mockClient, logtest.NewScope(t)) + catalog, err := svc.List(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if catalog.Total != 2 || len(catalog.Checks) != 2 { + t.Fatalf("catalog = %+v, want total=2 and 2 checks", catalog) + } + first := catalog.Checks[0] + if first.ID != "chk-1" || first.Domain != domain.CheckDomainCost || first.OpenFindingCount != 5 { + t.Errorf("first check = %+v", first) + } + if first.CurrentCostPerHour == nil || *first.CurrentCostPerHour != 12.5 { + t.Errorf("first check cost = %v, want 12.5", first.CurrentCostPerHour) + } + if catalog.DomainCount(domain.CheckDomainCost) != 1 || catalog.DomainCount(domain.CheckDomainCompliance) != 1 { + t.Errorf("domain counts = %+v", catalog.ByDomain) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + ListChecksFunc: func(ctx context.Context) (*gen.ListChecksResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewCheckService(mockClient, logtest.NewScope(t)) + _, err := svc.List(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/client.go b/internal/boundary/graphql/client.go index a48287c4..46aaab7f 100644 --- a/internal/boundary/graphql/client.go +++ b/internal/boundary/graphql/client.go @@ -48,6 +48,11 @@ type Client interface { // Service operations EnableService(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableService(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) + + // Product surface reads + GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) + ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) } // client is the concrete implementation of Client. @@ -262,3 +267,29 @@ func (c *client) DisableService(ctx context.Context, serviceID string) (*gen.Dis } return gen.DisableService(ctx, gql, serviceID) } + +// Product surface reads + +func (c *client) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.GetIssueSummary(ctx, gql) +} + +func (c *client) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ListChecks(ctx, gql) +} + +func (c *client) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ListEdgeInstances(ctx, gql) +} diff --git a/internal/boundary/graphql/edge_instance_service.go b/internal/boundary/graphql/edge_instance_service.go new file mode 100644 index 00000000..898dbcbf --- /dev/null +++ b/internal/boundary/graphql/edge_instance_service.go @@ -0,0 +1,62 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// EdgeInstances provides access to the account's edge runtime fleet. +type EdgeInstances interface { + List(ctx context.Context) (domain.EdgeFleet, error) +} + +// EdgeInstanceService reads edge instances from the control plane. +type EdgeInstanceService struct { + client Client + scope log.Scope +} + +var _ EdgeInstances = (*EdgeInstanceService)(nil) + +// NewEdgeInstanceService creates a new edge instance service. +func NewEdgeInstanceService(client Client, scope log.Scope) *EdgeInstanceService { + return &EdgeInstanceService{ + client: client, + scope: scope.Child("edge-instances"), + } +} + +// List fetches the edge instance fleet for the active account. The total is +// server-reported; recency/connectivity is derived by callers from LastSyncAt. +func (s *EdgeInstanceService) List(ctx context.Context) (domain.EdgeFleet, error) { + s.scope.Debug("fetching edge instances from API") + resp, err := s.client.ListEdgeInstances(ctx) + if err != nil { + s.scope.Error("failed to fetch edge instances", "error", err) + return domain.EdgeFleet{}, err + } + + fleet := domain.EdgeFleet{ + Total: int64(resp.EdgeInstances.TotalCount), + Instances: make([]domain.EdgeInstance, 0, len(resp.EdgeInstances.Edges)), + } + for _, edge := range resp.EdgeInstances.Edges { + node := edge.Node + namespace := "" + if node.ServiceNamespace != nil { + namespace = *node.ServiceNamespace + } + fleet.Instances = append(fleet.Instances, domain.EdgeInstance{ + ID: node.Id, + InstanceID: node.InstanceID, + ServiceName: node.ServiceName, + ServiceNamespace: namespace, + LastSyncAt: node.LastSyncAt, + }) + } + + s.scope.Debug("fetched edge instances", log.Int("count", len(fleet.Instances))) + return fleet, nil +} diff --git a/internal/boundary/graphql/edge_instance_service_test.go b/internal/boundary/graphql/edge_instance_service_test.go new file mode 100644 index 00000000..49328101 --- /dev/null +++ b/internal/boundary/graphql/edge_instance_service_test.go @@ -0,0 +1,81 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + "time" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestEdgeInstanceService_List(t *testing.T) { + t.Parallel() + t.Run("maps fleet total and instances", func(t *testing.T) { + t.Parallel() + ns := "payments" + now := time.Now() + mockClient := &apitest.MockClient{ + ListEdgeInstancesFunc: func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + return &gen.ListEdgeInstancesResponse{ + EdgeInstances: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnection{ + TotalCount: 2, + Edges: []gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge{ + {Node: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance{ + Id: "edge-1", + InstanceID: "inst-1", + ServiceName: "checkout", + ServiceNamespace: &ns, + LastSyncAt: now, + }}, + {Node: gen.ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance{ + Id: "edge-2", + InstanceID: "inst-2", + ServiceName: "billing", + LastSyncAt: now.Add(-time.Hour), + }}, + }, + }, + }, nil + }, + } + + svc := graphql.NewEdgeInstanceService(mockClient, logtest.NewScope(t)) + fleet, err := svc.List(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fleet.Total != 2 || len(fleet.Instances) != 2 { + t.Fatalf("fleet = %+v, want total=2 and 2 instances", fleet) + } + if fleet.Instances[0].ServiceNamespace != "payments" { + t.Errorf("namespace = %q, want payments", fleet.Instances[0].ServiceNamespace) + } + if fleet.Instances[1].ServiceNamespace != "" { + t.Errorf("nil namespace should map to empty string, got %q", fleet.Instances[1].ServiceNamespace) + } + if got := fleet.ConnectedCount(now, 30*time.Minute); got != 1 { + t.Errorf("ConnectedCount = %d, want 1", got) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + ListEdgeInstancesFunc: func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewEdgeInstanceService(mockClient, logtest.NewScope(t)) + _, err := svc.List(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index 10390d8d..57e635a2 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -303,6 +303,18 @@ func (v *EnableServiceSetServiceEnabledService) GetName() string { return v.Name // GetEnabled returns EnableServiceSetServiceEnabledService.Enabled, and is useful for accessing the field via an interface. func (v *EnableServiceSetServiceEnabledService) GetEnabled() bool { return v.Enabled } +type FindingCheckDomain string + +const ( + FindingCheckDomainCost FindingCheckDomain = "cost" + FindingCheckDomainCompliance FindingCheckDomain = "compliance" +) + +var AllFindingCheckDomain = []FindingCheckDomain{ + FindingCheckDomainCost, + FindingCheckDomainCompliance, +} + // GetAccountAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. type GetAccountAccountsAccountConnection struct { Edges []GetAccountAccountsAccountConnectionEdgesAccountEdge `json:"edges"` @@ -504,6 +516,79 @@ func (v *GetDatadogAccountStatusResponse) GetDatadogAccounts() GetDatadogAccount return v.DatadogAccounts } +// GetIssueSummaryIssuesIssueConnection includes the requested fields of the GraphQL type IssueConnection. +type GetIssueSummaryIssuesIssueConnection struct { + TotalCount int `json:"totalCount"` + Summary GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary `json:"summary"` + Facets GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets `json:"facets"` +} + +// GetTotalCount returns GetIssueSummaryIssuesIssueConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetTotalCount() int { return v.TotalCount } + +// GetSummary returns GetIssueSummaryIssuesIssueConnection.Summary, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetSummary() GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary { + return v.Summary +} + +// GetFacets returns GetIssueSummaryIssuesIssueConnection.Facets, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnection) GetFacets() GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets { + return v.Facets +} + +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets includes the requested fields of the GraphQL type IssueFacets. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets struct { + Priorities GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet `json:"priorities"` +} + +// GetPriorities returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets.Priorities, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets) GetPriorities() GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet { + return v.Priorities +} + +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet includes the requested fields of the GraphQL type IssuePriorityFacet. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet struct { + Buckets []GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket `json:"buckets"` +} + +// GetBuckets returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet.Buckets, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet) GetBuckets() []GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket { + return v.Buckets +} + +// GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket includes the requested fields of the GraphQL type IssuePriorityFacetBucket. +type GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket struct { + Value IssuePriority `json:"value"` + Count int `json:"count"` +} + +// GetValue returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket.Value, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket) GetValue() IssuePriority { + return v.Value +} + +// GetCount returns GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket.Count, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket) GetCount() int { + return v.Count +} + +// GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary includes the requested fields of the GraphQL type IssueSummary. +type GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary struct { + Count int `json:"count"` +} + +// GetCount returns GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary.Count, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary) GetCount() int { return v.Count } + +// GetIssueSummaryResponse is returned by GetIssueSummary on success. +type GetIssueSummaryResponse struct { + // Query Issues records in your account. + Issues GetIssueSummaryIssuesIssueConnection `json:"issues"` +} + +// GetIssues returns GetIssueSummaryResponse.Issues, and is useful for accessing the field via an interface. +func (v *GetIssueSummaryResponse) GetIssues() GetIssueSummaryIssuesIssueConnection { return v.Issues } + // GetServiceByNameResponse is returned by GetServiceByName on success. type GetServiceByNameResponse struct { // Query Services records in your account. @@ -717,6 +802,20 @@ func (v *GetServiceServicesServiceConnectionEdgesServiceEdgeNodeServiceLogEvents return v.CreatedAt } +type IssuePriority string + +const ( + IssuePriorityLow IssuePriority = "low" + IssuePriorityMedium IssuePriority = "medium" + IssuePriorityHigh IssuePriority = "high" +) + +var AllIssuePriority = []IssuePriority{ + IssuePriorityLow, + IssuePriorityMedium, + IssuePriorityHigh, +} + // ListAccountsAccountsAccountConnection includes the requested fields of the GraphQL type AccountConnection. type ListAccountsAccountsAccountConnection struct { Edges []ListAccountsAccountsAccountConnectionEdgesAccountEdge `json:"edges"` @@ -775,6 +874,237 @@ type ListAccountsResponse struct { // GetAccounts returns ListAccountsResponse.Accounts, and is useful for accessing the field via an interface. func (v *ListAccountsResponse) GetAccounts() ListAccountsAccountsAccountConnection { return v.Accounts } +// ListChecksChecksCheckConnection includes the requested fields of the GraphQL type CheckConnection. +type ListChecksChecksCheckConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListChecksChecksCheckConnectionEdgesCheckEdge `json:"edges"` + Facets ListChecksChecksCheckConnectionFacetsCheckFacets `json:"facets"` +} + +// GetTotalCount returns ListChecksChecksCheckConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetTotalCount() int { return v.TotalCount } + +// GetEdges returns ListChecksChecksCheckConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetEdges() []ListChecksChecksCheckConnectionEdgesCheckEdge { + return v.Edges +} + +// GetFacets returns ListChecksChecksCheckConnection.Facets, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnection) GetFacets() ListChecksChecksCheckConnectionFacetsCheckFacets { + return v.Facets +} + +// ListChecksChecksCheckConnectionEdgesCheckEdge includes the requested fields of the GraphQL type CheckEdge. +type ListChecksChecksCheckConnectionEdgesCheckEdge struct { + Node ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck `json:"node"` +} + +// GetNode returns ListChecksChecksCheckConnectionEdgesCheckEdge.Node, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdge) GetNode() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck { + return v.Node +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck includes the requested fields of the GraphQL type Check. +// The GraphQL type's documentation follows. +// +// One code-defined standing product question and its account posture. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck struct { + Id string `json:"id"` + Name string `json:"name"` + Domain FindingCheckDomain `json:"domain"` + Posture ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture `json:"posture"` +} + +// GetId returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Id, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetId() string { return v.Id } + +// GetName returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Name, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetName() string { return v.Name } + +// GetDomain returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Domain, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetDomain() FindingCheckDomain { + return v.Domain +} + +// GetPosture returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck.Posture, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheck) GetPosture() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture { + return v.Posture +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture includes the requested fields of the GraphQL type CheckPosture. +// The GraphQL type's documentation follows. +// +// Account-scoped posture for one check, computed from findings and issues. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture struct { + OpenFindingCount int `json:"openFindingCount"` + PendingFindingCount int `json:"pendingFindingCount"` + EscalatedFindingCount int `json:"escalatedFindingCount"` + ActiveIssueCount int `json:"activeIssueCount"` + AffectedServiceCount int `json:"affectedServiceCount"` + Current ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals `json:"current"` +} + +// GetOpenFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.OpenFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetOpenFindingCount() int { + return v.OpenFindingCount +} + +// GetPendingFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.PendingFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetPendingFindingCount() int { + return v.PendingFindingCount +} + +// GetEscalatedFindingCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.EscalatedFindingCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetEscalatedFindingCount() int { + return v.EscalatedFindingCount +} + +// GetActiveIssueCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.ActiveIssueCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetActiveIssueCount() int { + return v.ActiveIssueCount +} + +// GetAffectedServiceCount returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.AffectedServiceCount, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetAffectedServiceCount() int { + return v.AffectedServiceCount +} + +// GetCurrent returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture.Current, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPosture) GetCurrent() ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals { + return v.Current +} + +// ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals struct { + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` +} + +// GetTotalUsdPerHour returns ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionEdgesCheckEdgeNodeCheckPostureCurrentStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// ListChecksChecksCheckConnectionFacetsCheckFacets includes the requested fields of the GraphQL type CheckFacets. +type ListChecksChecksCheckConnectionFacetsCheckFacets struct { + Domains ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet `json:"domains"` +} + +// GetDomains returns ListChecksChecksCheckConnectionFacetsCheckFacets.Domains, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacets) GetDomains() ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet { + return v.Domains +} + +// ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet includes the requested fields of the GraphQL type CheckDomainFacet. +type ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet struct { + Buckets []ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket `json:"buckets"` +} + +// GetBuckets returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet.Buckets, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacet) GetBuckets() []ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket { + return v.Buckets +} + +// ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket includes the requested fields of the GraphQL type CheckDomainFacetBucket. +type ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket struct { + Value FindingCheckDomain `json:"value"` + Count int `json:"count"` +} + +// GetValue returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket.Value, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket) GetValue() FindingCheckDomain { + return v.Value +} + +// GetCount returns ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket.Count, and is useful for accessing the field via an interface. +func (v *ListChecksChecksCheckConnectionFacetsCheckFacetsDomainsCheckDomainFacetBucketsCheckDomainFacetBucket) GetCount() int { + return v.Count +} + +// ListChecksResponse is returned by ListChecks on success. +type ListChecksResponse struct { + // Code-defined product checks with account-scoped posture measurements. + Checks ListChecksChecksCheckConnection `json:"checks"` +} + +// GetChecks returns ListChecksResponse.Checks, and is useful for accessing the field via an interface. +func (v *ListChecksResponse) GetChecks() ListChecksChecksCheckConnection { return v.Checks } + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnection includes the requested fields of the GraphQL type EdgeInstanceConnection. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge `json:"edges"` +} + +// GetTotalCount returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnection) GetTotalCount() int { + return v.TotalCount +} + +// GetEdges returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnection) GetEdges() []ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge { + return v.Edges +} + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge includes the requested fields of the GraphQL type EdgeInstanceEdge. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge struct { + Node ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance `json:"node"` +} + +// GetNode returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge.Node, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdge) GetNode() ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance { + return v.Node +} + +// ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance includes the requested fields of the GraphQL type EdgeInstance. +type ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance struct { + // Unique identifier of this edge instance + Id string `json:"id"` + // The service.instance.id resource attribute identifying this instance + InstanceID string `json:"instanceID"` + // The service.name resource attribute + ServiceName string `json:"serviceName"` + // The service.namespace resource attribute + ServiceNamespace *string `json:"serviceNamespace"` + // When this edge instance last synced + LastSyncAt time.Time `json:"lastSyncAt"` +} + +// GetId returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.Id, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetId() string { + return v.Id +} + +// GetInstanceID returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.InstanceID, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetInstanceID() string { + return v.InstanceID +} + +// GetServiceName returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.ServiceName, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetServiceName() string { + return v.ServiceName +} + +// GetServiceNamespace returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.ServiceNamespace, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetServiceNamespace() *string { + return v.ServiceNamespace +} + +// GetLastSyncAt returns ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance.LastSyncAt, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesEdgeInstancesEdgeInstanceConnectionEdgesEdgeInstanceEdgeNodeEdgeInstance) GetLastSyncAt() time.Time { + return v.LastSyncAt +} + +// ListEdgeInstancesResponse is returned by ListEdgeInstances on success. +type ListEdgeInstancesResponse struct { + // Query EdgeInstances records in your account. + EdgeInstances ListEdgeInstancesEdgeInstancesEdgeInstanceConnection `json:"edgeInstances"` +} + +// GetEdgeInstances returns ListEdgeInstancesResponse.EdgeInstances, and is useful for accessing the field via an interface. +func (v *ListEdgeInstancesResponse) GetEdgeInstances() ListEdgeInstancesEdgeInstancesEdgeInstanceConnection { + return v.EdgeInstances +} + // ListOrganizationsOrganizationsOrganizationConnection includes the requested fields of the GraphQL type OrganizationConnection. type ListOrganizationsOrganizationsOrganizationConnection struct { Edges []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` @@ -1356,6 +1686,50 @@ func GetDatadogAccountStatus( return data_, err_ } +// The query executed by GetIssueSummary. +const GetIssueSummary_Operation = ` +query GetIssueSummary { + issues(where: {closedAtIsNil:true,ignoredAtIsNil:true}) { + totalCount + summary { + count + } + facets { + priorities { + buckets { + value + count + } + } + } + } +} +` + +// Active issues with server-computed priority breakdown. An issue is active +// while both closedAt and ignoredAt are nil; the control plane computes the +// summary count and priority facet buckets so the CLI never aggregates locally. +func GetIssueSummary( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *GetIssueSummaryResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetIssueSummary", + Query: GetIssueSummary_Operation, + } + + data_ = &GetIssueSummaryResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetService. const GetService_Operation = ` query GetService ($id: ID!) { @@ -1496,6 +1870,105 @@ func ListAccounts( return data_, err_ } +// The query executed by ListChecks. +const ListChecks_Operation = ` +query ListChecks { + checks { + totalCount + edges { + node { + id + name + domain + posture { + openFindingCount + pendingFindingCount + escalatedFindingCount + activeIssueCount + affectedServiceCount + current { + totalUsdPerHour + } + } + } + } + facets { + domains { + buckets { + value + count + } + } + } + } +} +` + +// Code-defined product checks with account-scoped posture. Posture counts and +// cost totals are computed server-side from findings and issues; the domain +// facet groups checks by lane (cost / compliance). +func ListChecks( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *ListChecksResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListChecks", + Query: ListChecks_Operation, + } + + data_ = &ListChecksResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ListEdgeInstances. +const ListEdgeInstances_Operation = ` +query ListEdgeInstances { + edgeInstances(orderBy: {field:LAST_SYNC_AT,direction:DESC}) { + totalCount + edges { + node { + id + instanceID + serviceName + serviceNamespace + lastSyncAt + } + } + } +} +` + +// Edge instances registered for the active account. totalCount is the fleet +// size; lastSyncAt lets the surface report recency without local aggregation. +func ListEdgeInstances( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *ListEdgeInstancesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListEdgeInstances", + Query: ListEdgeInstances_Operation, + } + + data_ = &ListEdgeInstancesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by ListOrganizations. const ListOrganizations_Operation = ` query ListOrganizations { diff --git a/internal/boundary/graphql/gen/queries/checks.graphql b/internal/boundary/graphql/gen/queries/checks.graphql new file mode 100644 index 00000000..80025c08 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/checks.graphql @@ -0,0 +1,33 @@ +# Code-defined product checks with account-scoped posture. Posture counts and +# cost totals are computed server-side from findings and issues; the domain +# facet groups checks by lane (cost / compliance). +query ListChecks { + checks { + totalCount + edges { + node { + id + name + domain + posture { + openFindingCount + pendingFindingCount + escalatedFindingCount + activeIssueCount + affectedServiceCount + current { + totalUsdPerHour + } + } + } + } + facets { + domains { + buckets { + value + count + } + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/edge_instances.graphql b/internal/boundary/graphql/gen/queries/edge_instances.graphql new file mode 100644 index 00000000..034f24d7 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/edge_instances.graphql @@ -0,0 +1,16 @@ +# Edge instances registered for the active account. totalCount is the fleet +# size; lastSyncAt lets the surface report recency without local aggregation. +query ListEdgeInstances { + edgeInstances(orderBy: { field: LAST_SYNC_AT, direction: DESC }) { + totalCount + edges { + node { + id + instanceID + serviceName + serviceNamespace + lastSyncAt + } + } + } +} diff --git a/internal/boundary/graphql/gen/queries/issues.graphql b/internal/boundary/graphql/gen/queries/issues.graphql new file mode 100644 index 00000000..c862389f --- /dev/null +++ b/internal/boundary/graphql/gen/queries/issues.graphql @@ -0,0 +1,19 @@ +# Active issues with server-computed priority breakdown. An issue is active +# while both closedAt and ignoredAt are nil; the control plane computes the +# summary count and priority facet buckets so the CLI never aggregates locally. +query GetIssueSummary { + issues(where: { closedAtIsNil: true, ignoredAtIsNil: true }) { + totalCount + summary { + count + } + facets { + priorities { + buckets { + value + count + } + } + } + } +} diff --git a/internal/boundary/graphql/issue_service.go b/internal/boundary/graphql/issue_service.go new file mode 100644 index 00000000..97d0c461 --- /dev/null +++ b/internal/boundary/graphql/issue_service.go @@ -0,0 +1,60 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// Issues provides access to the account's active issues. +type Issues interface { + GetSummary(ctx context.Context) (domain.IssueSummary, error) +} + +// IssueService reads issue aggregates from the control plane. +type IssueService struct { + client Client + scope log.Scope +} + +var _ Issues = (*IssueService)(nil) + +// NewIssueService creates a new issue service. +func NewIssueService(client Client, scope log.Scope) *IssueService { + return &IssueService{ + client: client, + scope: scope.Child("issues"), + } +} + +// GetSummary fetches the server-computed active-issue summary, including the +// per-priority breakdown. Counts are never aggregated locally. +func (s *IssueService) GetSummary(ctx context.Context) (domain.IssueSummary, error) { + s.scope.Debug("fetching issue summary from API") + resp, err := s.client.GetIssueSummary(ctx) + if err != nil { + s.scope.Error("failed to fetch issue summary", "error", err) + return domain.IssueSummary{}, err + } + + summary := domain.IssueSummary{ + Open: int64(resp.Issues.Summary.Count), + } + for _, bucket := range resp.Issues.Facets.Priorities.Buckets { + switch bucket.Value { + case gen.IssuePriorityHigh: + summary.HighCount = int64(bucket.Count) + case gen.IssuePriorityMedium: + summary.MediumCount = int64(bucket.Count) + case gen.IssuePriorityLow: + summary.LowCount = int64(bucket.Count) + } + } + + s.scope.Debug("fetched issue summary", + log.Int("open", int(summary.Open)), + log.Int("high", int(summary.HighCount))) + return summary, nil +} diff --git a/internal/boundary/graphql/issue_service_test.go b/internal/boundary/graphql/issue_service_test.go new file mode 100644 index 00000000..1eee8936 --- /dev/null +++ b/internal/boundary/graphql/issue_service_test.go @@ -0,0 +1,67 @@ +package graphql_test + +import ( + "context" + "errors" + "testing" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/log/logtest" +) + +func TestIssueService_GetSummary(t *testing.T) { + t.Parallel() + t.Run("maps server count and priority facet buckets", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + GetIssueSummaryFunc: func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + return &gen.GetIssueSummaryResponse{ + Issues: gen.GetIssueSummaryIssuesIssueConnection{ + TotalCount: 9, + Summary: gen.GetIssueSummaryIssuesIssueConnectionSummaryIssueSummary{Count: 9}, + Facets: gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacets{ + Priorities: gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacet{ + Buckets: []gen.GetIssueSummaryIssuesIssueConnectionFacetsIssueFacetsPrioritiesIssuePriorityFacetBucketsIssuePriorityFacetBucket{ + {Value: gen.IssuePriorityHigh, Count: 4}, + {Value: gen.IssuePriorityMedium, Count: 3}, + {Value: gen.IssuePriorityLow, Count: 2}, + }, + }, + }, + }, + }, nil + }, + } + + svc := graphql.NewIssueService(mockClient, logtest.NewScope(t)) + summary, err := svc.GetSummary(context.Background()) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if summary.Open != 9 { + t.Errorf("Open = %d, want 9", summary.Open) + } + if summary.HighCount != 4 || summary.MediumCount != 3 || summary.LowCount != 2 { + t.Errorf("priority counts = %+v, want high=4 medium=3 low=2", summary) + } + }) + + t.Run("propagates client errors", func(t *testing.T) { + t.Parallel() + mockClient := &apitest.MockClient{ + GetIssueSummaryFunc: func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) { + return nil, errors.New("network error") + }, + } + + svc := graphql.NewIssueService(mockClient, logtest.NewScope(t)) + _, err := svc.GetSummary(context.Background()) + + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/internal/boundary/graphql/services.go b/internal/boundary/graphql/services.go index 18caa838..00f26e85 100644 --- a/internal/boundary/graphql/services.go +++ b/internal/boundary/graphql/services.go @@ -19,6 +19,9 @@ type ServiceSet struct { DatadogAccounts DatadogAccounts Services Services Policies Policies + Issues Issues + Checks Checks + EdgeInstances EdgeInstances } // NewServiceSet creates ServiceSet with an internally-managed client. @@ -48,6 +51,9 @@ func newServiceSetWithScope(client Client, scope log.Scope) ServiceSet { DatadogAccounts: NewDatadogAccountService(client, scope), Services: NewServiceService(client, scope), Policies: NewPolicyService(client, scope), + Issues: NewIssueService(client, scope), + Checks: NewCheckService(client, scope), + EdgeInstances: NewEdgeInstanceService(client, scope), } } diff --git a/internal/domain/check.go b/internal/domain/check.go new file mode 100644 index 00000000..a15f87c3 --- /dev/null +++ b/internal/domain/check.go @@ -0,0 +1,43 @@ +package domain + +// CheckDomain is the lane a product check belongs to. +type CheckDomain string + +const ( + CheckDomainCost CheckDomain = "cost" + CheckDomainCompliance CheckDomain = "compliance" +) + +func (d CheckDomain) String() string { return string(d) } + +// Check is one code-defined product check with its account-scoped posture. +// Posture counts and cost totals are computed server-side from findings and +// issues. +type Check struct { + ID string + Name string + Domain CheckDomain + + OpenFindingCount int64 + PendingFindingCount int64 + EscalatedFindingCount int64 + ActiveIssueCount int64 + AffectedServiceCount int64 + + // CurrentCostPerHour is the current spend attributable to this check, or + // nil when unmeasured. + CurrentCostPerHour *float64 +} + +// CheckCatalog is the full set of product checks plus server-computed +// per-domain counts. +type CheckCatalog struct { + Total int64 + Checks []Check + ByDomain map[CheckDomain]int64 +} + +// DomainCount returns the number of checks in a domain. +func (c CheckCatalog) DomainCount(d CheckDomain) int64 { + return c.ByDomain[d] +} diff --git a/internal/domain/edge_instance.go b/internal/domain/edge_instance.go new file mode 100644 index 00000000..56996f46 --- /dev/null +++ b/internal/domain/edge_instance.go @@ -0,0 +1,32 @@ +package domain + +import "time" + +// EdgeInstance is one edge runtime registered for the account. +type EdgeInstance struct { + ID string + InstanceID string + ServiceName string + ServiceNamespace string + LastSyncAt time.Time +} + +// EdgeFleet is the set of edge instances for the active account. Total is the +// server-reported fleet size. +type EdgeFleet struct { + Total int64 + Instances []EdgeInstance +} + +// ConnectedCount returns the number of instances that synced within the given +// recency window relative to now. +func (f EdgeFleet) ConnectedCount(now time.Time, within time.Duration) int64 { + var connected int64 + cutoff := now.Add(-within) + for _, inst := range f.Instances { + if inst.LastSyncAt.After(cutoff) { + connected++ + } + } + return connected +} diff --git a/internal/domain/issue.go b/internal/domain/issue.go new file mode 100644 index 00000000..484a4108 --- /dev/null +++ b/internal/domain/issue.go @@ -0,0 +1,26 @@ +package domain + +// IssuePriority is how much attention a kept finding deserves. +type IssuePriority string + +const ( + IssuePriorityLow IssuePriority = "low" + IssuePriorityMedium IssuePriority = "medium" + IssuePriorityHigh IssuePriority = "high" +) + +func (p IssuePriority) String() string { return string(p) } + +// IssueSummary is the server-computed aggregate state for active issues +// (issues whose closedAt and ignoredAt are both nil). The control plane +// computes the count and per-priority breakdown; the CLI never aggregates +// issue rows locally. +type IssueSummary struct { + // Open is the total number of active issues. + Open int64 + + // Per-priority counts of active issues. + HighCount int64 + MediumCount int64 + LowCount int64 +} From 39e165686432afb460d7d26de78313ed53f3d44c Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 10:34:51 -0700 Subject: [PATCH 07/20] refactor: make chat ephemeral, drop message persistence Chat conversations and messages are no longer persisted. The in-memory core session is already the source of truth for request history, so the SQLite-backed persistence collaborators become in-memory stand-ins: - AssistantPersister / ToolLoop mint local message IDs (no DB writes) - OrphanMessageCleaner is a no-op (the message list drops cancelled rounds in the UI; there is no store to reconcile) - conversation IDs are minted locally on first message NewRuntimeDeps no longer takes a database. Chat effect closures stop importing sqlite. Tests now read history from the session instead of the database. The chat empty-state summary still reads the local projection; that read moves to GraphQL with the other status surfaces. --- internal/app/chat/chat_test.go | 25 ++++------ internal/app/chat/input_flow.go | 39 +++------------ internal/app/chat/layout.go | 5 +- .../messagelisttest/messagelist.go | 6 +-- .../chat/messagelist/round/round_effects.go | 6 +-- .../messagelist/round/turn/turn_effects.go | 8 ++-- internal/app/chat/messagelist/update_test.go | 4 +- .../app/chat/usecase/assistant_persistence.go | 36 ++++---------- .../usecase/assistant_persistence_test.go | 47 ++++++------------- internal/app/chat/usecase/orphan_cleanup.go | 19 +++----- .../app/chat/usecase/orphan_cleanup_test.go | 34 +++----------- internal/app/chat/usecase/runtime_deps.go | 12 +++-- internal/app/chat/usecase/tool_loop.go | 20 ++++---- internal/app/chat/usecase/tool_loop_test.go | 18 +++---- internal/app/runtime_sync.go | 2 +- 15 files changed, 90 insertions(+), 191 deletions(-) diff --git a/internal/app/chat/chat_test.go b/internal/app/chat/chat_test.go index 699a4556..78d04abf 100644 --- a/internal/app/chat/chat_test.go +++ b/internal/app/chat/chat_test.go @@ -29,7 +29,7 @@ func newTestChat(t *testing.T, client chat.Client) *Model { theme := styles.NewTheme(true) scope := logtest.NewScope(t) db := dbtest.OpenTestDB(t) - runtimeDeps := usecase.NewRuntimeDeps(db, client) + runtimeDeps := usecase.NewRuntimeDeps(client) m := New(nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, theme, db, runtimeDeps, nil, scope) m.SetSize(80, 40) @@ -151,13 +151,14 @@ func submitToolResultsAndDrain(m *Model, results []domaintools.Result, maxSteps teatest.DrainCmds(m.Update, cmd, maxSteps) } +// listMessages returns the conversation history from the in-memory session. +// Chat is ephemeral: the session is the source of truth, not a database. func listMessages(t *testing.T, m *Model) []domain.Message { t.Helper() - messages, err := m.db.Messages().List(context.Background(), m.conversationID) - if err != nil { - t.Fatalf("failed to list messages: %v", err) + if m.session == nil { + return nil } - return messages + return m.session.Messages() } func TestCancelActiveRound(t *testing.T) { @@ -211,7 +212,7 @@ func TestCancelActiveRound(t *testing.T) { }) } -func TestRequestHistoryUsesInMemorySessionNotDBRead(t *testing.T) { +func TestRequestHistoryUsesInMemorySession(t *testing.T) { t.Parallel() var requests []chat.Request @@ -219,16 +220,8 @@ func TestRequestHistoryUsesInMemorySessionNotDBRead(t *testing.T) { submitAndDrain(m, "first", 50) - // Simulate durability drift: assistant row missing in SQLite. - stored := listMessages(t, m) - for _, msg := range stored { - if msg.Role == domain.RoleAssistant { - if err := m.db.Messages().Delete(context.Background(), msg.ID); err != nil { - t.Fatalf("delete assistant: %v", err) - } - } - } - + // The in-memory session is the sole source of request history; the second + // turn must carry the prior user+assistant exchange forward. submitAndDrain(m, "second", 70) if len(requests) < 2 { diff --git a/internal/app/chat/input_flow.go b/internal/app/chat/input_flow.go index c28eec97..c91e0b82 100644 --- a/internal/app/chat/input_flow.go +++ b/internal/app/chat/input_flow.go @@ -3,10 +3,8 @@ package chat import ( tea "charm.land/bubbletea/v2" msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" ) // handleUserInput creates conversation if needed, then persists the user message. @@ -28,24 +26,12 @@ func (m *Model) handleUserInput(input msgs.UserSubmittedInput) tea.Cmd { return tea.Batch(cancelCmd, m.persistUserMessage(input)) } -// createConversation creates a new conversation. +// createConversation starts a new ephemeral conversation. Chat is not +// persisted, so the conversation ID is minted locally for this session only. func (m *Model) createConversation(input msgs.UserSubmittedInput) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - - convID, err := m.db.Conversations().Create( - ctx, - m.account.ID, - m.workspace.ID, - ) - if err != nil { - m.scope.Error("failed to create conversation", "error", err) - return appevents.ErrorToastPublished{Message: "Failed to create conversation", Err: err} - } - return conversationCreated{ - conversationID: convID, + conversationID: domain.NewConversationID(), input: input, } } @@ -57,16 +43,11 @@ type conversationCreated struct { input msgs.UserSubmittedInput } -// persistUserMessage saves the user message and updates in-memory request history. +// persistUserMessage appends the user message to the in-memory session. Chat +// is ephemeral, so the message ID is minted locally and nothing is stored. func (m *Model) persistUserMessage(input msgs.UserSubmittedInput) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - - var msgID domain.MessageID - var err error var domainResults []domain.ToolResult - if len(input.ToolResults) > 0 { // Convert typed results to domain format at the boundary. domainResults = make([]domain.ToolResult, len(input.ToolResults)) @@ -80,15 +61,9 @@ func (m *Model) persistUserMessage(input msgs.UserSubmittedInput) tea.Cmd { domainResults[i].Error = r.Error.Message } } - msgID, err = m.db.Messages().CreateToolResultMessage(ctx, m.account.ID, m.conversationID, domainResults) - } else { - msgID, err = m.db.Messages().CreateUserMessage(ctx, m.account.ID, m.conversationID, input.Text) - } - if err != nil { - m.scope.Error("failed to create user message", "error", err) - return appevents.ErrorToastPublished{Message: "Failed to save message", Err: err} } + msgID := domain.NewMessageID() if m.session == nil { m.session = corechat.NewSession(m.conversationID, nil) } @@ -108,7 +83,7 @@ func (m *Model) persistUserMessage(input msgs.UserSubmittedInput) tea.Cmd { } } -// userMessagePersisted is fired after user message is saved to database. +// userMessagePersisted is fired after the user message is appended to the session. type userMessagePersisted struct { conversationID domain.ConversationID messageID domain.MessageID diff --git a/internal/app/chat/layout.go b/internal/app/chat/layout.go index a8aefcfc..21c5ace4 100644 --- a/internal/app/chat/layout.go +++ b/internal/app/chat/layout.go @@ -1,10 +1,11 @@ package chat import ( + "context" + "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" ) // SetSize updates the dimensions. This is a flexible component. @@ -99,7 +100,7 @@ func (m *Model) cleanupOrphanedMessages(ids []domain.MessageID) tea.Cmd { } cleaner := m.runtimeDeps.OrphanCleaner return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) + ctx, cancel := context.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) defer cancel() return orphanedMessagesCleanupCompleted{ ids: ids, diff --git a/internal/app/chat/messagelist/messagelisttest/messagelist.go b/internal/app/chat/messagelist/messagelisttest/messagelist.go index ed3ce436..d4dee811 100644 --- a/internal/app/chat/messagelist/messagelisttest/messagelist.go +++ b/internal/app/chat/messagelist/messagelisttest/messagelist.go @@ -8,20 +8,18 @@ import ( "github.com/usetero/cli/internal/app/chat/usecase" "github.com/usetero/cli/internal/boundary/chat/chattest" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" "github.com/usetero/cli/internal/styles" ) // New creates a messagelist.Model wired with test dependencies. -// Uses a real SQLite database, mock chat client, real theme, and test logger. +// Uses ephemeral in-memory chat deps, mock chat client, real theme, and test logger. func New(t *testing.T, width, height int) *messagelist.Model { t.Helper() theme := styles.NewTheme(true) - db := dbtest.OpenTestDB(t) client := &chattest.MockClient{} scope := logtest.NewScope(t) - runtimeDeps := usecase.NewRuntimeDeps(db, client) + runtimeDeps := usecase.NewRuntimeDeps(client) m := messagelist.New(theme, runtimeDeps, nil, scope) m.SetSize(width, height) diff --git a/internal/app/chat/messagelist/round/round_effects.go b/internal/app/chat/messagelist/round/round_effects.go index 40657a3a..c512efdc 100644 --- a/internal/app/chat/messagelist/round/round_effects.go +++ b/internal/app/chat/messagelist/round/round_effects.go @@ -1,6 +1,7 @@ package round import ( + "context" "fmt" "strings" @@ -11,10 +12,9 @@ import ( corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" ) -// startNextTurn persists tool results and creates the next turn using in-memory history. +// startNextTurn appends tool results and creates the next turn using in-memory history. func (m *Model) startNextTurn(results []domaintools.Result) tea.Cmd { m.scope.Info("starting next turn", "result_count", len(results)) for _, summary := range summarizeToolResults(results) { @@ -22,7 +22,7 @@ func (m *Model) startNextTurn(results []domaintools.Result) tea.Cmd { } return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.effectCtx, dbOpTimeout) + ctx, cancel := context.WithTimeout(m.effectCtx, dbOpTimeout) defer cancel() prepared, err := m.toolLoop.PrepareNextTurn(ctx, usecase.PrepareNextTurnInput{ diff --git a/internal/app/chat/messagelist/round/turn/turn_effects.go b/internal/app/chat/messagelist/round/turn/turn_effects.go index 5927c7df..7a0a3efc 100644 --- a/internal/app/chat/messagelist/round/turn/turn_effects.go +++ b/internal/app/chat/messagelist/round/turn/turn_effects.go @@ -1,20 +1,22 @@ package turn import ( + "context" + tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/app/chat/usecase" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" ) -// persistAssistantMessage saves the assistant message to the database. +// persistAssistantMessage records the assistant message for the session. Chat +// is ephemeral, so this mints a local message ID rather than storing content. func (m *Model) persistAssistantMessage(msg *domain.Message) tea.Cmd { if msg == nil { return nil } return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.effectCtx, dbOpTimeout) + ctx, cancel := context.WithTimeout(m.effectCtx, dbOpTimeout) defer cancel() msgID, err := m.assistantPersister.PersistAssistant(ctx, usecase.PersistAssistantInput{ diff --git a/internal/app/chat/messagelist/update_test.go b/internal/app/chat/messagelist/update_test.go index bfe0bc3a..c1cb9124 100644 --- a/internal/app/chat/messagelist/update_test.go +++ b/internal/app/chat/messagelist/update_test.go @@ -13,7 +13,6 @@ import ( corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" "github.com/usetero/cli/internal/styles" ) @@ -42,7 +41,6 @@ func newStreamingMessageList(t *testing.T) *Model { t.Helper() theme := styles.NewTheme(true) scope := logtest.NewScope(t) - db := dbtest.OpenTestDB(t) client := &chattest.MockClient{ StreamFunc: func(_ context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { @@ -54,7 +52,7 @@ func newStreamingMessageList(t *testing.T) *Model { select {} }, } - runtimeDeps := usecase.NewRuntimeDeps(db, client) + runtimeDeps := usecase.NewRuntimeDeps(client) m := New(theme, runtimeDeps, nil, scope) m.SetSize(80, 40) diff --git a/internal/app/chat/usecase/assistant_persistence.go b/internal/app/chat/usecase/assistant_persistence.go index 8a284f42..872b3edb 100644 --- a/internal/app/chat/usecase/assistant_persistence.go +++ b/internal/app/chat/usecase/assistant_persistence.go @@ -4,7 +4,6 @@ import ( "context" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" ) type PersistAssistantInput struct { @@ -17,34 +16,15 @@ type AssistantPersister interface { PersistAssistant(ctx context.Context, input PersistAssistantInput) (domain.MessageID, error) } -type SQLiteAssistantPersister struct { - db sqlite.DB -} +// MemoryAssistantPersister mints assistant message IDs without persisting. +// Chat is ephemeral: the rendered content lives in the in-memory message list +// for the duration of the session and is intentionally not stored. +type MemoryAssistantPersister struct{} -func NewSQLiteAssistantPersister(db sqlite.DB) *SQLiteAssistantPersister { - return &SQLiteAssistantPersister{db: db} +func NewMemoryAssistantPersister() *MemoryAssistantPersister { + return &MemoryAssistantPersister{} } -func (p *SQLiteAssistantPersister) PersistAssistant(ctx context.Context, input PersistAssistantInput) (domain.MessageID, error) { - msgID, err := p.db.Messages().CreateAssistantMessage( - ctx, - input.AccountID, - input.ConversationID, - input.Message.Model, - ) - if err != nil { - return "", err - } - - content, err := domain.EncodeBlocks(input.Message.Content) - if err != nil { - return "", err - } - if err := p.db.Messages().UpdateContent(ctx, msgID, content); err != nil { - return "", err - } - if err := p.db.Messages().UpdateMeta(ctx, msgID, input.Message.Model, input.Message.StopReason); err != nil { - return "", err - } - return msgID, nil +func (p *MemoryAssistantPersister) PersistAssistant(_ context.Context, _ PersistAssistantInput) (domain.MessageID, error) { + return domain.NewMessageID(), nil } diff --git a/internal/app/chat/usecase/assistant_persistence_test.go b/internal/app/chat/usecase/assistant_persistence_test.go index d8f40042..eaa03311 100644 --- a/internal/app/chat/usecase/assistant_persistence_test.go +++ b/internal/app/chat/usecase/assistant_persistence_test.go @@ -5,15 +5,12 @@ import ( "testing" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" ) -func TestSQLiteAssistantPersister_PersistAssistant_Success(t *testing.T) { +func TestMemoryAssistantPersister_PersistAssistant_MintsID(t *testing.T) { t.Parallel() - db := dbtest.OpenTestDB(t) - p := NewSQLiteAssistantPersister(db) + p := NewMemoryAssistantPersister() msgID, err := p.PersistAssistant(context.Background(), PersistAssistantInput{ AccountID: "acct-1", @@ -32,37 +29,23 @@ func TestSQLiteAssistantPersister_PersistAssistant_Success(t *testing.T) { if msgID == "" { t.Fatal("PersistAssistant() returned empty message ID") } - - got, err := db.Messages().Get(context.Background(), msgID) - if err != nil { - t.Fatalf("db.Messages().Get() error = %v", err) - } - if got.Role != domain.RoleAssistant { - t.Fatalf("role = %q, want %q", got.Role, domain.RoleAssistant) - } - if got.Model != "claude-3" { - t.Fatalf("model = %q, want claude-3", got.Model) - } - if got.StopReason != "end_turn" { - t.Fatalf("stop_reason = %q, want end_turn", got.StopReason) - } - if len(got.Content) != 1 || got.Content[0].Text == nil || got.Content[0].Text.Content != "hello" { - t.Fatalf("content mismatch: %+v", got.Content) - } } -func TestSQLiteAssistantPersister_PersistAssistant_ErrorOnMissingSchema(t *testing.T) { +func TestMemoryAssistantPersister_PersistAssistant_UniqueIDs(t *testing.T) { t.Parallel() - db := sqlitetest.OpenBareDB(t) // no schema tables applied - p := NewSQLiteAssistantPersister(db) + p := NewMemoryAssistantPersister() + input := PersistAssistantInput{AccountID: "acct-1", ConversationID: "conv-1"} - _, err := p.PersistAssistant(context.Background(), PersistAssistantInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Message: domain.Message{Model: "claude-3"}, - }) - if err == nil { - t.Fatal("expected error, got nil") + first, err := p.PersistAssistant(context.Background(), input) + if err != nil { + t.Fatalf("PersistAssistant() error = %v", err) + } + second, err := p.PersistAssistant(context.Background(), input) + if err != nil { + t.Fatalf("PersistAssistant() error = %v", err) + } + if first == second { + t.Fatalf("expected unique message IDs, got %q twice", first) } } diff --git a/internal/app/chat/usecase/orphan_cleanup.go b/internal/app/chat/usecase/orphan_cleanup.go index 02116eaa..a6c328f5 100644 --- a/internal/app/chat/usecase/orphan_cleanup.go +++ b/internal/app/chat/usecase/orphan_cleanup.go @@ -4,7 +4,6 @@ import ( "context" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite" ) // OrphanMessageCleaner removes uncommitted/orphaned messages after cancellation/failure. @@ -12,19 +11,15 @@ type OrphanMessageCleaner interface { CleanupMessages(ctx context.Context, ids []domain.MessageID) error } -type SQLiteOrphanMessageCleaner struct { - db sqlite.DB -} +// MemoryOrphanMessageCleaner is a no-op cleaner. With ephemeral chat there is +// no persisted store to reconcile; the message list drops cancelled rounds in +// the UI, so orphan cleanup has nothing to do. +type MemoryOrphanMessageCleaner struct{} -func NewSQLiteOrphanMessageCleaner(db sqlite.DB) *SQLiteOrphanMessageCleaner { - return &SQLiteOrphanMessageCleaner{db: db} +func NewMemoryOrphanMessageCleaner() *MemoryOrphanMessageCleaner { + return &MemoryOrphanMessageCleaner{} } -func (c *SQLiteOrphanMessageCleaner) CleanupMessages(ctx context.Context, ids []domain.MessageID) error { - for _, id := range ids { - if err := c.db.Messages().Delete(ctx, id); err != nil { - return err - } - } +func (c *MemoryOrphanMessageCleaner) CleanupMessages(_ context.Context, _ []domain.MessageID) error { return nil } diff --git a/internal/app/chat/usecase/orphan_cleanup_test.go b/internal/app/chat/usecase/orphan_cleanup_test.go index 2a384abe..9bbda840 100644 --- a/internal/app/chat/usecase/orphan_cleanup_test.go +++ b/internal/app/chat/usecase/orphan_cleanup_test.go @@ -5,44 +5,24 @@ import ( "testing" "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" ) -func TestSQLiteOrphanMessageCleaner_CleanupMessages_Success(t *testing.T) { +func TestMemoryOrphanMessageCleaner_CleanupMessages_NoOp(t *testing.T) { t.Parallel() - db := dbtest.OpenTestDB(t) - cleaner := NewSQLiteOrphanMessageCleaner(db) + cleaner := NewMemoryOrphanMessageCleaner() - ids := make([]domain.MessageID, 0, 2) - for _, text := range []string{"hello", "world"} { - id, err := db.Messages().CreateUserMessage(context.Background(), "acct-1", "conv-1", text) - if err != nil { - t.Fatalf("CreateUserMessage() error = %v", err) - } - ids = append(ids, id) - } - - if err := cleaner.CleanupMessages(context.Background(), ids); err != nil { + if err := cleaner.CleanupMessages(context.Background(), []domain.MessageID{"msg-1", "msg-2"}); err != nil { t.Fatalf("CleanupMessages() error = %v", err) } - - for _, id := range ids { - if _, err := db.Messages().Get(context.Background(), id); err == nil { - t.Fatalf("message %s still exists after cleanup", id) - } - } } -func TestSQLiteOrphanMessageCleaner_CleanupMessages_ErrorOnMissingSchema(t *testing.T) { +func TestMemoryOrphanMessageCleaner_CleanupMessages_EmptyIDs(t *testing.T) { t.Parallel() - db := sqlitetest.OpenBareDB(t) - cleaner := NewSQLiteOrphanMessageCleaner(db) + cleaner := NewMemoryOrphanMessageCleaner() - err := cleaner.CleanupMessages(context.Background(), []domain.MessageID{"msg-1"}) - if err == nil { - t.Fatal("expected error, got nil") + if err := cleaner.CleanupMessages(context.Background(), nil); err != nil { + t.Fatalf("CleanupMessages() error = %v", err) } } diff --git a/internal/app/chat/usecase/runtime_deps.go b/internal/app/chat/usecase/runtime_deps.go index 21c4c199..16843053 100644 --- a/internal/app/chat/usecase/runtime_deps.go +++ b/internal/app/chat/usecase/runtime_deps.go @@ -4,7 +4,6 @@ import ( "context" chatboundary "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/sqlite" ) // RuntimeDeps contains orchestration dependencies for app/chat. @@ -17,14 +16,17 @@ type RuntimeDeps struct { EffectContext context.Context } -func NewRuntimeDeps(db sqlite.DB, client chatboundary.Client) RuntimeDeps { +// NewRuntimeDeps wires the chat orchestration dependencies. Chat is ephemeral: +// messages live only in memory for the session, so the persistence collaborators +// are in-memory stand-ins rather than SQLite-backed stores. +func NewRuntimeDeps(client chatboundary.Client) RuntimeDeps { gateway := NewChatBoundaryGateway(client) return RuntimeDeps{ StreamRunner: NewChatStreamRunner(gateway), StreamErrorMapper: NewChatBoundaryStreamErrorMapper(), - AssistantPersister: NewSQLiteAssistantPersister(db), - ToolLoop: NewSQLiteToolLoop(db), - OrphanCleaner: NewSQLiteOrphanMessageCleaner(db), + AssistantPersister: NewMemoryAssistantPersister(), + ToolLoop: NewMemoryToolLoop(), + OrphanCleaner: NewMemoryOrphanMessageCleaner(), EffectContext: context.Background(), } } diff --git a/internal/app/chat/usecase/tool_loop.go b/internal/app/chat/usecase/tool_loop.go index b65351f0..1c02636f 100644 --- a/internal/app/chat/usecase/tool_loop.go +++ b/internal/app/chat/usecase/tool_loop.go @@ -6,7 +6,6 @@ import ( corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" ) type PrepareNextTurnInput struct { @@ -26,15 +25,15 @@ type ToolLoop interface { PrepareNextTurn(ctx context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) } -type SQLiteToolLoop struct { - db sqlite.DB -} +// MemoryToolLoop prepares the next turn entirely in memory, minting a local +// message ID and appending the tool results to the in-memory session. +type MemoryToolLoop struct{} -func NewSQLiteToolLoop(db sqlite.DB) *SQLiteToolLoop { - return &SQLiteToolLoop{db: db} +func NewMemoryToolLoop() *MemoryToolLoop { + return &MemoryToolLoop{} } -func (t *SQLiteToolLoop) PrepareNextTurn(ctx context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) { +func (t *MemoryToolLoop) PrepareNextTurn(_ context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) { domainResults := make([]domain.ToolResult, len(input.Results)) for i, r := range input.Results { domainResults[i] = domain.ToolResult{ @@ -47,10 +46,7 @@ func (t *SQLiteToolLoop) PrepareNextTurn(ctx context.Context, input PrepareNextT } } - msgID, err := t.db.Messages().CreateToolResultMessage(ctx, input.AccountID, input.ConversationID, domainResults) - if err != nil { - msgID = domain.NewMessageID() - } + msgID := domain.NewMessageID() session := input.Session if session == nil { @@ -62,5 +58,5 @@ func (t *SQLiteToolLoop) PrepareNextTurn(ctx context.Context, input PrepareNextT MessageID: msgID, Messages: session.Messages(), ToolResultMessage: toolResultMessage, - }, err + }, nil } diff --git a/internal/app/chat/usecase/tool_loop_test.go b/internal/app/chat/usecase/tool_loop_test.go index a9bdb6de..5a8a9754 100644 --- a/internal/app/chat/usecase/tool_loop_test.go +++ b/internal/app/chat/usecase/tool_loop_test.go @@ -7,15 +7,12 @@ import ( corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" ) -func TestSQLiteToolLoop_PrepareNextTurn_AppendsToolResultToSession(t *testing.T) { +func TestMemoryToolLoop_PrepareNextTurn_AppendsToolResultToSession(t *testing.T) { t.Parallel() - db := dbtest.OpenTestDB(t) - loop := NewSQLiteToolLoop(db) + loop := NewMemoryToolLoop() session := corechat.NewSession("conv-1", []domain.Message{ { @@ -62,11 +59,10 @@ func TestSQLiteToolLoop_PrepareNextTurn_AppendsToolResultToSession(t *testing.T) } } -func TestSQLiteToolLoop_PrepareNextTurn_ContinuesOnPersistenceFailure(t *testing.T) { +func TestMemoryToolLoop_PrepareNextTurn_StartsSessionWhenNil(t *testing.T) { t.Parallel() - db := sqlitetest.OpenBareDB(t) // missing schema to force persistence failure - loop := NewSQLiteToolLoop(db) + loop := NewMemoryToolLoop() out, err := loop.PrepareNextTurn(context.Background(), PrepareNextTurnInput{ AccountID: "acct-1", @@ -76,11 +72,11 @@ func TestSQLiteToolLoop_PrepareNextTurn_ContinuesOnPersistenceFailure(t *testing }, Session: nil, }) - if err == nil { - t.Fatal("expected persistence error, got nil") + if err != nil { + t.Fatalf("PrepareNextTurn() error = %v", err) } if out.MessageID == "" { - t.Fatal("expected fallback generated MessageID") + t.Fatal("expected a generated MessageID") } if len(out.Messages) != 1 { t.Fatalf("messages len = %d, want 1", len(out.Messages)) diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index dadb1e5b..e8f4a71a 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -73,7 +73,7 @@ func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { // Create chat client with tool definitions m.chatClient = chatboundary.NewClient(m.cfg.ChatEndpoint, m.authService, m.scope, m.toolRegistry.Definitions()). WithAccountID(domain.AccountID(accountID)) - m.runtimeDeps = usecase.NewRuntimeDeps(m.db, m.chatClient).WithEffectContext(m.sessionCtx) + m.runtimeDeps = usecase.NewRuntimeDeps(m.chatClient).WithEffectContext(m.sessionCtx) return catalogCmd, nil } From 0d0f6b50bed2872be380e260dd8e73bd9bf12f6b Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 10:57:39 -0700 Subject: [PATCH 08/20] feat: re-point status surfaces to control-plane GraphQL reads Move the status-bar drawer tabs off the local PowerSync projection and onto direct GraphQL reads: - Issues surface reads the active-issue summary (priority facet) - Checks surface reads the product-check catalog grouped by domain - Edge instances surface shows the real fleet with sync recency - Log events surface reads datadog status coverage - Services tab reads service status summaries and per-service log events via new ListServiceStatuses / ListServiceLogEvents queries Add a StatusService + GetAccountStatusSummary that maps the nested datadog status into the account summary the surfaces render. Tab data injection switches from SetDB(sqlite.DB) to SetServices(ServiceSet); the sync dot and workspace count still read the runtime db. The chat empty-state summary is repointed too, removing chat's last db use. Remove the redundant Policies tab (policies moved to the Issue model; Issues is the canonical review queue). --- internal/app/app.go | 2 +- internal/app/chat/chat_test.go | 9 +- internal/app/chat/empty_state_poll_test.go | 55 +- internal/app/chat/model.go | 8 +- internal/app/chat/update_handlers.go | 10 +- internal/app/runtime_sync.go | 8 +- internal/app/statusbar/dispatch_test.go | 20 +- internal/app/statusbar/services/services.go | 42 +- .../app/statusbar/services/services_test.go | 8 +- internal/app/statusbar/statusbar.go | 17 +- internal/app/statusbar/statusbar_data.go | 20 +- internal/app/statusbar/statusbar_test.go | 1 - internal/app/statusbar/surfaces/surfaces.go | 222 +++---- .../app/statusbar/surfaces/surfaces_test.go | 39 +- internal/app/statusbar/tabs.go | 10 +- .../boundary/graphql/apitest/mock_client.go | 24 + internal/boundary/graphql/client.go | 31 + internal/boundary/graphql/gen/generated.go | 622 ++++++++++++++++++ .../graphql/gen/queries/status.graphql | 91 +++ internal/boundary/graphql/services.go | 2 + internal/boundary/graphql/status_service.go | 158 +++++ internal/domain/service_status.go | 2 + internal/tea/components/status/status.go | 2 + 23 files changed, 1168 insertions(+), 235 deletions(-) create mode 100644 internal/boundary/graphql/gen/queries/status.graphql create mode 100644 internal/boundary/graphql/status_service.go diff --git a/internal/app/app.go b/internal/app/app.go index cd83fd55..28e60def 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -210,7 +210,7 @@ func (m *Model) newChat() *chat.Model { m.account, m.workspace, m.theme, - m.db, + m.services, m.runtimeDeps, m.toolRegistry, m.scope, diff --git a/internal/app/chat/chat_test.go b/internal/app/chat/chat_test.go index 78d04abf..13fb1f9d 100644 --- a/internal/app/chat/chat_test.go +++ b/internal/app/chat/chat_test.go @@ -14,24 +14,25 @@ import ( appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/boundary/chat" "github.com/usetero/cli/internal/boundary/chat/chattest" + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/boundary/graphql/apitest" corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" domaintools "github.com/usetero/cli/internal/domain/tools" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/teatest" ) -// newTestChat creates a chat model with a real DB and a mock streaming client. +// newTestChat creates an ephemeral chat model with a mock streaming client. func newTestChat(t *testing.T, client chat.Client) *Model { t.Helper() theme := styles.NewTheme(true) scope := logtest.NewScope(t) - db := dbtest.OpenTestDB(t) + services := graphql.NewServiceSetFromClient(apitest.NewMockClient(), scope) runtimeDeps := usecase.NewRuntimeDeps(client) - m := New(nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, theme, db, runtimeDeps, nil, scope) + m := New(nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, theme, services, runtimeDeps, nil, scope) m.SetSize(80, 40) return m } diff --git a/internal/app/chat/empty_state_poll_test.go b/internal/app/chat/empty_state_poll_test.go index 4fd1dbb2..04fe7147 100644 --- a/internal/app/chat/empty_state_poll_test.go +++ b/internal/app/chat/empty_state_poll_test.go @@ -5,38 +5,45 @@ import ( "testing" "github.com/usetero/cli/internal/app/chat/usecase" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" "github.com/usetero/cli/internal/styles" ) -type stubDatadogAccountStatuses struct { - getSummary func(ctx context.Context) (domain.AccountSummary, error) +// stubStatus implements graphql.Status for the empty-state tests. +type stubStatus struct { + summary domain.AccountSummary } -func (s stubDatadogAccountStatuses) GetSummary(ctx context.Context) (domain.AccountSummary, error) { - return s.getSummary(ctx) +func (s stubStatus) GetAccountSummary(context.Context) (domain.AccountSummary, error) { + return s.summary, nil } -func TestEmptyStatePollDoesNotMutateSynchronously(t *testing.T) { - mockDB := sqlitetest.NewMockDB() - mockDB.DatadogAccountStatusesImpl = stubDatadogAccountStatuses{ - getSummary: func(context.Context) (domain.AccountSummary, error) { - return domain.AccountSummary{ServiceCount: 7}, nil - }, - } +func (s stubStatus) ListServiceStatuses(context.Context) ([]domain.ServiceStatus, error) { + return nil, nil +} + +func (s stubStatus) ListServiceLogEvents(context.Context, string) ([]domain.LogEventStatus, error) { + return nil, nil +} - m := New( +func newEmptyStateChat(t *testing.T, summary domain.AccountSummary) *Model { + t.Helper() + return New( nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, styles.NewTheme(true), - mockDB, - usecase.RuntimeDeps{}, + graphql.ServiceSet{Status: stubStatus{summary: summary}}, + usecase.RuntimeDeps{EffectContext: context.Background()}, nil, logtest.NewScope(t), ) +} + +func TestEmptyStatePollDoesNotMutateSynchronously(t *testing.T) { + m := newEmptyStateChat(t, domain.AccountSummary{ServiceCount: 7}) cmd := m.Update(emptyStatePollTickMsg{}) if cmd == nil { @@ -48,23 +55,7 @@ func TestEmptyStatePollDoesNotMutateSynchronously(t *testing.T) { } func TestEmptyStateSummaryMessageUpdatesState(t *testing.T) { - mockDB := sqlitetest.NewMockDB() - mockDB.DatadogAccountStatusesImpl = stubDatadogAccountStatuses{ - getSummary: func(context.Context) (domain.AccountSummary, error) { - return domain.AccountSummary{ServiceCount: 5, ActiveServices: 3}, nil - }, - } - - m := New( - nil, - domain.Account{ID: "acct-1"}, - domain.Workspace{ID: "ws-1"}, - styles.NewTheme(true), - mockDB, - usecase.RuntimeDeps{}, - nil, - logtest.NewScope(t), - ) + m := newEmptyStateChat(t, domain.AccountSummary{ServiceCount: 5, ActiveServices: 3}) msg := m.fetchEmptyStateSummary()() if _, ok := msg.(emptyStateSummaryLoadedMsg); !ok { diff --git a/internal/app/chat/model.go b/internal/app/chat/model.go index d3965c9c..c377c52e 100644 --- a/internal/app/chat/model.go +++ b/internal/app/chat/model.go @@ -11,10 +11,10 @@ import ( "github.com/usetero/cli/internal/app/chat/usecase" "github.com/usetero/cli/internal/app/chattools" "github.com/usetero/cli/internal/auth" + graphql "github.com/usetero/cli/internal/boundary/graphql" corechat "github.com/usetero/cli/internal/core/chat" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -70,7 +70,7 @@ type Model struct { policySummary *domain.AccountSummary // Dependencies - db sqlite.DB + services graphql.ServiceSet runtimeDeps usecase.RuntimeDeps toolRegistry *tools.Registry } @@ -90,7 +90,7 @@ func New( account domain.Account, workspace domain.Workspace, theme styles.Theme, - db sqlite.DB, + services graphql.ServiceSet, runtimeDeps usecase.RuntimeDeps, toolRegistry *tools.Registry, scope log.Scope, @@ -105,7 +105,7 @@ func New( account: account, workspace: workspace, theme: theme, - db: db, + services: services, runtimeDeps: runtimeDeps, toolRegistry: toolRegistry, } diff --git a/internal/app/chat/update_handlers.go b/internal/app/chat/update_handlers.go index f7032c1a..8ff30d6c 100644 --- a/internal/app/chat/update_handlers.go +++ b/internal/app/chat/update_handlers.go @@ -1,6 +1,7 @@ package chat import ( + "context" "time" "charm.land/bubbles/v2/key" @@ -8,7 +9,6 @@ import ( msgs "github.com/usetero/cli/internal/app/chat/events" corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/tea/keymap" ) @@ -36,18 +36,18 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) updateDispatch { } func (m *Model) handleEmptyStatePoll() tea.Cmd { - if m.hasMessages() || m.db == nil { + if m.hasMessages() || m.services.Status == nil { return nil // stop polling once messages exist } return tea.Batch(m.fetchEmptyStateSummary(), m.pollEmptyState()) } func (m *Model) fetchEmptyStateSummary() tea.Cmd { - db := m.db + status := m.services.Status return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(m.runtimeDeps.EffectContext, time.Second) + ctx, cancel := context.WithTimeout(m.runtimeDeps.EffectContext, time.Second) defer cancel() - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + summary, err := status.GetAccountSummary(ctx) return emptyStateSummaryLoadedMsg{summary: summary, err: err} } } diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index e8f4a71a..0b2030c4 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -52,8 +52,12 @@ func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { return nil, err } - // Start catalog status polling now that db is ready - catalogCmd := m.statusBar.SetDB(m.db) + // Start status polling: drawer tabs read from the account-scoped + // control-plane services; the sync indicator still reads the runtime db. + catalogCmd := tea.Batch( + m.statusBar.SetServices(m.services), + m.statusBar.SetDB(m.db), + ) // Create tool registry with executors m.toolRegistry = chattools.NewRegistry( diff --git a/internal/app/statusbar/dispatch_test.go b/internal/app/statusbar/dispatch_test.go index 0028cf1f..23f05db2 100644 --- a/internal/app/statusbar/dispatch_test.go +++ b/internal/app/statusbar/dispatch_test.go @@ -5,9 +5,9 @@ import ( tea "charm.land/bubbletea/v2" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/log/logtest" "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -101,15 +101,15 @@ type stubDrawerTab struct { onHandle func(msg tea.KeyPressMsg) tea.Cmd } -func (s stubDrawerTab) Label() string { return s.label } -func (s stubDrawerTab) SetDB(_ sqlite.DB) tea.Cmd { return nil } -func (s stubDrawerTab) Init() tea.Cmd { return nil } -func (s stubDrawerTab) Update(_ tea.Msg) tea.Cmd { return nil } -func (s stubDrawerTab) HasData() bool { return s.hasData } -func (s stubDrawerTab) CompactView() string { return "" } -func (s stubDrawerTab) ExpandedView(_, _ int) string { return "" } -func (s stubDrawerTab) Interactive() bool { return s.interactive } -func (s stubDrawerTab) InDetail() bool { return s.detail } +func (s stubDrawerTab) Label() string { return s.label } +func (s stubDrawerTab) SetServices(_ graphql.ServiceSet) tea.Cmd { return nil } +func (s stubDrawerTab) Init() tea.Cmd { return nil } +func (s stubDrawerTab) Update(_ tea.Msg) tea.Cmd { return nil } +func (s stubDrawerTab) HasData() bool { return s.hasData } +func (s stubDrawerTab) CompactView() string { return "" } +func (s stubDrawerTab) ExpandedView(_, _ int) string { return "" } +func (s stubDrawerTab) Interactive() bool { return s.interactive } +func (s stubDrawerTab) InDetail() bool { return s.detail } func (s stubDrawerTab) CloseDetail() { if s.onClose != nil { s.onClose() diff --git a/internal/app/statusbar/services/services.go b/internal/app/statusbar/services/services.go index 3b085b35..dcb5a5da 100644 --- a/internal/app/statusbar/services/services.go +++ b/internal/app/statusbar/services/services.go @@ -15,10 +15,10 @@ import ( "github.com/usetero/cli/internal/app/statusbar/listdetail" "github.com/usetero/cli/internal/app/statusbar/tabpoll" "github.com/usetero/cli/internal/app/statusbar/viewkit" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/format" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/components/status" "github.com/usetero/cli/internal/tea/components/table" @@ -26,8 +26,7 @@ import ( const ( pollInterval = 2 * time.Second - maxServices = 50 - dbTimeout = 2 * time.Second + fetchTimeout = 2 * time.Second pollSource = "services" // levelDisplayThreshold is the minimum fraction of total volume a @@ -51,7 +50,9 @@ type serviceDetailLoadedMsg struct { type Model struct { theme styles.Theme scope log.Scope - db sqlite.DB + + api graphql.ServiceSet + ready bool summary domain.AccountSummary services []domain.ServiceStatus @@ -72,15 +73,16 @@ func New(theme styles.Theme, scope log.Scope) *Model { } } -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - m.db = db +// SetServices points the tab at the account-scoped services and starts polling. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + m.api = services + m.ready = true return m.poll() } -// Init starts polling. +// Init starts polling once the services are available. func (m *Model) Init() tea.Cmd { - if m.db == nil { + if !m.ready { return nil } return m.poll() @@ -95,7 +97,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if cmd, handled := tabpoll.UpdatePollCycle( msg, pollSource, - m.db != nil, + m.ready, &m.fetching, m.fetchData(), m.poll(), @@ -123,31 +125,31 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { // fetchData returns a Cmd that queries service data off the event loop. func (m *Model) fetchData() tea.Cmd { - db := m.db + services := m.api scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (fetchedData, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) + return tabpoll.Fetch(fetchTimeout, func(ctx context.Context) (fetchedData, error) { + summary, err := services.Status.GetAccountSummary(ctx) if err != nil { scope.Error("get summary", "err", err) return fetchedData{}, err } - services, err := db.ServiceStatuses().ListEnabledServiceStatuses(ctx, maxServices) + statuses, err := services.Status.ListServiceStatuses(ctx) if err != nil { scope.Error("list service statuses", "err", err) - services = nil + statuses = nil } - return fetchedData{summary: summary, services: services}, nil + return fetchedData{summary: summary, services: statuses}, nil }) } // fetchDetail returns a Cmd that queries log event detail off the event loop. func (m *Model) fetchDetail(svc domain.ServiceStatus) tea.Cmd { - db := m.db + services := m.api scope := m.scope - return tabpoll.FetchDetail(dbTimeout, func(ctx context.Context) ([]domain.LogEventStatus, error) { - logEvents, err := db.LogEventStatuses().ListByService(ctx, svc.Name, 25) + return tabpoll.FetchDetail(fetchTimeout, func(ctx context.Context) ([]domain.LogEventStatus, error) { + logEvents, err := services.Status.ListServiceLogEvents(ctx, svc.ID) if err != nil { scope.Error("list log event statuses", "service", svc.Name, "err", err) return nil, err @@ -240,7 +242,7 @@ func (m *Model) ExpandedView(width, height int) string { if !m.hasData { return viewkit.RenderServicesEmptyState( m.theme, - m.db != nil, + m.ready, m.summary, "Ask Tero to explore your services and pick which ones to enable.", ) diff --git a/internal/app/statusbar/services/services_test.go b/internal/app/statusbar/services/services_test.go index 6ce11f80..0ad5c5bb 100644 --- a/internal/app/statusbar/services/services_test.go +++ b/internal/app/statusbar/services/services_test.go @@ -5,15 +5,14 @@ import ( "testing" "github.com/usetero/cli/internal/app/statusbar/tabpoll" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" "github.com/usetero/cli/internal/styles" ) func TestUpdatePollSourceFiltering(t *testing.T) { m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) + m.SetServices(graphql.ServiceSet{}) if cmd := m.Update(tabpoll.PollMsg{Source: "other"}); cmd != nil { t.Fatalf("expected foreign poll source to be ignored") @@ -26,8 +25,7 @@ func TestUpdatePollSourceFiltering(t *testing.T) { func TestUpdateDoesNotStartOverlappingFetch(t *testing.T) { m := New(styles.NewTheme(true), logtest.NewScope(t)) - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) + m.SetServices(graphql.ServiceSet{}) if cmd := m.Update(tabpoll.PollMsg{Source: pollSource}); cmd == nil { t.Fatalf("expected poll to schedule fetch") diff --git a/internal/app/statusbar/statusbar.go b/internal/app/statusbar/statusbar.go index 743f94a2..10125965 100644 --- a/internal/app/statusbar/statusbar.go +++ b/internal/app/statusbar/statusbar.go @@ -22,17 +22,16 @@ type workspaceCountLoadedMsg struct { // Tab indices for the drawer. const ( - TabPolicies = 0 - TabIssues = 1 - TabChecks = 2 - TabServices = 3 - TabLogEvents = 4 - TabEdgeInstances = 5 - tabCount = 6 + TabIssues = 0 + TabChecks = 1 + TabServices = 2 + TabLogEvents = 3 + TabEdgeInstances = 4 + tabCount = 5 ) // Tab labels. -var tabLabels = [tabCount]string{"Policies", "Issues", "Checks", "Services", "Log events", "Edge instances"} +var tabLabels = [tabCount]string{"Issues", "Checks", "Services", "Log events", "Edge instances"} // Model renders the app status bar. type Model struct { @@ -41,7 +40,6 @@ type Model struct { env string tabs []drawerTab syncStatus *syncstatus.Model - policiesStatus *surfaces.Model issuesStatus *surfaces.Model checksStatus *surfaces.Model servicesStatus *services.Model @@ -73,7 +71,6 @@ func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, host stri scope: scope, env: env, syncStatus: syncstatus.New(theme, scope, syncer, host), - policiesStatus: surfaces.NewPolicies(theme, scope), issuesStatus: surfaces.NewIssues(theme, scope), checksStatus: surfaces.NewChecks(theme, scope), servicesStatus: services.New(theme, scope), diff --git a/internal/app/statusbar/statusbar_data.go b/internal/app/statusbar/statusbar_data.go index 81e2b624..50422781 100644 --- a/internal/app/statusbar/statusbar_data.go +++ b/internal/app/statusbar/statusbar_data.go @@ -5,21 +5,29 @@ import ( tea "charm.land/bubbletea/v2" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/sqlite" ) -// SetDB sets the database for status polling. +// SetServices points the drawer tabs at the account-scoped control-plane +// services. Each tab polls its own GraphQL reads from here. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + cmds := make([]tea.Cmd, 0, len(m.tabs)) + for _, tab := range m.tabs { + cmds = append(cmds, tab.SetServices(services)) + } + return tea.Batch(cmds...) +} + +// SetDB feeds the sync indicator and workspace count, which still read the +// local runtime database. The drawer tabs are fed via SetServices. // // syncStatus is fed alongside the drawer tabs even though it is no longer a // drawer tab itself: its compact sync dot lives in the brand segment and its // pending-upload count needs the runtime database. func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - cmds := []tea.Cmd{m.fetchWorkspaceCount(db), m.syncStatus.SetDB(db)} - for _, tab := range m.tabs { - cmds = append(cmds, tab.SetDB(db)) - } - return tea.Batch(cmds...) + return tea.Batch(m.fetchWorkspaceCount(db), m.syncStatus.SetDB(db)) } // Init initializes child models. diff --git a/internal/app/statusbar/statusbar_test.go b/internal/app/statusbar/statusbar_test.go index 3bc20231..42817555 100644 --- a/internal/app/statusbar/statusbar_test.go +++ b/internal/app/statusbar/statusbar_test.go @@ -79,7 +79,6 @@ func TestBuildTabsMirrorsProductSurfaces(t *testing.T) { group string label string }{ - {group: "Control Plane", label: "Policies"}, {group: "Control Plane", label: "Issues"}, {group: "Control Plane", label: "Checks"}, {group: "Data Plane", label: "Services"}, diff --git a/internal/app/statusbar/surfaces/surfaces.go b/internal/app/statusbar/surfaces/surfaces.go index 68cb8649..09444b31 100644 --- a/internal/app/statusbar/surfaces/surfaces.go +++ b/internal/app/statusbar/surfaces/surfaces.go @@ -11,20 +11,24 @@ import ( "charm.land/lipgloss/v2" "github.com/usetero/cli/internal/app/statusbar/tabpoll" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/format" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/components/table" ) const ( pollInterval = 2 * time.Second - dbTimeout = 2 * time.Second + fetchTimeout = 2 * time.Second + + // edgeConnectedWindow is how recently an edge instance must have synced to + // count as connected. + edgeConnectedWindow = 10 * time.Minute ) -type fetchFunc func(context.Context, sqlite.DB) (Snapshot, error) +type fetchFunc func(context.Context, graphql.ServiceSet) (Snapshot, error) // Metric is one line of supporting state for a product surface. type Metric struct { @@ -47,7 +51,9 @@ type Snapshot struct { type Model struct { theme styles.Theme scope log.Scope - db sqlite.DB + + services graphql.ServiceSet + ready bool source string fetch fetchFunc @@ -58,11 +64,6 @@ type Model struct { lastState string } -// NewPolicies creates the policies surface. -func NewPolicies(theme styles.Theme, scope log.Scope) *Model { - return newModel(theme, scope, "policies", fetchPolicies) -} - // NewIssues creates the issues surface. func NewIssues(theme styles.Theme, scope log.Scope) *Model { return newModel(theme, scope, "issues", fetchIssues) @@ -92,15 +93,16 @@ func newModel(theme styles.Theme, scope log.Scope, source string, fetch fetchFun } } -// SetDB sets the database and starts polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - m.db = db +// SetServices points the surface at the account-scoped services and starts polling. +func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { + m.services = services + m.ready = true return m.poll() } -// Init starts polling when the runtime database is available. +// Init starts polling once the services are available. func (m *Model) Init() tea.Cmd { - if m.db == nil { + if !m.ready { return nil } return m.poll() @@ -115,7 +117,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if cmd, handled := tabpoll.UpdatePollCycle( msg, m.source, - m.db != nil, + m.ready, &m.fetching, m.fetchData(), m.poll(), @@ -133,11 +135,11 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } func (m *Model) fetchData() tea.Cmd { - db := m.db + services := m.services fetch := m.fetch scope := m.scope - return tabpoll.Fetch(dbTimeout, func(ctx context.Context) (Snapshot, error) { - snapshot, err := fetch(ctx, db) + return tabpoll.Fetch(fetchTimeout, func(ctx context.Context) (Snapshot, error) { + snapshot, err := fetch(ctx, services) if err != nil { scope.Error("fetch surface", "err", err) return Snapshot{}, err @@ -219,101 +221,65 @@ func (m *Model) renderRows(width int) string { return tbl.View() } -func fetchPolicies(ctx context.Context, db sqlite.DB) (Snapshot, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) - if err != nil { - return Snapshot{}, err - } - total, err := db.LogEventPolicies().Count(ctx) - if err != nil { - total = summary.PendingPolicyCount + summary.ApprovedPolicyCount + summary.DismissedPolicyCount - } - - return Snapshot{ - Title: "Policies", - Description: "Reviewable policy state synced from the control plane.", - Primary: Metric{Label: "total", Value: count(total)}, - Metrics: []Metric{ - {Label: "pending", Value: count(summary.PendingPolicyCount), Tone: pendingTone(summary.PendingPolicyCount)}, - {Label: "approved", Value: count(summary.ApprovedPolicyCount), Tone: "success"}, - {Label: "dismissed", Value: count(summary.DismissedPolicyCount)}, - }, - Rows: [][]string{ - {"Awaiting review", count(summary.PendingPolicyCount), highSignal(summary)}, - {"Approved", count(summary.ApprovedPolicyCount), "running policy decisions"}, - {"Dismissed", count(summary.DismissedPolicyCount), "reviewed and set aside"}, - }, - Loaded: true, - }, nil -} - -func fetchIssues(ctx context.Context, db sqlite.DB) (Snapshot, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) +func fetchIssues(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + summary, err := services.Issues.GetSummary(ctx) if err != nil { return Snapshot{}, err } - high := summary.PolicyPendingCriticalCount + summary.PolicyPendingHighCount return Snapshot{ Title: "Issues", - Description: "Policy-remediable issues currently awaiting operator judgment.", - Primary: Metric{Label: "open", Value: count(summary.PendingPolicyCount), Tone: pendingTone(summary.PendingPolicyCount)}, + Description: "Active issues awaiting operator judgment.", + Primary: Metric{Label: "open", Value: count(summary.Open), Tone: pendingTone(summary.Open)}, Metrics: []Metric{ - {Label: "high", Value: count(high), Tone: highTone(high)}, - {Label: "medium", Value: count(summary.PolicyPendingMediumCount), Tone: pendingTone(summary.PolicyPendingMediumCount)}, - {Label: "low", Value: count(summary.PolicyPendingLowCount)}, + {Label: "high", Value: count(summary.HighCount), Tone: highTone(summary.HighCount)}, + {Label: "medium", Value: count(summary.MediumCount), Tone: pendingTone(summary.MediumCount)}, + {Label: "low", Value: count(summary.LowCount)}, }, Rows: [][]string{ - {"Critical", count(summary.PolicyPendingCriticalCount), "needs immediate review"}, - {"High", count(summary.PolicyPendingHighCount), "high-priority review queue"}, - {"Medium", count(summary.PolicyPendingMediumCount), "normal review queue"}, - {"Low", count(summary.PolicyPendingLowCount), "background cleanup"}, + {"High", count(summary.HighCount), "high-priority review queue"}, + {"Medium", count(summary.MediumCount), "normal review queue"}, + {"Low", count(summary.LowCount), "background cleanup"}, }, Loaded: true, }, nil } -func fetchChecks(ctx context.Context, db sqlite.DB) (Snapshot, error) { - statuses := db.LogEventPolicyCategoryStatuses() - waste, err := statuses.ListWasteCategoryStatuses(ctx) +func fetchChecks(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + catalog, err := services.Checks.List(ctx) if err != nil { return Snapshot{}, err } - quality, err := statuses.ListQualityCategoryStatuses(ctx) - if err != nil { - return Snapshot{}, err - } - compliance, err := statuses.ListComplianceCategoryStatuses(ctx) - if err != nil { - return Snapshot{}, err + + var openFindings, activeIssues int64 + for _, check := range catalog.Checks { + openFindings += check.OpenFindingCount + activeIssues += check.ActiveIssueCount } return Snapshot{ Title: "Checks", - Description: "Control-plane check categories represented in the local projection.", - Primary: Metric{Label: "categories", Value: count(int64(len(waste) + len(quality) + len(compliance)))}, + Description: "Product checks and their account-scoped posture.", + Primary: Metric{Label: "checks", Value: count(catalog.Total)}, Metrics: []Metric{ - {Label: "cost", Value: count(int64(len(waste)))}, - {Label: "quality", Value: count(int64(len(quality)))}, - {Label: "compliance", Value: count(int64(len(compliance)))}, + {Label: "cost", Value: count(catalog.DomainCount(domain.CheckDomainCost))}, + {Label: "compliance", Value: count(catalog.DomainCount(domain.CheckDomainCompliance))}, + {Label: "active issues", Value: count(activeIssues), Tone: pendingTone(activeIssues)}, }, Rows: [][]string{ - {"Cost", categorySummary(waste), pendingCategorySignal(waste)}, - {"Data quality", categorySummary(quality), pendingCategorySignal(quality)}, - {"Compliance", categorySummary(compliance), pendingCategorySignal(compliance)}, + {"Cost", checkDomainSummary(catalog, domain.CheckDomainCost), "spend-reduction checks"}, + {"Compliance", checkDomainSummary(catalog, domain.CheckDomainCompliance), "data-protection checks"}, + {"Open findings", count(openFindings), "across all checks"}, }, Loaded: true, }, nil } -func fetchLogEvents(ctx context.Context, db sqlite.DB) (Snapshot, error) { - summary, err := db.DatadogAccountStatuses().GetSummary(ctx) +func fetchLogEvents(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + summary, err := services.Status.GetAccountSummary(ctx) if err != nil { return Snapshot{}, err } - total, err := db.LogEvents().Count(ctx) - if err != nil { - total = summary.EventCount - } + total := summary.EventCount coverage := "not analyzed" if total > 0 { coverage = fmt.Sprintf("%d%% analyzed", int(float64(summary.AnalyzedCount)/float64(total)*100)) @@ -340,19 +306,33 @@ func fetchLogEvents(ctx context.Context, db sqlite.DB) (Snapshot, error) { }, nil } -func fetchEdgeInstances(context.Context, sqlite.DB) (Snapshot, error) { +func fetchEdgeInstances(ctx context.Context, services graphql.ServiceSet) (Snapshot, error) { + fleet, err := services.EdgeInstances.List(ctx) + if err != nil { + return Snapshot{}, err + } + connected := fleet.ConnectedCount(time.Now(), edgeConnectedWindow) + + rows := make([][]string, 0, len(fleet.Instances)) + for _, inst := range fleet.Instances { + state := "idle" + if inst.LastSyncAt.After(time.Now().Add(-edgeConnectedWindow)) { + state = "connected" + } + rows = append(rows, []string{inst.ServiceName, state, "last sync " + relativeTime(inst.LastSyncAt)}) + } + if len(rows) == 0 { + rows = [][]string{{"Runtime", "none", "no edge instances registered yet"}} + } + return Snapshot{ Title: "Edge instances", - Description: "Edge runtime projection is not synced into this CLI yet.", - Primary: Metric{Label: "instances", Value: "0"}, + Description: "Edge runtimes syncing policies from this account.", + Primary: Metric{Label: "instances", Value: count(fleet.Total)}, Metrics: []Metric{ - {Label: "connected", Value: "0"}, - {Label: "projection", Value: "pending"}, - }, - Rows: [][]string{ - {"Runtime", "pending", "waiting for edge instance sync"}, - {"Control plane", "available in webapp", "CLI surface reserved"}, + {Label: "connected", Value: count(connected), Tone: connectedTone(connected, fleet.Total)}, }, + Rows: rows, Loaded: true, }, nil } @@ -378,6 +358,21 @@ func count(n int64) string { return fmt.Sprintf("%d", n) } +// relativeTime renders a compact "Xm ago" style duration since t. +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + func pendingTone(n int64) string { if n > 0 { return "warning" @@ -392,12 +387,17 @@ func highTone(n int64) string { return "success" } -func highSignal(summary domain.AccountSummary) string { - high := summary.PolicyPendingCriticalCount + summary.PolicyPendingHighCount - if high > 0 { - return fmt.Sprintf("%d high priority", high) +func connectedTone(connected, total int64) string { + if total == 0 { + return "" + } + if connected == 0 { + return "danger" + } + if connected < total { + return "warning" } - return "no high-priority issues" + return "success" } func analyzedTone(summary domain.AccountSummary, total int64) string { @@ -410,28 +410,14 @@ func analyzedTone(summary domain.AccountSummary, total int64) string { return "warning" } -func categorySummary(categories []domain.PolicyCategoryStatus) string { - var pending int64 - for _, category := range categories { - pending += category.PendingCount - } - return fmt.Sprintf("%d categories, %d pending", len(categories), pending) -} - -func pendingCategorySignal(categories []domain.PolicyCategoryStatus) string { - var high int64 - var estimatedCost float64 - for _, category := range categories { - high += category.PolicyPendingCriticalCount + category.PolicyPendingHighCount - if category.EstimatedCostPerHour != nil { - estimatedCost += *category.EstimatedCostPerHour +func checkDomainSummary(catalog domain.CheckCatalog, d domain.CheckDomain) string { + var openFindings, activeIssues int64 + for _, check := range catalog.Checks { + if check.Domain != d { + continue } + openFindings += check.OpenFindingCount + activeIssues += check.ActiveIssueCount } - if high > 0 { - return fmt.Sprintf("%d high-priority policies", high) - } - if estimatedCost > 0 { - return format.YearlyCost(estimatedCost) - } - return "no high-priority policies" + return fmt.Sprintf("%d checks, %d findings, %d issues", catalog.DomainCount(d), openFindings, activeIssues) } diff --git a/internal/app/statusbar/surfaces/surfaces_test.go b/internal/app/statusbar/surfaces/surfaces_test.go index d173f8f1..ca07291f 100644 --- a/internal/app/statusbar/surfaces/surfaces_test.go +++ b/internal/app/statusbar/surfaces/surfaces_test.go @@ -43,22 +43,37 @@ func TestCompactViewHidesEmptyAndZeroPrimary(t *testing.T) { } } -func TestPendingCategorySignalBranches(t *testing.T) { - cost := 4.0 - - highPriority := []domain.PolicyCategoryStatus{{PolicyPendingHighCount: 2, EstimatedCostPerHour: &cost}} - if got := pendingCategorySignal(highPriority); !strings.Contains(got, "high-priority") { - t.Fatalf("expected high-priority signal to win, got %q", got) +func TestCheckDomainSummaryAggregatesByDomain(t *testing.T) { + catalog := domain.CheckCatalog{ + Checks: []domain.Check{ + {Domain: domain.CheckDomainCost, OpenFindingCount: 3, ActiveIssueCount: 1}, + {Domain: domain.CheckDomainCost, OpenFindingCount: 2, ActiveIssueCount: 0}, + {Domain: domain.CheckDomainCompliance, OpenFindingCount: 9, ActiveIssueCount: 4}, + }, + ByDomain: map[domain.CheckDomain]int64{ + domain.CheckDomainCost: 2, + domain.CheckDomainCompliance: 1, + }, } - costOnly := []domain.PolicyCategoryStatus{{EstimatedCostPerHour: &cost}} - if got := pendingCategorySignal(costOnly); strings.Contains(got, "high-priority") || got == "" { - t.Fatalf("expected a cost signal with no high-priority work, got %q", got) + got := checkDomainSummary(catalog, domain.CheckDomainCost) + if !strings.Contains(got, "2 checks") || !strings.Contains(got, "5 findings") || !strings.Contains(got, "1 issues") { + t.Fatalf("cost summary = %q, want 2 checks / 5 findings / 1 issues", got) } +} - idle := []domain.PolicyCategoryStatus{{}} - if got := pendingCategorySignal(idle); got != "no high-priority policies" { - t.Fatalf("expected idle signal, got %q", got) +func TestConnectedToneBranches(t *testing.T) { + if got := connectedTone(0, 0); got != "" { + t.Fatalf("expected no tone with zero fleet, got %q", got) + } + if got := connectedTone(0, 3); got != "danger" { + t.Fatalf("expected danger when none connected, got %q", got) + } + if got := connectedTone(1, 3); got != "warning" { + t.Fatalf("expected warning when partially connected, got %q", got) + } + if got := connectedTone(3, 3); got != "success" { + t.Fatalf("expected success when all connected, got %q", got) } } diff --git a/internal/app/statusbar/tabs.go b/internal/app/statusbar/tabs.go index 89365937..2d52d58c 100644 --- a/internal/app/statusbar/tabs.go +++ b/internal/app/statusbar/tabs.go @@ -3,12 +3,13 @@ package statusbar import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/sqlite" + graphql "github.com/usetero/cli/internal/boundary/graphql" ) // TabModel is the base contract every status bar tab model must satisfy. +// Tabs read from control-plane GraphQL services, not a local database. type TabModel interface { - SetDB(db sqlite.DB) tea.Cmd + SetServices(services graphql.ServiceSet) tea.Cmd Init() tea.Cmd Update(msg tea.Msg) tea.Cmd HasData() bool @@ -26,7 +27,7 @@ type InteractiveTabModel interface { type drawerTab interface { Label() string - SetDB(db sqlite.DB) tea.Cmd + SetServices(services graphql.ServiceSet) tea.Cmd Init() tea.Cmd Update(msg tea.Msg) tea.Cmd HasData() bool @@ -56,7 +57,7 @@ func (t tab) Label() string { return t.label } func (t tab) GroupLabel() string { return t.group } -func (t tab) SetDB(db sqlite.DB) tea.Cmd { return t.model.SetDB(db) } +func (t tab) SetServices(services graphql.ServiceSet) tea.Cmd { return t.model.SetServices(services) } func (t tab) Init() tea.Cmd { return t.model.Init() } @@ -97,7 +98,6 @@ func (t tab) HandleKeyPress(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) buildTabs() []drawerTab { return []drawerTab{ - newTab("Control Plane", tabLabels[TabPolicies], m.policiesStatus), newTab("Control Plane", tabLabels[TabIssues], m.issuesStatus), newTab("Control Plane", tabLabels[TabChecks], m.checksStatus), newTab("Data Plane", tabLabels[TabServices], m.servicesStatus), diff --git a/internal/boundary/graphql/apitest/mock_client.go b/internal/boundary/graphql/apitest/mock_client.go index f909de77..cc603424 100644 --- a/internal/boundary/graphql/apitest/mock_client.go +++ b/internal/boundary/graphql/apitest/mock_client.go @@ -26,6 +26,9 @@ type MockClient struct { GetIssueSummaryFunc func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) ListChecksFunc func(ctx context.Context) (*gen.ListChecksResponse, error) ListEdgeInstancesFunc func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) + GetAccountStatusSummaryFunc func(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) + ListServiceStatusesFunc func(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) + ListServiceLogEventsFunc func(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -146,3 +149,24 @@ func (m *MockClient) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstan } return nil, nil } + +func (m *MockClient) GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) { + if m.GetAccountStatusSummaryFunc != nil { + return m.GetAccountStatusSummaryFunc(ctx) + } + return nil, nil +} + +func (m *MockClient) ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) { + if m.ListServiceStatusesFunc != nil { + return m.ListServiceStatusesFunc(ctx, first) + } + return nil, nil +} + +func (m *MockClient) ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) { + if m.ListServiceLogEventsFunc != nil { + return m.ListServiceLogEventsFunc(ctx, serviceID, first) + } + return nil, nil +} diff --git a/internal/boundary/graphql/client.go b/internal/boundary/graphql/client.go index 46aaab7f..d898a116 100644 --- a/internal/boundary/graphql/client.go +++ b/internal/boundary/graphql/client.go @@ -53,6 +53,11 @@ type Client interface { GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) + + // Data-plane status reads + GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) + ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) + ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) } // client is the concrete implementation of Client. @@ -293,3 +298,29 @@ func (c *client) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesR } return gen.ListEdgeInstances(ctx, gql) } + +// Data-plane status reads + +func (c *client) GetAccountStatusSummary(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.GetAccountStatusSummary(ctx, gql) +} + +func (c *client) ListServiceStatuses(ctx context.Context, first int) (*gen.ListServiceStatusesResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ListServiceStatuses(ctx, gql, first) +} + +func (c *client) ListServiceLogEvents(ctx context.Context, serviceID string, first int) (*gen.ListServiceLogEventsResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ListServiceLogEvents(ctx, gql, serviceID, first) +} diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index 57e635a2..a8f44def 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -387,6 +387,194 @@ type GetAccountResponse struct { // GetAccounts returns GetAccountResponse.Accounts, and is useful for accessing the field via an interface. func (v *GetAccountResponse) GetAccounts() GetAccountAccountsAccountConnection { return v.Accounts } +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection struct { + Edges []GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` +} + +// GetEdges returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection.Edges, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection) GetEdges() []GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge { + return v.Edges +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge includes the requested fields of the GraphQL type DatadogAccountEdge. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge struct { + Node GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount `json:"node"` +} + +// GetNode returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge.Node, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge) GetNode() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount { + return v.Node +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount includes the requested fields of the GraphQL type DatadogAccount. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount struct { + // Status of this Datadog account in the catalog pipeline. + // Derived from the status of all services discovered from this account. + // Returns null if the status view has no row yet. + Status *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus `json:"status"` +} + +// GetStatus returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount.Status, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccount) GetStatus() *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus { + return v.Status +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus includes the requested fields of the GraphQL type DatadogAccountStatus. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus struct { + Health StatusHealth `json:"health"` + Readiness GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness `json:"readiness"` + Coverage GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage `json:"coverage"` + Current GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus `json:"current"` +} + +// GetHealth returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Health, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetHealth() StatusHealth { + return v.Health +} + +// GetReadiness returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Readiness, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetReadiness() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness { + return v.Readiness +} + +// GetCoverage returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Coverage, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCoverage() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage { + return v.Coverage +} + +// GetCurrent returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus.Current, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatus) GetCurrent() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus { + return v.Current +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage includes the requested fields of the GraphQL type DatadogAccountStatusCoverage. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage struct { + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + LogServiceCount int `json:"logServiceCount"` + LogActiveServices int `json:"logActiveServices"` + DisabledServices int `json:"disabledServices"` + InactiveServices int `json:"inactiveServices"` + OkServices int `json:"okServices"` +} + +// GetLogEventCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventCount() int { + return v.LogEventCount +} + +// GetLogEventAnalyzedCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogEventAnalyzedCount() int { + return v.LogEventAnalyzedCount +} + +// GetLogServiceCount returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogServiceCount, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogServiceCount() int { + return v.LogServiceCount +} + +// GetLogActiveServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.LogActiveServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetLogActiveServices() int { + return v.LogActiveServices +} + +// GetDisabledServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.DisabledServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetDisabledServices() int { + return v.DisabledServices +} + +// GetInactiveServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.InactiveServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetInactiveServices() int { + return v.InactiveServices +} + +// GetOkServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage.OkServices, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCoverage) GetOkServices() int { + return v.OkServices +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus includes the requested fields of the GraphQL type DatadogAccountCurrentStatus. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus struct { + Services GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals `json:"services"` + Totals GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals `json:"totals"` +} + +// GetServices returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus.Services, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus) GetServices() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals { + return v.Services +} + +// GetTotals returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus.Totals, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatus) GetTotals() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals { + return v.Totals +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals includes the requested fields of the GraphQL type StatusServiceTotals. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + VolumeUsdPerHour *float64 `json:"volumeUsdPerHour"` +} + +// GetEventsPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour +} + +// GetVolumeUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals.VolumeUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusServicesStatusServiceTotals) GetVolumeUsdPerHour() *float64 { + return v.VolumeUsdPerHour +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` + VolumeUsdPerHour *float64 `json:"volumeUsdPerHour"` +} + +// GetEventsPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour +} + +// GetBytesPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.BytesPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetBytesPerHour() *float64 { + return v.BytesPerHour +} + +// GetTotalUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// GetVolumeUsdPerHour returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals.VolumeUsdPerHour, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusCurrentDatadogAccountCurrentStatusTotalsStatusMeasurementTotals) GetVolumeUsdPerHour() *float64 { + return v.VolumeUsdPerHour +} + +// GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness includes the requested fields of the GraphQL type StatusReadiness. +type GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness struct { + ReadyForUse bool `json:"readyForUse"` +} + +// GetReadyForUse returns GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness.ReadyForUse, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdgeNodeDatadogAccountStatusReadiness) GetReadyForUse() bool { + return v.ReadyForUse +} + +// GetAccountStatusSummaryResponse is returned by GetAccountStatusSummary on success. +type GetAccountStatusSummaryResponse struct { + // Query DatadogAccounts records in your account. + DatadogAccounts GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection `json:"datadogAccounts"` +} + +// GetDatadogAccounts returns GetAccountStatusSummaryResponse.DatadogAccounts, and is useful for accessing the field via an interface. +func (v *GetAccountStatusSummaryResponse) GetDatadogAccounts() GetAccountStatusSummaryDatadogAccountsDatadogAccountConnection { + return v.DatadogAccounts +} + // GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection includes the requested fields of the GraphQL type DatadogAccountConnection. type GetDatadogAccountStatusDatadogAccountsDatadogAccountConnection struct { Edges []GetDatadogAccountStatusDatadogAccountsDatadogAccountConnectionEdgesDatadogAccountEdge `json:"edges"` @@ -1174,6 +1362,246 @@ func (v *ListOrganizationsResponse) GetOrganizations() ListOrganizationsOrganiza return v.Organizations } +// ListServiceLogEventsLogEventsLogEventConnection includes the requested fields of the GraphQL type LogEventConnection. +type ListServiceLogEventsLogEventsLogEventConnection struct { + Edges []ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge `json:"edges"` +} + +// GetEdges returns ListServiceLogEventsLogEventsLogEventConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnection) GetEdges() []ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge { + return v.Edges +} + +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge includes the requested fields of the GraphQL type LogEventEdge. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge struct { + Node ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent `json:"node"` +} + +// GetNode returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge.Node, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdge) GetNode() ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent { + return v.Node +} + +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent includes the requested fields of the GraphQL type LogEvent. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent struct { + // Snake_case identifier unique per service, e.g. nginx_access_log + Name string `json:"name"` + // Human-readable title for the log event, suitable for product surfaces. + DisplayName *string `json:"displayName"` + // Status of this log event. + // Shows where the log event is in the preparation pipeline. + // Returns null if the status view has no row yet. + Status *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus `json:"status"` +} + +// GetName returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Name, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetName() string { + return v.Name +} + +// GetDisplayName returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.DisplayName, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetDisplayName() *string { + return v.DisplayName +} + +// GetStatus returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent.Status, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEvent) GetStatus() *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus { + return v.Status +} + +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus includes the requested fields of the GraphQL type LogEventStatus. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus struct { + Current ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals `json:"current"` +} + +// GetCurrent returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus.Current, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatus) GetCurrent() ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals { + return v.Current +} + +// ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` +} + +// GetEventsPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.EventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetEventsPerHour() *float64 { + return v.EventsPerHour +} + +// GetBytesPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.BytesPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetBytesPerHour() *float64 { + return v.BytesPerHour +} + +// GetTotalUsdPerHour returns ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsLogEventsLogEventConnectionEdgesLogEventEdgeNodeLogEventStatusCurrentStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// ListServiceLogEventsResponse is returned by ListServiceLogEvents on success. +type ListServiceLogEventsResponse struct { + // Query LogEvents records in your account. + LogEvents ListServiceLogEventsLogEventsLogEventConnection `json:"logEvents"` +} + +// GetLogEvents returns ListServiceLogEventsResponse.LogEvents, and is useful for accessing the field via an interface. +func (v *ListServiceLogEventsResponse) GetLogEvents() ListServiceLogEventsLogEventsLogEventConnection { + return v.LogEvents +} + +// ListServiceStatusesResponse is returned by ListServiceStatuses on success. +type ListServiceStatusesResponse struct { + // Query Services records in your account. + Services ListServiceStatusesServicesServiceConnection `json:"services"` +} + +// GetServices returns ListServiceStatusesResponse.Services, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesResponse) GetServices() ListServiceStatusesServicesServiceConnection { + return v.Services +} + +// ListServiceStatusesServicesServiceConnection includes the requested fields of the GraphQL type ServiceConnection. +type ListServiceStatusesServicesServiceConnection struct { + Edges []ListServiceStatusesServicesServiceConnectionEdgesServiceEdge `json:"edges"` +} + +// GetEdges returns ListServiceStatusesServicesServiceConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnection) GetEdges() []ListServiceStatusesServicesServiceConnectionEdgesServiceEdge { + return v.Edges +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdge includes the requested fields of the GraphQL type ServiceEdge. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdge struct { + Node ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService `json:"node"` +} + +// GetNode returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdge.Node, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdge) GetNode() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService { + return v.Node +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService includes the requested fields of the GraphQL type Service. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService struct { + // Unique identifier of the service + Id string `json:"id"` + // Service identifier in telemetry (e.g., 'checkout-service') + Name string `json:"name"` + // Narrow status surface for the services list. + // Drops issue-projection rollups (preview / effective / savings) and + // per-service indexing cost from the full status to keep the list query cheap. + // Use status for the service detail page. + // Returns null if no baseline or volume data is available yet. + StatusSummary *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary `json:"statusSummary"` +} + +// GetId returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.Id, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetId() string { + return v.Id +} + +// GetName returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.Name, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetName() string { + return v.Name +} + +// GetStatusSummary returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService.StatusSummary, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeService) GetStatusSummary() *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary { + return v.StatusSummary +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary includes the requested fields of the GraphQL type ServiceStatusSummary. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary struct { + Health StatusHealth `json:"health"` + LogEventCount int `json:"logEventCount"` + LogEventAnalyzedCount int `json:"logEventAnalyzedCount"` + Current ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent `json:"current"` +} + +// GetHealth returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.Health, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetHealth() StatusHealth { + return v.Health +} + +// GetLogEventCount returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.LogEventCount, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetLogEventCount() int { + return v.LogEventCount +} + +// GetLogEventAnalyzedCount returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.LogEventAnalyzedCount, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetLogEventAnalyzedCount() int { + return v.LogEventAnalyzedCount +} + +// GetCurrent returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary.Current, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummary) GetCurrent() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent { + return v.Current +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent includes the requested fields of the GraphQL type ServiceStatusSummaryCurrent. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent struct { + EventsPerHour *float64 `json:"eventsPerHour"` + BytesPerHour *float64 `json:"bytesPerHour"` + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` + Severity ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals `json:"severity"` +} + +// GetEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.EventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetEventsPerHour() *float64 { + return v.EventsPerHour +} + +// GetBytesPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.BytesPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetBytesPerHour() *float64 { + return v.BytesPerHour +} + +// GetTotalUsdPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// GetSeverity returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent.Severity, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrent) GetSeverity() ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals { + return v.Severity +} + +// ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals includes the requested fields of the GraphQL type StatusSeverityTotals. +type ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals struct { + DebugEventsPerHour *float64 `json:"debugEventsPerHour"` + InfoEventsPerHour *float64 `json:"infoEventsPerHour"` + WarnEventsPerHour *float64 `json:"warnEventsPerHour"` + ErrorEventsPerHour *float64 `json:"errorEventsPerHour"` + OtherEventsPerHour *float64 `json:"otherEventsPerHour"` +} + +// GetDebugEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.DebugEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetDebugEventsPerHour() *float64 { + return v.DebugEventsPerHour +} + +// GetInfoEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.InfoEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetInfoEventsPerHour() *float64 { + return v.InfoEventsPerHour +} + +// GetWarnEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.WarnEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetWarnEventsPerHour() *float64 { + return v.WarnEventsPerHour +} + +// GetErrorEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.ErrorEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetErrorEventsPerHour() *float64 { + return v.ErrorEventsPerHour +} + +// GetOtherEventsPerHour returns ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals.OtherEventsPerHour, and is useful for accessing the field via an interface. +func (v *ListServiceStatusesServicesServiceConnectionEdgesServiceEdgeNodeServiceStatusSummaryCurrentSeverityStatusSeverityTotals) GetOtherEventsPerHour() *float64 { + return v.OtherEventsPerHour +} + // ListServicesResponse is returned by ListServices on success. type ListServicesResponse struct { // Query Services records in your account. @@ -1391,6 +1819,26 @@ type __ListAccountsInput struct { // GetOrganizationID returns __ListAccountsInput.OrganizationID, and is useful for accessing the field via an interface. func (v *__ListAccountsInput) GetOrganizationID() string { return v.OrganizationID } +// __ListServiceLogEventsInput is used internally by genqlient +type __ListServiceLogEventsInput struct { + ServiceID string `json:"serviceID"` + First int `json:"first"` +} + +// GetServiceID returns __ListServiceLogEventsInput.ServiceID, and is useful for accessing the field via an interface. +func (v *__ListServiceLogEventsInput) GetServiceID() string { return v.ServiceID } + +// GetFirst returns __ListServiceLogEventsInput.First, and is useful for accessing the field via an interface. +func (v *__ListServiceLogEventsInput) GetFirst() int { return v.First } + +// __ListServiceStatusesInput is used internally by genqlient +type __ListServiceStatusesInput struct { + First int `json:"first"` +} + +// GetFirst returns __ListServiceStatusesInput.First, and is useful for accessing the field via an interface. +func (v *__ListServiceStatusesInput) GetFirst() int { return v.First } + // __ValidateDatadogApiKeyInput is used internally by genqlient type __ValidateDatadogApiKeyInput struct { Input ValidateDatadogApiKeyInput `json:"input"` @@ -1633,6 +2081,71 @@ func GetAccount( return data_, err_ } +// The query executed by GetAccountStatusSummary. +const GetAccountStatusSummary_Operation = ` +query GetAccountStatusSummary { + datadogAccounts(first: 1) { + edges { + node { + status { + health + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } + current { + services { + eventsPerHour + volumeUsdPerHour + } + totals { + eventsPerHour + bytesPerHour + totalUsdPerHour + volumeUsdPerHour + } + } + } + } + } + } +} +` + +// Account-level status summary for the data-plane surfaces. The control plane +// computes health, service counts, coverage, and throughput totals; the CLI +// reads them as-is rather than aggregating service rows locally. The active +// account is scoped via the request header, so the single datadog account for +// the account is taken as the first edge. +func GetAccountStatusSummary( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *GetAccountStatusSummaryResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAccountStatusSummary", + Query: GetAccountStatusSummary_Operation, + } + + data_ = &GetAccountStatusSummaryResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetDatadogAccountStatus. const GetDatadogAccountStatus_Operation = ` query GetDatadogAccountStatus ($id: ID!) { @@ -2007,6 +2520,115 @@ func ListOrganizations( return data_, err_ } +// The query executed by ListServiceLogEvents. +const ListServiceLogEvents_Operation = ` +query ListServiceLogEvents ($serviceID: ID!, $first: Int!) { + logEvents(first: $first, where: {serviceID:$serviceID}) { + edges { + node { + name + displayName + status { + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + } + } + } + } + } +} +` + +// Log events for one service, with current throughput/cost, for the service +// detail drill-down. +func ListServiceLogEvents( + ctx_ context.Context, + client_ graphql.Client, + serviceID string, + first int, +) (data_ *ListServiceLogEventsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListServiceLogEvents", + Query: ListServiceLogEvents_Operation, + Variables: &__ListServiceLogEventsInput{ + ServiceID: serviceID, + First: first, + }, + } + + data_ = &ListServiceLogEventsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ListServiceStatuses. +const ListServiceStatuses_Operation = ` +query ListServiceStatuses ($first: Int!) { + services(first: $first, where: {enabled:true}) { + edges { + node { + id + name + statusSummary { + health + logEventCount + logEventAnalyzedCount + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + severity { + debugEventsPerHour + infoEventsPerHour + warnEventsPerHour + errorEventsPerHour + otherEventsPerHour + } + } + } + } + } + } +} +` + +// Enabled services with the narrow per-service status used by the services list. +// statusSummary is the control plane's list-optimized projection (health, event +// counts, current throughput and severity mix). +func ListServiceStatuses( + ctx_ context.Context, + client_ graphql.Client, + first int, +) (data_ *ListServiceStatusesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListServiceStatuses", + Query: ListServiceStatuses_Operation, + Variables: &__ListServiceStatusesInput{ + First: first, + }, + } + + data_ = &ListServiceStatusesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by ListServices. const ListServices_Operation = ` query ListServices { diff --git a/internal/boundary/graphql/gen/queries/status.graphql b/internal/boundary/graphql/gen/queries/status.graphql new file mode 100644 index 00000000..655994c2 --- /dev/null +++ b/internal/boundary/graphql/gen/queries/status.graphql @@ -0,0 +1,91 @@ +# Account-level status summary for the data-plane surfaces. The control plane +# computes health, service counts, coverage, and throughput totals; the CLI +# reads them as-is rather than aggregating service rows locally. The active +# account is scoped via the request header, so the single datadog account for +# the account is taken as the first edge. +query GetAccountStatusSummary { + datadogAccounts(first: 1) { + edges { + node { + status { + health + readiness { + readyForUse + } + coverage { + logEventCount + logEventAnalyzedCount + logServiceCount + logActiveServices + disabledServices + inactiveServices + okServices + } + current { + services { + eventsPerHour + volumeUsdPerHour + } + totals { + eventsPerHour + bytesPerHour + totalUsdPerHour + volumeUsdPerHour + } + } + } + } + } + } +} + +# Enabled services with the narrow per-service status used by the services list. +# statusSummary is the control plane's list-optimized projection (health, event +# counts, current throughput and severity mix). +query ListServiceStatuses($first: Int!) { + services(first: $first, where: { enabled: true }) { + edges { + node { + id + name + statusSummary { + health + logEventCount + logEventAnalyzedCount + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + severity { + debugEventsPerHour + infoEventsPerHour + warnEventsPerHour + errorEventsPerHour + otherEventsPerHour + } + } + } + } + } + } +} + +# Log events for one service, with current throughput/cost, for the service +# detail drill-down. +query ListServiceLogEvents($serviceID: ID!, $first: Int!) { + logEvents(first: $first, where: { serviceID: $serviceID }) { + edges { + node { + name + displayName + status { + current { + eventsPerHour + bytesPerHour + totalUsdPerHour + } + } + } + } + } +} diff --git a/internal/boundary/graphql/services.go b/internal/boundary/graphql/services.go index 00f26e85..a329fd56 100644 --- a/internal/boundary/graphql/services.go +++ b/internal/boundary/graphql/services.go @@ -22,6 +22,7 @@ type ServiceSet struct { Issues Issues Checks Checks EdgeInstances EdgeInstances + Status Status } // NewServiceSet creates ServiceSet with an internally-managed client. @@ -54,6 +55,7 @@ func newServiceSetWithScope(client Client, scope log.Scope) ServiceSet { Issues: NewIssueService(client, scope), Checks: NewCheckService(client, scope), EdgeInstances: NewEdgeInstanceService(client, scope), + Status: NewStatusService(client, scope), } } diff --git a/internal/boundary/graphql/status_service.go b/internal/boundary/graphql/status_service.go new file mode 100644 index 00000000..f79dfdbd --- /dev/null +++ b/internal/boundary/graphql/status_service.go @@ -0,0 +1,158 @@ +package graphql + +import ( + "context" + + "github.com/usetero/cli/internal/boundary/graphql/gen" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" +) + +// maxServiceStatuses bounds the services-list read for the data-plane surface. +const maxServiceStatuses = 50 + +// Status provides the account- and service-level status reads that back the +// data-plane status surfaces. All aggregates are computed by the control plane. +type Status interface { + GetAccountSummary(ctx context.Context) (domain.AccountSummary, error) + ListServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) + ListServiceLogEvents(ctx context.Context, serviceID string) ([]domain.LogEventStatus, error) +} + +// StatusService reads control-plane status projections. +type StatusService struct { + client Client + scope log.Scope +} + +var _ Status = (*StatusService)(nil) + +// NewStatusService creates a new status service. +func NewStatusService(client Client, scope log.Scope) *StatusService { + return &StatusService{ + client: client, + scope: scope.Child("status"), + } +} + +// GetAccountSummary fetches the account-level status summary. Returns a zero +// summary (ServiceCount 0) when no Datadog account status exists yet. +func (s *StatusService) GetAccountSummary(ctx context.Context) (domain.AccountSummary, error) { + resp, err := s.client.GetAccountStatusSummary(ctx) + if err != nil { + s.scope.Error("failed to fetch account status summary", "error", err) + return domain.AccountSummary{}, err + } + + if len(resp.DatadogAccounts.Edges) == 0 { + return domain.AccountSummary{}, nil + } + status := resp.DatadogAccounts.Edges[0].Node.Status + if status == nil { + return domain.AccountSummary{}, nil + } + + cov := status.Coverage + cur := status.Current + summary := domain.AccountSummary{ + ReadyForUse: status.Readiness.ReadyForUse, + Health: serviceHealth(status.Health), + ServiceCount: int64(cov.LogServiceCount), + ActiveServices: int64(cov.LogActiveServices), + OkServices: int64(cov.OkServices), + DisabledServices: int64(cov.DisabledServices), + InactiveServices: int64(cov.InactiveServices), + EventCount: int64(cov.LogEventCount), + AnalyzedCount: int64(cov.LogEventAnalyzedCount), + // Service-level ground truth. + TotalServiceVolumePerHour: cur.Services.EventsPerHour, + TotalServiceCostPerHour: cur.Services.VolumeUsdPerHour, + // Discovered log-event throughput. + TotalVolumePerHour: cur.Totals.EventsPerHour, + TotalBytesPerHour: cur.Totals.BytesPerHour, + TotalCostPerHour: cur.Totals.TotalUsdPerHour, + } + return summary, nil +} + +// ListServiceStatuses fetches enabled services with their list-status summary. +func (s *StatusService) ListServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) { + resp, err := s.client.ListServiceStatuses(ctx, maxServiceStatuses) + if err != nil { + s.scope.Error("failed to fetch service statuses", "error", err) + return nil, err + } + + statuses := make([]domain.ServiceStatus, 0, len(resp.Services.Edges)) + for _, edge := range resp.Services.Edges { + node := edge.Node + svc := domain.ServiceStatus{ID: node.Id, Name: node.Name, Health: domain.ServiceHealthInactive} + if node.StatusSummary != nil { + sum := node.StatusSummary + cur := sum.Current + sev := cur.Severity + svc.Health = serviceHealth(sum.Health) + svc.LogEventCount = int64(sum.LogEventCount) + svc.LogEventAnalyzedCount = int64(sum.LogEventAnalyzedCount) + svc.ServiceVolumePerHour = cur.EventsPerHour + svc.LogEventVolumePerHour = cur.EventsPerHour + svc.LogEventBytesPerHour = cur.BytesPerHour + svc.ServiceCostPerHourVolumeUSD = cur.TotalUsdPerHour + svc.LogEventCostPerHourUSD = cur.TotalUsdPerHour + svc.ServiceDebugVolumePerHour = sev.DebugEventsPerHour + svc.ServiceInfoVolumePerHour = sev.InfoEventsPerHour + svc.ServiceWarnVolumePerHour = sev.WarnEventsPerHour + svc.ServiceErrorVolumePerHour = sev.ErrorEventsPerHour + svc.ServiceOtherVolumePerHour = sev.OtherEventsPerHour + } + statuses = append(statuses, svc) + } + + s.scope.Debug("fetched service statuses", log.Int("count", len(statuses))) + return statuses, nil +} + +// ListServiceLogEvents fetches the log events for a single service. +func (s *StatusService) ListServiceLogEvents(ctx context.Context, serviceID string) ([]domain.LogEventStatus, error) { + resp, err := s.client.ListServiceLogEvents(ctx, serviceID, 25) + if err != nil { + s.scope.Error("failed to fetch service log events", "error", err, "serviceID", serviceID) + return nil, err + } + + events := make([]domain.LogEventStatus, 0, len(resp.LogEvents.Edges)) + for _, edge := range resp.LogEvents.Edges { + node := edge.Node + name := node.Name + if node.DisplayName != nil && *node.DisplayName != "" { + name = *node.DisplayName + } + event := domain.LogEventStatus{Name: name} + if node.Status != nil { + cur := node.Status.Current + event.VolumePerHour = cur.EventsPerHour + event.BytesPerHour = cur.BytesPerHour + event.CostPerHourUSD = cur.TotalUsdPerHour + } + events = append(events, event) + } + + s.scope.Debug("fetched service log events", log.Int("count", len(events)), log.String("serviceID", serviceID)) + return events, nil +} + +// serviceHealth maps a control-plane StatusHealth to the domain health enum. +func serviceHealth(h gen.StatusHealth) domain.ServiceHealth { + switch h { + case gen.StatusHealthOk: + return domain.ServiceHealthOK + case gen.StatusHealthError: + return domain.ServiceHealthError + case gen.StatusHealthDisabled: + return domain.ServiceHealthDisabled + case gen.StatusHealthInactive: + return domain.ServiceHealthInactive + default: + return domain.ServiceHealthInactive + } +} diff --git a/internal/domain/service_status.go b/internal/domain/service_status.go index e99466d6..29fc8301 100644 --- a/internal/domain/service_status.go +++ b/internal/domain/service_status.go @@ -6,6 +6,7 @@ type ServiceHealth string const ( ServiceHealthDisabled ServiceHealth = "DISABLED" ServiceHealthInactive ServiceHealth = "INACTIVE" + ServiceHealthError ServiceHealth = "ERROR" ServiceHealthOK ServiceHealth = "OK" ) @@ -14,6 +15,7 @@ func (s ServiceHealth) String() string { return string(s) } // ServiceStatus mirrors service_statuses_cache. All columns included; // callers pick what they need. type ServiceStatus struct { + ID string Name string Health ServiceHealth diff --git a/internal/tea/components/status/status.go b/internal/tea/components/status/status.go index 5b1fbaff..afcb096a 100644 --- a/internal/tea/components/status/status.go +++ b/internal/tea/components/status/status.go @@ -40,6 +40,8 @@ func serviceColor(theme styles.Theme, h domain.ServiceHealth) color.Color { switch h { case domain.ServiceHealthOK: return theme.Success + case domain.ServiceHealthError: + return theme.Error case domain.ServiceHealthDisabled, domain.ServiceHealthInactive: return theme.TextMuted default: From 6b7b536cf8d9acf00af9188552f596526e4f1c6a Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 11:01:22 -0700 Subject: [PATCH 09/20] feat: convert service enable/disable write to a GraphQL mutation The set_service_enabled chat tool now calls the control-plane setServiceEnabled mutation directly instead of writing through the local PowerSync outbox. Remove the obsolete approve_policy tool and its domain types: policy approval moved to the issue model and is no longer a chat action. Drop the now-dead conversation-title persistence write (chat is ephemeral). --- internal/app/chattools/approvepolicy.go | 69 --------------------- internal/app/chattools/setserviceenabled.go | 47 ++++++-------- internal/app/onboarding_orchestration.go | 18 +----- internal/app/runtime_sync.go | 12 ++-- internal/domain/tools/approvepolicy.go | 20 ------ 5 files changed, 25 insertions(+), 141 deletions(-) delete mode 100644 internal/app/chattools/approvepolicy.go delete mode 100644 internal/domain/tools/approvepolicy.go diff --git a/internal/app/chattools/approvepolicy.go b/internal/app/chattools/approvepolicy.go deleted file mode 100644 index a0332c64..00000000 --- a/internal/app/chattools/approvepolicy.go +++ /dev/null @@ -1,69 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// NewApprovePolicyAction creates an ActionTool for approve_policy. -// userIDFunc is called at execution time to get the current user's ID. -func NewApprovePolicyAction(db sqlite.DB, userIDFunc func() string) ActionTool { - def := chat.Tool{ - Name: "approve_policy", - Description: "Approve a log event policy for enforcement. This marks the policy as approved and queues it for the enforcement pipeline.", - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "policy_id": { - Type: "string", - Description: "UUID of the policy (e.g., '4a3b1c2d-...'). Use the query tool to look up policy IDs: SELECT policy_id, service_name, log_event_name, category FROM log_event_policy_statuses_cache WHERE status = 'PENDING'", - }, - }, - []string{"policy_id"}, - ), - } - - executor := func(input json.RawMessage) (tools.Result, error) { - var in tools.ApprovePolicyInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.Result{}, err - } - - ctx, cancel := withToolTimeout() - defer cancel() - - if err := db.LogEventPolicies().Approve(ctx, in.PolicyID, userIDFunc()); err != nil { - return tools.Result{}, fmt.Errorf("approve policy: %w", err) - } - - return tools.Result{ - Content: tools.ApprovePolicyResult{ - PolicyID: in.PolicyID, - Status: "APPROVED", - }.ToMap(), - }, nil - } - - config := action.Config{ - DisplayName: func(_ json.RawMessage) string { - return "Approve Policy" - }, - Status: func(input json.RawMessage) string { - var in tools.ApprovePolicyInput - if json.Unmarshal(input, &in) != nil { - return "" - } - return fmt.Sprintf("Approving %s", in.PolicyID) - }, - Result: func(result tools.Result) string { - id, _ := result.Content["policy_id"].(string) - return fmt.Sprintf("Policy %s approved", id) - }, - } - - return NewActionTool(def, executor, config) -} diff --git a/internal/app/chattools/setserviceenabled.go b/internal/app/chattools/setserviceenabled.go index 64919052..7c617bd7 100644 --- a/internal/app/chattools/setserviceenabled.go +++ b/internal/app/chattools/setserviceenabled.go @@ -1,20 +1,19 @@ package tools import ( - "database/sql" "encoding/json" - "errors" "fmt" "github.com/google/uuid" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" "github.com/usetero/cli/internal/boundary/chat" + graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" ) // NewSetServiceEnabledAction creates an ActionTool for set_service_enabled. -func NewSetServiceEnabledAction(db sqlite.DB) ActionTool { +// The enable/disable write is a synchronous control-plane GraphQL mutation. +func NewSetServiceEnabledAction(services graphql.Services) ActionTool { def := chat.Tool{ Name: "set_service_enabled", Description: "Enable or disable a service for log analysis. Enabling triggers the analysis pipeline.", @@ -39,41 +38,31 @@ func NewSetServiceEnabledAction(db sqlite.DB) ActionTool { return tools.Result{}, err } + if _, parseErr := uuid.Parse(in.ServiceID.String()); parseErr != nil { + return tools.Result{}, fmt.Errorf( + "service ID %q is not a UUID — this looks like a name. "+ + "Use the query tool: SELECT id, name FROM services WHERE name LIKE '%%%s%%'", + in.ServiceID, in.ServiceID, + ) + } + ctx, cancel := withToolTimeout() defer cancel() - svc, err := db.Services().Get(ctx, in.ServiceID) - if errors.Is(err, sql.ErrNoRows) { - if _, parseErr := uuid.Parse(in.ServiceID.String()); parseErr != nil { - return tools.Result{}, fmt.Errorf( - "no service found with ID %q — this looks like a name, not a UUID. "+ - "Use the query tool: SELECT id, name FROM services WHERE name LIKE '%%%s%%'", - in.ServiceID, in.ServiceID, - ) - } - return tools.Result{}, fmt.Errorf( - "no service found with ID %q. Use the query tool: SELECT id, name FROM services", - in.ServiceID, - ) + var err error + if in.Enabled { + err = services.EnableService(ctx, in.ServiceID) + } else { + err = services.DisableService(ctx, in.ServiceID) } if err != nil { - return tools.Result{}, fmt.Errorf("get service: %w", err) - } - - if err := db.Services().SetEnabled(ctx, in.ServiceID, in.Enabled); err != nil { return tools.Result{}, fmt.Errorf("set service enabled: %w", err) } - var serviceName string - if svc.Name != nil { - serviceName = *svc.Name - } - return tools.Result{ Content: tools.SetServiceEnabledResult{ - ServiceID: in.ServiceID, - ServiceName: serviceName, - Enabled: in.Enabled, + ServiceID: in.ServiceID, + Enabled: in.Enabled, }.ToMap(), }, nil } diff --git a/internal/app/onboarding_orchestration.go b/internal/app/onboarding_orchestration.go index 62e6dee6..0b0d2f4e 100644 --- a/internal/app/onboarding_orchestration.go +++ b/internal/app/onboarding_orchestration.go @@ -1,7 +1,6 @@ package app import ( - "context" "time" tea "charm.land/bubbletea/v2" @@ -72,22 +71,11 @@ func (m *Model) handleStreamCompleted(msg tea.Msg) { return } - if stream.Title != "" && m.db != nil && m.chat != nil { + if stream.Title != "" && m.chat != nil { + // Chat is ephemeral: the title is shown in the status bar and window + // title for the session but is not persisted. m.statusBar.SetTitle(stream.Title) m.windowTitle = "Tero: " + stream.Title - db := m.db - conversationID := m.chat.ConversationID() - title := stream.Title - scope := m.scope - ctx := m.ctx - // Persist title in background using immutable captured values. - go func() { - writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - if err := db.Conversations().UpdateTitle(writeCtx, conversationID, title); err != nil { - scope.Error("failed to update conversation title", "error", err) - } - }() } // Update context window usage in statusbar if stream.InputTokens > 0 && stream.ContextWindow > 0 { diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index 0b2030c4..e492f612 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -59,18 +59,14 @@ func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { m.statusBar.SetDB(m.db), ) - // Create tool registry with executors + // Create tool registry with executors. Service enable/disable is a + // synchronous control-plane mutation; policy approval moved to the issue + // model and is no longer a chat action. m.toolRegistry = chattools.NewRegistry( chattools.NewQueryTool(m.db, m.scope), chattools.NewShowTool(m.db), map[string]chattools.ActionTool{ - "set_service_enabled": chattools.NewSetServiceEnabledAction(m.db), - "approve_policy": chattools.NewApprovePolicyAction(m.db, func() string { - if m.user != nil { - return m.user.ID - } - return "" - }), + "set_service_enabled": chattools.NewSetServiceEnabledAction(m.services.Services), }, ) diff --git a/internal/domain/tools/approvepolicy.go b/internal/domain/tools/approvepolicy.go deleted file mode 100644 index e99c01a8..00000000 --- a/internal/domain/tools/approvepolicy.go +++ /dev/null @@ -1,20 +0,0 @@ -package tools - -// ApprovePolicyInput is the input schema for the approve_policy tool. -type ApprovePolicyInput struct { - PolicyID string `json:"policy_id"` -} - -// ApprovePolicyResult is the typed output of an approve_policy tool execution. -type ApprovePolicyResult struct { - PolicyID string `json:"policy_id"` - Status string `json:"status"` -} - -// ToMap serializes the result for the GraphQL API. -func (r ApprovePolicyResult) ToMap() map[string]any { - return map[string]any{ - "policy_id": r.PolicyID, - "status": r.Status, - } -} From a41713e1e487f86773f7a81cbb545e9ad9269440 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 11:10:31 -0700 Subject: [PATCH 10/20] feat: replace SQL chat tools with GraphQL read tools The chat agent's read surface moves off the local SQLite catalog onto control-plane GraphQL. Replace the arbitrary-SQL query tool and the policy show card (the policy model is gone) with structured action tools: - list_services, list_issues, list_checks, list_edge_instances, account_status Add a ListIssues query and Issues.List for individual active issues. The tool registry is now just GraphQL-backed action tools (no special query/show UI). Removes the query/show tool UI packages and the embedded SQL schema. --- .../app/chat/messagelist/behavior_test.go | 2 +- .../round/turn/assistant/assistant.go | 20 +- .../round/turn/assistant/assistant_test.go | 66 +-- .../assistant/blocks/tools/query/query.go | 279 ------------ .../blocks/tools/query/query_test.go | 114 ----- .../turn/assistant/blocks/tools/show/show.go | 224 --------- .../assistant/blocks/tools/show/show_test.go | 133 ------ internal/app/chattools/query.go | 225 --------- internal/app/chattools/query_schema.sql | 431 ------------------ internal/app/chattools/query_test.go | 115 ----- internal/app/chattools/reads.go | 214 +++++++++ internal/app/chattools/registry.go | 21 +- internal/app/chattools/runtime.go | 6 +- internal/app/chattools/show.go | 219 --------- internal/app/chattools/show_policy.go | 208 --------- internal/app/runtime_sync.go | 14 +- .../boundary/graphql/apitest/mock_client.go | 8 + internal/boundary/graphql/client.go | 9 + internal/boundary/graphql/gen/generated.go | 153 +++++++ .../graphql/gen/queries/issues.graphql | 25 + internal/boundary/graphql/issue_service.go | 33 ++ internal/domain/issue.go | 10 + 22 files changed, 491 insertions(+), 2038 deletions(-) delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go delete mode 100644 internal/app/chattools/query.go delete mode 100644 internal/app/chattools/query_schema.sql delete mode 100644 internal/app/chattools/query_test.go create mode 100644 internal/app/chattools/reads.go delete mode 100644 internal/app/chattools/show.go delete mode 100644 internal/app/chattools/show_policy.go diff --git a/internal/app/chat/messagelist/behavior_test.go b/internal/app/chat/messagelist/behavior_test.go index 483de31f..9593e5f4 100644 --- a/internal/app/chat/messagelist/behavior_test.go +++ b/internal/app/chat/messagelist/behavior_test.go @@ -268,7 +268,7 @@ func TestBehavior_ToolResultsStayBoundToOriginalBlock(t *testing.T) { }, }, ) - registry := chattools.NewRegistry(nil, nil, map[string]chattools.ActionTool{ + registry := chattools.NewRegistry(map[string]chattools.ActionTool{ "set_service_enabled": actionTool, }) diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go index eba5c8a7..bd34e4d5 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant.go @@ -7,8 +7,6 @@ import ( "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show" chattools "github.com/usetero/cli/internal/app/chattools" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" @@ -159,19 +157,11 @@ func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *too return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) } - var child tools.Child - switch { - case m.toolRegistry.Query != nil && toolUse.Name == m.toolRegistry.Query.Name(): - child = query.New(m.blockTheme, index, m.turnID, toolUse.ID, width, m.toolRegistry.Query, m.scope) - case m.toolRegistry.Show != nil && toolUse.Name == m.toolRegistry.Show.Name(): - child = show.New(m.blockTheme, index, m.turnID, toolUse.ID, width, m.toolRegistry.Show, m.scope) - default: - entry, ok := m.toolRegistry.Lookup(toolUse.Name) - if !ok { - m.scope.Warn("unknown tool, using generic action", "name", toolUse.Name) - entry = chattools.UnknownTool(toolUse.Name) - } - child = action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) + entry, ok := m.toolRegistry.Lookup(toolUse.Name) + if !ok { + m.scope.Warn("unknown tool, using generic action", "name", toolUse.Name) + entry = chattools.UnknownTool(toolUse.Name) } + child := action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) } diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go b/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go index f54e7ddb..f3d30db2 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go @@ -1,63 +1,32 @@ package assistant import ( - "fmt" + "encoding/json" "strings" "testing" msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" "github.com/usetero/cli/internal/domain" + domaintools "github.com/usetero/cli/internal/domain/tools" "github.com/usetero/cli/internal/log/logtest" "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" ) -func TestBlocksNoWrapping(t *testing.T) { - t.Parallel() +// actionToolBlock builds a generic action tool block for rendering tests. +func actionToolBlock(t *testing.T, width int, displayName string) *tools.Model { + t.Helper() theme := styles.NewTheme(true) scope := logtest.NewScope(t) - - // Wide 10-column query result — the exact data that was wrapping - rows := []map[string]any{ - {"name": "accounting", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 6, "log_percent_complete": 96.74829044065488, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 6}, - {"name": "ad", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 5, "log_percent_complete": 95.47126675672867, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 5}, - {"name": "cart", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 11, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 11}, - {"name": "checkout", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 41, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 41}, - {"name": "currency", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 1, "log_percent_complete": 95.20486903597435, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 1}, - } - - for _, termWidth := range []int{80, 120, 160, 200} { - t.Run(fmt.Sprintf("term_%d", termWidth), func(t *testing.T) { - // Real width chain: app subtracts 2 for horizontal padding - assistantWidth := termWidth - 2 - contentWidth := assistantWidth - block.BorderWidth - - // Real assistant model - m := New(theme, "turn-1", "test-msg", assistantWidth, nil, scope) - // Real query model — pass contentWidth (same as production in newToolBlock) - q := query.New(theme, 0, "turn-1", "tool-1", contentWidth, nil, scope) - q.SetRows(rows) - - // Real tool model wrapping query (same as production) - tool := tools.New(theme, 0, "turn-1", "tool-1", contentWidth, q) - tool.ForceStatus(tools.StatusSuccess) - - // Add tool block to assistant - m.AddBlock(tool) - - // Verify each block renders within contentWidth. - // The viewport applies a border; blocks handle their own internal padding. - // Blocks must fit within contentWidth. - for _, b := range m.Blocks() { - b.SetWidth(contentWidth) - output := b.View() - teatest.AssertMaxWidth(t, contentWidth, output) - } - }) + cfg := action.Config{ + DisplayName: func(json.RawMessage) string { return displayName }, + Status: func(json.RawMessage) string { return "Running" }, + Result: func(domaintools.Result) string { return "Done" }, } + exec := func(json.RawMessage) (domaintools.Result, error) { return domaintools.Result{}, nil } + child := action.New(0, "turn-1", "tool-1", width, cfg, exec, scope) + return tools.New(theme, 0, "turn-1", "tool-1", width, child) } func TestCancel(t *testing.T) { @@ -70,19 +39,18 @@ func TestCancel(t *testing.T) { m := New(theme, "turn-1", "test-msg", 80, nil, scope) - // Add a tool block in pending state - q := query.New(theme, 0, "turn-1", "tool-1", 78, nil, scope) - tool := tools.New(theme, 0, "turn-1", "tool-1", 78, q) + // Add a tool block in pending state. + tool := actionToolBlock(t, 78, "List Services") m.AddBlock(tool) m.Cancel() - // Tool should render as cancelled — padding + icon/name + padding (3 lines) + // Tool should render as cancelled — padding + icon/name + padding (3 lines). view := tool.View() if lines := strings.Count(view, "\n"); lines != 2 { t.Errorf("expected 3-line render for cancelled tool, got %d lines", lines+1) } - if !strings.Contains(view, "Query") { + if !strings.Contains(view, "List Services") { t.Error("expected tool name in cancelled view") } }) diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go deleted file mode 100644 index 60e6bff7..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query.go +++ /dev/null @@ -1,279 +0,0 @@ -package query - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/table" -) - -// Model handles query tool execution and content rendering. -// Chrome (icon, name) is handled by the parent tools.Model. -type Model struct { - theme styles.Theme - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - executor *chattools.QueryTool - width int - - // Input accumulation - input string - - // Parsed input - sql string - status string - resultTemplate string - - // Results - rows []map[string]any - rowsDropped int - err error - duration time.Duration -} - -type queryExecutionCompletedMsg struct { - toolID string - result domaintools.QueryResult - err error - duration time.Duration -} - -// New creates a new query tool model. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, executor *chattools.QueryTool, scope log.Scope) *Model { - scope = scope.Child("query") - return &Model{ - theme: theme, - scope: scope, - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - executor: executor, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case queryExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - m.duration = msg.duration - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("query failed", "error", msg.err) - return m.fireCompleted() - } - - m.rows = msg.result.Rows - m.rowsDropped = msg.result.RowsDropped - m.state = tools.StateComplete - m.scope.Info("query completed", "row_count", len(m.rows), "rows_dropped", m.rowsDropped, "duration", m.duration) - return m.fireCompleted() - } - return nil -} - -// handleContent finds this tool's data by index and updates state. -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = string(b.ToolUse.Input) - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -// Status returns the status message shown while executing. -func (m *Model) Status() string { - return m.status -} - -// Result returns the result message with {count} substituted. -func (m *Model) Result() string { - if m.err != nil { - return "Query failed" - } - var base string - if m.resultTemplate == "" { - base = fmt.Sprintf("%d rows", len(m.rows)) - } else { - base = strings.Replace(m.resultTemplate, "{count}", fmt.Sprintf("%d", len(m.rows)), 1) - } - if m.duration > 0 { - base += fmt.Sprintf(" (%.1fs)", m.duration.Seconds()) - } - return base -} - -const maxPreviewRows = 5 - -// View renders a table preview of query results, clipped to the available width. -func (m *Model) View() string { - if len(m.rows) == 0 { - return "" - } - - // Collect column headers from first row, sorted for stable order. - // Promote "name" to the front if it exists. - first := m.rows[0] - var headers []string - hasName := false - for k := range first { - if k == "name" { - hasName = true - } else { - headers = append(headers, k) - } - } - sort.Strings(headers) - if hasName { - headers = append([]string{"name"}, headers...) - } - - // Cap rows - showRows := m.rows - truncatedRows := 0 - if len(showRows) > maxPreviewRows { - truncatedRows = len(showRows) - maxPreviewRows - showRows = showRows[:maxPreviewRows] - } - - // Build table — no explicit width so columns size to content - tbl := table.New(m.theme, table.WithFitHeaders(), table.WithBackground(m.theme.Bg)) - tbl.Headers(headers...) - - for _, row := range showRows { - cells := make([]string, len(headers)) - for i, h := range headers { - cells[i] = fmt.Sprintf("%v", row[h]) - } - tbl.Row(cells...) - } - - result := tbl.View() - - if truncatedRows > 0 { - muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted).Background(m.theme.Bg).PaddingLeft(1) - result += "\n\n" + muted.Render(fmt.Sprintf("+%d more rows", truncatedRows)) - } - - // Clip each line to available width so wide tables don't wrap - if m.width > 0 { - lines := strings.Split(result, "\n") - for i, line := range lines { - lines[i] = ansi.Truncate(line, m.width, "") - } - result = strings.Join(lines, "\n") - } - - return result -} - -// SetRows sets the result rows directly (for testing). -func (m *Model) SetRows(rows []map[string]any) { - m.rows = rows - m.state = tools.StateComplete -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - - // Parse input - var in domaintools.QueryInput - if err := json.Unmarshal([]byte(m.input), &in); err == nil { - m.sql = in.SQL - m.status = in.Status - m.resultTemplate = in.Result - } - - m.scope.Info("executing query", "sql", m.sql, "status", m.status) - - start := time.Now() - - if m.executor == nil { - m.err = fmt.Errorf("no executor") - m.state = tools.StateComplete - m.scope.Error("query failed", "error", m.err) - return m.fireCompleted() - } - input := append([]byte(nil), []byte(m.input)...) - executor := m.executor - return func() tea.Msg { - result, err := executor.Execute(json.RawMessage(input)) - return queryExecutionCompletedMsg{ - toolID: m.toolID, - result: result, - err: err, - duration: time.Since(start), - } - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - result := domaintools.QueryResult{Rows: m.rows, RowsDropped: m.rowsDropped} - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: domaintools.Result{Content: result.ToMap()}, - Error: m.err, - } - } -} - -// Name returns the tool's display name. -func (m *Model) Name() string { - return "Query" -} - -// ToolID returns the tool's ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// State returns the tool's current state. -func (m *Model) State() tools.State { - return m.state -} - -// Err returns any error from execution. -func (m *Model) Err() error { - return m.err -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go deleted file mode 100644 index 85b2bd54..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query/query_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package query - -import ( - "fmt" - "testing" - "time" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" -) - -func TestViewClipsToWidth(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := wideRows() - - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: 80, - } - - output := m.View() - teatest.AssertMaxWidth(t, 80, output) -} - -func TestViewClipsToWidth_NarrowTerminal(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := []map[string]any{ - {"name": "accounting", "status": "READY", "count": 42}, - {"name": "checkout", "status": "READY", "count": 11}, - } - - for _, width := range []int{40, 60, 80, 120} { - t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) { - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: width, - } - - output := m.View() - teatest.AssertMaxWidth(t, width, output) - }) - } -} - -func TestViewWidthZero(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - rows := []map[string]any{ - {"name": "test", "value": 123}, - } - - m := &Model{ - theme: theme, - scope: scope, - rows: rows, - width: 0, - } - - output := m.View() - t.Logf("width=0 output:\n%s", output) - - // With width 0, no clipping happens — just make sure it doesn't panic - if output == "" { - t.Error("expected non-empty output even with width=0") - } -} - -func TestUpdate_IgnoresExecutionCompletionFromDifferentToolInstance(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, 0, "turn-1", "tool-1", 80, nil, scope) - cmd := m.Update(queryExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.QueryResult{Rows: []map[string]any{{"name": "wrong"}}}, - duration: 100 * time.Millisecond, - }) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if len(m.rows) != 0 { - t.Fatalf("rows = %d, want 0", len(m.rows)) - } -} - -func wideRows() []map[string]any { - return []map[string]any{ - {"name": "accounting", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 6, "log_percent_complete": 96.74829044065488, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 6}, - {"name": "ad", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 5, "log_percent_complete": 95.47126675672867, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 5}, - {"name": "cart", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 11, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 11}, - {"name": "checkout", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 41, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 41}, - {"name": "currency", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 1, "log_percent_complete": 95.20486903597435, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 1}, - {"name": "email", "log_analyzing_count": 0, "log_discovering_count": 0, "log_error": nil, "log_event_count": 2, "log_percent_complete": 100, "log_saved_count": 0, "log_status": "READY", "log_valuable_count": 0, "log_waste_count": 2}, - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go deleted file mode 100644 index 84038675..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package show renders entity cards inside tool chrome. -// The parent tools.Model handles icon, name, collapse/expand, and status. -// Entity rendering is dispatched by type: policies → policycard, etc. -package show - -import ( - "encoding/json" - "fmt" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/policycard" -) - -// Model handles show tool execution and renders entity content. -// Chrome (icon, name, expand/collapse) is handled by the parent tools.Model. -type Model struct { - theme styles.Theme - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - executor *chattools.ShowTool - width int - - // Input accumulation - input string - - // Parsed input - entity domaintools.EntityType - - // Results - result domaintools.ShowResult - err error - policy *domain.Policy - - // Entity-specific child components (created on execute based on entity type) - card *policycard.Model -} - -type showExecutionCompletedMsg struct { - toolID string - result domaintools.ShowResult - err error -} - -// New creates a new show tool model. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, executor *chattools.ShowTool, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("show"), - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - executor: executor, - width: width, - } -} - -// AutoExpand implements tools.AutoExpander — show results are always expanded. -func (m *Model) AutoExpand() bool { return true } - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case showExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("show failed", "error", msg.err) - return m.fireCompleted() - } - - m.result = msg.result - m.state = tools.StateComplete - - // Create entity-specific child components based on result type. - if msg.result.Entity == domaintools.EntityPolicy && msg.result.Data != nil { - if p, ok := msg.result.Data["policy"].(*domain.Policy); ok { - m.policy = p - m.card = policycard.New(m.theme) - m.card.SetPolicy(p) - m.card.SetWidth(m.width) - } - } - - m.scope.Info("show completed", "entity", string(msg.result.Entity), "id", msg.result.ID) - return m.fireCompleted() - } - return nil -} - -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = string(b.ToolUse.Input) - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - - var in domaintools.ShowInput - if err := json.Unmarshal([]byte(m.input), &in); err == nil { - m.entity = in.Entity - } - - m.scope.Info("executing show", "entity", string(m.entity)) - - if m.executor == nil { - m.err = fmt.Errorf("no executor") - m.state = tools.StateComplete - return m.fireCompleted() - } - input := append([]byte(nil), []byte(m.input)...) - executor := m.executor - return func() tea.Msg { - result, err := executor.Execute(json.RawMessage(input)) - return showExecutionCompletedMsg{toolID: m.toolID, result: result, err: err} - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: domaintools.Result{Content: m.result.ToMap()}, - Error: m.err, - } - } -} - -// ─── tools.Child interface ────────────────────────────────────────────────── - -// Name returns the display name for the chrome header. -func (m *Model) Name() string { - if m.policy != nil { - categoryName := m.policy.CategoryDisplayName - if categoryName == "" { - categoryName = format.TitleCase(string(m.policy.Category)) - } - if categoryName != "" { - return "Policy · pol-" + m.result.IDShort + " · " + categoryName - } - } - return "Policy" -} - -// Status returns the message shown while executing. -func (m *Model) Status() string { - return "Fetching policy" -} - -// Result returns the message shown when complete. -func (m *Model) Result() string { - if m.err != nil { - return "Failed" - } - if m.policy != nil { - if m.policy.ServiceName != "" && m.policy.LogEventName != "" { - return m.policy.ServiceName + " / " + m.policy.LogEventName - } - if m.policy.ServiceName != "" { - return m.policy.ServiceName - } - } - return "" -} - -// View renders the policy body content (shown when expanded). -func (m *Model) View() string { - if m.policy == nil { - return "" - } - switch m.result.Entity { - case domaintools.EntityPolicy: - return m.card.View() - default: - return "" - } -} - -// SetWidth sets the available width for content rendering. -func (m *Model) SetWidth(width int) { - m.width = width - if m.card != nil { - m.card.SetWidth(width) - } -} - -// State returns the current execution state. -func (m *Model) State() tools.State { return m.state } - -// ToolID returns the tool use ID. -func (m *Model) ToolID() string { return m.toolID } - -// Err returns any execution error. -func (m *Model) Err() error { return m.err } diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go deleted file mode 100644 index 5eed4296..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/show/show_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package show - -import ( - "encoding/json" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("executes on InputComplete and emits ToolCompleted", func(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"entity":"policy","id":"p-1"}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != 2 { // tools.StateComplete - t.Fatalf("expected complete state, got %d", m.state) - } - - msg := cmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected ToolCompleted, got %T", msg) - } - if completed.TurnID != "turn-1" { - t.Fatalf("TurnID = %q, want turn-1", completed.TurnID) - } - if completed.ToolUseID != "tool-1" { - t.Fatalf("ToolUseID = %q, want tool-1", completed.ToolUseID) - } - if completed.Error == nil { - t.Fatal("expected error (no executor)") - } - }) - - t.Run("does not execute when index mismatches", func(t *testing.T) { - t.Parallel() - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - cmd := m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"entity":"policy","id":"p-1"}`), - InputComplete: true, - }}, - }, - }, - }) - if cmd != nil { - t.Fatal("expected nil cmd for mismatched index") - } - }) -} - -func TestResultAndName(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - - m.err = errors.New("fail") - if got := m.Result(); got != "Failed" { - t.Fatalf("Result() = %q, want Failed", got) - } - - m.err = nil - m.result = domaintools.ShowResult{IDShort: "abcd"} - m.policy = &domain.Policy{ - Category: domain.CategoryPIILeakage, - CategoryDisplayName: "PII", - ServiceName: "api", - LogEventName: "request", - } - if got := m.Name(); got == "" { - t.Fatal("Name() should not be empty") - } - if got := m.Result(); got != "api / request" { - t.Fatalf("Result() = %q, want %q", got, "api / request") - } - - m.policy.LogEventName = "" - if got := m.Result(); got != "api" { - t.Fatalf("Result() = %q, want %q", got, "api") - } -} - -func TestAutoExpand(t *testing.T) { - t.Parallel() - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, &chattools.ShowTool{}, logtest.NewScope(t)) - if !m.AutoExpand() { - t.Fatal("AutoExpand() = false, want true") - } -} - -func TestUpdate_IgnoresExecutionCompletionFromDifferentToolInstance(t *testing.T) { - t.Parallel() - - m := New(styles.NewTheme(true), 0, "turn-1", "tool-1", 80, nil, logtest.NewScope(t)) - cmd := m.Update(showExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.ShowResult{Entity: domaintools.EntityPolicy, ID: "p-2"}, - }) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if m.result.ID != "" { - t.Fatalf("unexpected result id %q", m.result.ID) - } -} diff --git a/internal/app/chattools/query.go b/internal/app/chattools/query.go deleted file mode 100644 index 9c300ea5..00000000 --- a/internal/app/chattools/query.go +++ /dev/null @@ -1,225 +0,0 @@ -package tools - -import ( - "context" - _ "embed" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/sqlite" -) - -//go:embed query_schema.sql -var querySchema string - -// QueryTool executes read-only SQL queries against the local catalog. -type QueryTool struct { - db sqlite.DB - scope log.Scope -} - -// NewQueryTool creates a new query tool. -func NewQueryTool(db sqlite.DB, scope log.Scope) *QueryTool { - return &QueryTool{db: db, scope: scope.Child("query_tool")} -} - -// Name returns the tool name. -func (t *QueryTool) Name() string { - return "query" -} - -// Definition returns the tool definition for the chat API. -func (t *QueryTool) Definition() chat.Tool { - return chat.Tool{ - Name: t.Name(), - Description: fmt.Sprintf(`Read-only SQL against the local SQLite catalog (synced from Tero control plane). - -## Schema - -%s - -## Query rules - -1. SELECT only — the database is read-only. -2. Prefer *_cache tables for current status and metrics. -3. Timestamps are ISO 8601 strings. Use LIKE for pattern matching. - -## JOINs - -JOIN on a table's id column is fast: - JOIN log_events le ON le.id = p.log_event_id -- PK lookup, indexed - -JOIN on any other column (service_id, log_event_id, etc.) is a full table scan and will freeze the UI. Use a correlated subquery instead: - - -- WRONG: hangs for 30-60s - LEFT JOIN log_event_statuses_cache les ON les.log_event_id = p.log_event_id - - -- RIGHT: instant - (SELECT les.volume_per_hour FROM log_event_statuses_cache les WHERE les.log_event_id = p.log_event_id) AS volume_per_hour - -Pull each column you need as a separate subquery. This applies to all tables.`, querySchema), - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "sql": { - Type: "string", - Description: "The SQL query to execute", - }, - "status": { - Type: "string", - Description: "Message shown while the query runs (e.g., 'Checking service status', 'Looking for errors')", - }, - "result": { - Type: "string", - Description: "Message shown when complete. Use {count} for row count if relevant (e.g., 'Found {count} services'). Omit {count} for aggregates (e.g., 'Calculated')", - }, - }, - []string{"sql", "status", "result"}, - ), - } -} - -// Execute runs the query and returns a typed result. -func (t *QueryTool) Execute(input json.RawMessage) (tools.QueryResult, error) { - start := time.Now() - var in tools.QueryInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.QueryResult{}, err - } - - ctx, cancel := withToolTimeout() - defer cancel() - - // Reject queries with full table scans on JOINed tables — these hang for 30-60s. - if err := t.checkQueryPlan(ctx, in.SQL); err != nil { - return tools.QueryResult{}, err - } - - // Use the read pool — every connection has query_only = ON enforced by the driver - rows, err := t.db.ReadRaw().QueryContext(ctx, in.SQL) - if err != nil { - return tools.QueryResult{}, err - } - defer rows.Close() - - cols, err := rows.Columns() - if err != nil { - return tools.QueryResult{}, err - } - - results := make([]map[string]any, 0, 64) - rowsDropped := 0 - totalBytes := 2 // [] in JSON - for rows.Next() { - values := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range values { - ptrs[i] = &values[i] - } - - if err := rows.Scan(ptrs...); err != nil { - return tools.QueryResult{}, err - } - - row := make(map[string]any, len(cols)) - for i, col := range cols { - row[col] = values[i] - } - capRowFields(row) - - rowBytes, err := json.Marshal(row) - if err != nil { - return tools.QueryResult{}, err - } - nextTotal := totalBytes + len(rowBytes) - if len(results) > 0 { - nextTotal++ // JSON comma between rows - } - - if len(results) < maxResultRows && nextTotal <= maxResultBytes { - results = append(results, row) - totalBytes = nextTotal - continue - } - rowsDropped++ - } - - if err := rows.Err(); err != nil { - return tools.QueryResult{}, err - } - - result := tools.QueryResult{Rows: results, RowsDropped: rowsDropped} - duration := time.Since(start) - if rowsDropped > 0 { - t.scope.Info("query result capped", - "duration", duration, - "rows_returned", len(result.Rows), - "rows_dropped", rowsDropped, - ) - } else { - t.scope.Debug("query executed", - "duration", duration, - "rows_returned", len(result.Rows), - ) - } - return result, nil -} - -const ( - maxResultRows = 500 - maxFieldBytes = 4096 // truncate any string value longer than this - maxResultBytes = 102400 // drop trailing rows when total JSON exceeds this -) - -func capRowFields(row map[string]any) { - for k, v := range row { - s, ok := v.(string) - if ok && len(s) > maxFieldBytes { - row[k] = s[:maxFieldBytes] + "…(truncated)" - } - } -} - -// checkQueryPlan runs EXPLAIN QUERY PLAN and rejects queries that would do -// a full table scan on a JOINed table (SCAN ... JOIN). These take 30-60s -// due to a SQLite limitation with expression indexes through views. -func (t *QueryTool) checkQueryPlan(ctx context.Context, sql string) error { - rows, err := t.db.ReadRaw().QueryContext(ctx, "EXPLAIN QUERY PLAN "+sql) - if err != nil { - return nil // let the actual query surface the error - } - defer rows.Close() - - var scans []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - return nil - } - if strings.Contains(detail, "SCAN") && strings.Contains(detail, "JOIN") { - scans = append(scans, detail) - } - } - - if err := rows.Err(); err != nil { - return nil - } - - if len(scans) == 0 { - return nil - } - - return fmt.Errorf( - "query rejected: full table scan detected on a JOINed table, which would hang for 30-60 seconds.\n\n"+ - "Problem: %s\n\n"+ - "Fix: replace the JOIN with a correlated subquery. "+ - "JOIN on a table's id column is fast (e.g., JOIN log_events le ON le.id = p.log_event_id). "+ - "For any other column, use: (SELECT col FROM table WHERE foreign_key = outer.value) AS alias", - strings.Join(scans, "; "), - ) -} diff --git a/internal/app/chattools/query_schema.sql b/internal/app/chattools/query_schema.sql deleted file mode 100644 index 65b13e4e..00000000 --- a/internal/app/chattools/query_schema.sql +++ /dev/null @@ -1,431 +0,0 @@ --- Tero Client Schema --- --- This describes the local SQLite database synced from the Tero control plane. --- All data is scoped to the authenticated user's account. --- --- Key concepts: --- services - Applications producing logs (e.g., 'checkout-service') --- log_events - Distinct event patterns within a service --- policies - AI-identified waste (health checks, duplicate fields, bloat) --- *_cache - Pre-computed status and metrics (query these for current state) --- --- All queries are READ-ONLY. This is a local sync of server data. - --- Live working set of entities (services, log events) referenced in a conversation -CREATE TABLE conversation_contexts ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. - added_by TEXT, -- Who added this entity to context. user: added via @-reference, assistant: added by AI during chat. - conversation_id TEXT, -- Conversation this context belongs to - created_at TEXT, -- When the entity was added to context - entity_id TEXT, -- ID of the context entity - entity_type TEXT -- Type of the context entity. service: an application producing logs, log_event: a specific event pattern. -); - --- Chat session between a user and the AI assistant within a workspace -CREATE TABLE conversations ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - created_at TEXT, -- When the conversation was created - title TEXT, -- AI-generated title, set after first exchange - user_id TEXT, -- WorkOS user ID who owns this conversation - view_id TEXT, -- If set, this conversation is for iterating on a specific view - workspace_id TEXT -- Workspace this conversation belongs to -); - --- Cache table for datadog_account_statuses view. Refreshed by cron service. -CREATE TABLE datadog_account_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID (denormalized from Datadog account) - datadog_account_id TEXT, -- The Datadog account this status belongs to - disabled_services INTEGER, -- Services with DISABLED health - estimated_bytes_reduction_per_hour REAL, -- Account-wide estimated bytes reduction - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Account-wide estimated bytes-based USD/hour savings - estimated_cost_reduction_per_hour_usd REAL, -- Account-wide estimated total USD/hour savings - estimated_cost_reduction_per_hour_volume_usd REAL, -- Account-wide estimated volume-based USD/hour savings - estimated_volume_reduction_per_hour REAL, -- Account-wide estimated volume reduction - health TEXT, -- Overall health of the Datadog account. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - inactive_services INTEGER, -- Services with INACTIVE health - log_active_services INTEGER, -- Services not DISABLED or INACTIVE - log_event_analyzed_count INTEGER, -- Number of log events that have been analyzed - log_event_bytes_per_hour REAL, -- Discovered log event throughput in bytes/hour across all services - log_event_cost_per_hour_bytes_usd REAL, -- Discovered log event ingestion cost in USD/hour across all services - log_event_cost_per_hour_usd REAL, -- Discovered log event total cost in USD/hour across all services - log_event_cost_per_hour_volume_usd REAL, -- Discovered log event indexing cost in USD/hour across all services - log_event_count INTEGER, -- Total log events across all services - log_event_volume_per_hour REAL, -- Discovered log event throughput in events/hour across all services - log_service_count INTEGER, -- Total number of services - observed_bytes_per_hour_after REAL, -- Account-wide observed current bytes - observed_bytes_per_hour_before REAL, -- Account-wide observed pre-approval bytes - observed_cost_per_hour_after_bytes_usd REAL, -- Account-wide measured bytes-based USD/hour cost after approval - observed_cost_per_hour_after_usd REAL, -- Account-wide measured total USD/hour cost after approval - observed_cost_per_hour_after_volume_usd REAL, -- Account-wide measured volume-based USD/hour cost after approval - observed_cost_per_hour_before_bytes_usd REAL, -- Account-wide measured bytes-based USD/hour cost before approval - observed_cost_per_hour_before_usd REAL, -- Account-wide measured total USD/hour cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Account-wide measured volume-based USD/hour cost before approval - observed_volume_per_hour_after REAL, -- Account-wide observed current volume - observed_volume_per_hour_before REAL, -- Account-wide observed pre-approval volume - ok_services INTEGER, -- Services with OK health - policy_approved_count INTEGER, -- Policies approved by user - policy_dismissed_count INTEGER, -- Policies dismissed by user - policy_pending_count INTEGER, -- Policies awaiting user action - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - ready_for_use INTEGER, -- True when at least 1 log event has been analyzed - service_cost_per_hour_volume_usd REAL, -- Service-level indexing cost in USD/hour across all services - service_volume_per_hour REAL -- Ground-truth throughput in events/hour from service_log_volumes across all services -); - --- Datadog integration configuration for an account, one per account -CREATE TABLE datadog_accounts ( - id TEXT, -- Unique identifier of the Datadog configuration - account_id TEXT, -- Parent account this configuration belongs to - cost_per_gb_ingested REAL, -- Cost per GB of log data ingested (USD). NULL = using Datadog's published rate ($0.10/GB). Set to override with actual contract rate. - created_at TEXT, -- When the Datadog account was created - name TEXT, -- Display name for this Datadog account - site TEXT -- Datadog regional site. US1: datadoghq.com, US3: us3.datadoghq.com, US5: us5.datadoghq.com, EU1: datadoghq.eu, US1_FED: ddog-gov.com, AP1: ap1.datadoghq.com, AP2: ap2.datadoghq.com. -); - --- Discovered Datadog log index where logs are stored (e.g., main, security, compliance) -CREATE TABLE datadog_log_indexes ( - id TEXT, -- Unique identifier for this index record - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from datadog_account.account_id. - cost_per_million_events_indexed REAL, -- Cost per million events indexed in this index (USD). NULL = using Datadog's published rate ($1.70/M). SIEM indexes cost more — set accordingly. - created_at TEXT, -- When this index was first discovered - datadog_account_id TEXT, -- The Datadog account this index belongs to - name TEXT -- Index name from Datadog (e.g., 'main', 'security', 'compliance') - this is the stable identifier -); - --- Ground truth record for a field in a log event. Accumulates metadata as more production data is observed. -CREATE TABLE log_event_fields ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from log_event.account_id. - baseline_avg_bytes REAL, -- Current trailing 7-day volume-weighted average bytes for this attribute. Refreshed on volume ingestion. - created_at TEXT, -- When this field was first discovered - field_path TEXT, -- Unambiguous path segments, e.g. {attributes, http, status} - log_event_id TEXT, -- The log event this field belongs to - -- Top-N observed values with proportions. Populated on-demand for fields that need faceting (e.g., user agents for bot detection). - -- Opaque JSON data. Query using SQLite json_extract() or json_each(). - value_distribution TEXT -); - --- AI-generated recommendation for a specific quality category on a log event, scoped to a workspace for approval -CREATE TABLE log_event_policies ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - action TEXT, -- What this policy does when enforced: 'drop' (remove all events), 'sample' (keep at reduced rate), 'filter' (drop subset by field value), 'trim' (remove/truncate fields), 'none' (informational only). Auto-set via trigger. - -- Category-specific analysis from AI. JSON object with one field populated matching the category, containing the analysis and recommended actions. - -- JSON object. Fields: - -- $.pii_leakage - PII leakage analysis (optional) - -- $.pii_leakage.fields[] - List of fields that may contain PII - -- $.pii_leakage.fields[].path[] string[] - Path to field as array of segments - -- $.pii_leakage.fields[].types[] string[] - List of sensitive data types this field may contain - -- $.pii_leakage.fields[].observed boolean - Whether actual sensitive data was seen in examples - -- $.secrets_leakage - Secrets leakage analysis (optional) - -- $.secrets_leakage.fields[] - List of fields that may contain secrets - -- $.phi_leakage - PHI leakage analysis (optional) - -- $.phi_leakage.fields[] - List of fields that may contain PHI - -- $.payment_data_leakage - Payment data leakage analysis (optional) - -- $.payment_data_leakage.fields[] - List of fields that may contain payment data - -- $.health_checks - Health checks analysis (optional) - -- $.bot_traffic - Bot traffic analysis (optional) - -- $.bot_traffic.user_agent_field[] string[] - Path to user-agent field as array of segments - -- $.bot_traffic.bot_proportion number - Fraction of traffic identified as bot/crawler (optional) - -- $.debug_artifacts - Debug artifacts analysis (optional) - -- $.malformed - Malformed data analysis (optional) - -- $.broken_records - Broken records analysis (optional) - -- $.broken_records.min_interval_seconds number - Suggested minimum interval between kept events in seconds - -- $.commodity_traffic - Commodity traffic analysis (optional) - -- $.commodity_traffic.min_interval_seconds number - Suggested minimum interval between kept events in seconds - -- $.redundant_events - Redundant events analysis (optional) - -- $.dead_weight - Dead weight analysis (optional) - -- $.duplicate_fields - Duplicate fields analysis (optional) - -- $.duplicate_fields.pairs[] - List of duplicate field pairs - -- $.duplicate_fields.pairs[].remove[] string[][] - List of duplicate field paths to remove - -- $.duplicate_fields.pairs[].keep[] string[] - Canonical field path to keep - -- $.instrumentation_bloat - Instrumentation bloat analysis (optional) - -- $.instrumentation_bloat.fields[] string[][] - List of field paths that are instrumentation bloat - -- $.oversized_fields - Oversized fields analysis (optional) - -- $.oversized_fields.fields[] string[][] - List of field paths that are oversized - -- $.wrong_level - Wrong level analysis (optional) - -- $.wrong_level.current_level string - Current normalized severity level - -- $.wrong_level.suggested_level string - Suggested normalized severity level - -- - -- Example: json_extract(analysis, '$.field_name') - analysis TEXT, - approved_at TEXT, -- When this policy was approved by a user - approved_baseline_avg_bytes REAL, -- Baseline avg bytes frozen at approval time. Snapshot of log_event.baseline_avg_bytes. - approved_baseline_volume_per_hour REAL, -- Baseline volume/hour frozen at approval time. Snapshot of log_event.baseline_volume_per_hour. - approved_by TEXT, -- User ID who approved this policy - category TEXT, -- Quality issue category this policy addresses. Compliance: pii_leakage, secrets_leakage, phi_leakage, payment_data_leakage. Waste: health_checks, bot_traffic, debug_artifacts, malformed, broken_records, commodity_traffic, redundant_events, dead_weight. Quality: duplicate_fields, instrumentation_bloat, oversized_fields, wrong_level. - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), or quality (field-level improvements). Auto-set via trigger from CategoryMeta. - created_at TEXT, -- When this policy was created - dismissed_at TEXT, -- When this policy was dismissed by a user - dismissed_by TEXT, -- User ID who dismissed this policy - log_event_id TEXT, -- The log event this policy applies to - severity TEXT, -- Max compliance severity across sensitivity types. NULL for non-compliance categories. Auto-set via trigger. Values: low, medium, high, critical. - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false). Auto-set via trigger from CategoryMeta. - workspace_id TEXT -- The workspace that owns this policy -); - --- Cache table for per-category policy aggregations. Refreshed by cron service. -CREATE TABLE log_event_policy_category_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - action TEXT, -- What the policy does: drop (remove events), sample (reduce rate), filter (drop subset), trim (modify fields), none (informational) - approved_count INTEGER, -- Policies approved by user in this category - boundary TEXT, -- Where this category stops applying — what NOT to flag - category TEXT, -- Quality issue category (e.g., pii_leakage, noise, health_checks) - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - dismissed_count INTEGER, -- Policies dismissed by user in this category - display_name TEXT, -- Human-readable category name (e.g., 'PII Leakage') - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved by all pending policies in this category combined - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour from pending policies in this category - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour from pending policies in this category - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour from pending policies in this category - estimated_volume_reduction_per_hour REAL, -- Events/hour saved by all pending policies in this category combined - events_with_volumes INTEGER, -- Log events in this category that have volume data (subset of total_event_count) - pending_count INTEGER, -- Policies awaiting user review in this category - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - principle TEXT, -- What this category detects — the fundamental test for membership - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false) - total_event_count INTEGER -- Total log events that have a policy in this category -); - --- Cache table for log_event_policy_statuses view. Refreshed by cron service. -CREATE TABLE log_event_policy_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - action TEXT, -- What the policy does: drop (remove events), sample (reduce rate), filter (drop subset), trim (modify fields), none (informational) - approved_at TEXT, -- When this policy was approved by a user - bytes_per_hour REAL, -- Current throughput of the targeted log event in bytes/hour - category TEXT, -- Quality issue category this policy addresses (e.g., pii_leakage, noise, health_checks) - category_type TEXT, -- Type of problem: compliance (legal/security risk), waste (event-level cuts), quality (field-level improvements). - created_at TEXT, -- When this policy was created - dismissed_at TEXT, -- When this policy was dismissed by a user - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved if this policy applied alone. NULL if not estimable. - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour from bytes reduction - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour (bytes + volume) - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour from volume reduction - estimated_volume_reduction_per_hour REAL, -- Events/hour saved if this policy applied alone. NULL if not estimable. - log_event_id TEXT, -- The log event this policy targets - log_event_name TEXT, -- Name of the targeted log event (denormalized for display) - policy_id TEXT, -- The policy this status row represents - service_id TEXT, -- Service that produces the targeted log event (denormalized) - service_name TEXT, -- Name of the service (denormalized for display) - severity TEXT, -- Max compliance severity across sensitivity types. NULL for non-compliance categories. Values: low, medium, high, critical. - status TEXT, -- User decision on this policy. PENDING (awaiting review), APPROVED (accepted for enforcement), DISMISSED (rejected by user). - subjective INTEGER, -- Whether this category requires AI judgment (true) vs mechanically verifiable (false) - survival_rate REAL, -- Fraction of events that survive this policy (0.0 = all dropped, 1.0 = all kept). NULL if not estimable. - volume_per_hour REAL, -- Current throughput of the targeted log event in events/hour - workspace_id TEXT -- The workspace that owns this policy -); - --- Cache table for log_event_statuses view. Refreshed by cron service. -CREATE TABLE log_event_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID for tenant isolation - approved_policy_count INTEGER, -- Policies approved by user - bytes_per_hour REAL, -- Current throughput in bytes/hour (rolling 7-day) - cost_per_hour_bytes_usd REAL, -- Current ingestion cost in USD/hour - cost_per_hour_usd REAL, -- Current total cost in USD/hour (bytes + volume) - cost_per_hour_volume_usd REAL, -- Current indexing cost in USD/hour - dismissed_policy_count INTEGER, -- Policies dismissed by user - estimated_bytes_reduction_per_hour REAL, -- Bytes/hour saved by all policies combined - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated ingestion savings in USD/hour - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total savings in USD/hour (bytes + volume) - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated indexing savings in USD/hour - estimated_volume_reduction_per_hour REAL, -- Events/hour saved by all policies combined - has_been_analyzed INTEGER, -- Whether AI has analyzed this log event - has_volumes INTEGER, -- Whether volume data exists for this log event - log_event_id TEXT, -- The log event this status belongs to - observed_bytes_per_hour_after REAL, -- Measured bytes/hour after policy approval (current) - observed_bytes_per_hour_before REAL, -- Measured bytes/hour before first policy approval - observed_cost_per_hour_after_bytes_usd REAL, -- Measured ingestion cost after approval (current) - observed_cost_per_hour_after_usd REAL, -- Measured total cost after approval (current) - observed_cost_per_hour_after_volume_usd REAL, -- Measured indexing cost after approval (current) - observed_cost_per_hour_before_bytes_usd REAL, -- Measured ingestion cost before approval - observed_cost_per_hour_before_usd REAL, -- Measured total cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Measured indexing cost before approval - observed_volume_per_hour_after REAL, -- Measured events/hour after policy approval (current) - observed_volume_per_hour_before REAL, -- Measured events/hour before first policy approval - pending_policy_count INTEGER, -- Policies awaiting user action - policy_count INTEGER, -- Total non-dismissed policies - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - service_id TEXT, -- Service ID (denormalized from log_event) - volume_per_hour REAL -- Current throughput in events/hour (rolling 7-day) -); - --- Distinct log message pattern discovered within a service. Defines how to parse and match logs using codecs and matchers. -CREATE TABLE log_events ( - id TEXT, -- Unique identifier of the log event - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from service.account_id. - baseline_avg_bytes REAL, -- Current trailing 7-day volume-weighted average bytes/event. Refreshed on volume ingestion. - baseline_volume_per_hour REAL, -- Current trailing 7-day average events/hour. Refreshed on volume ingestion. - created_at TEXT, -- When the log event was created - description TEXT, -- What the event is and what data instances carry. Helps engineers decide whether to look here. - event_nature TEXT, -- What this event records: system (internal mechanics), traffic (request flow), activity (actor+action+resource), control (access/permission decisions). - -- Sample log records captured during discovery, used for AI analysis and pattern validation - -- JSON array of objects. Each element: - -- $[0].timestamp - When the log event occurred (RFC3339) - -- $[0].body string - Log message content - -- $[0].severity_text string - Severity level as text (e.g., INFO, ERROR) - -- $[0].severity_number number - OTel severity level number (1-24) - -- $[0].trace_id string - Distributed trace ID (optional) - -- $[0].span_id string - Span ID within trace (optional) - -- $[0].attributes object - Log-level attributes (http.status, error.message, etc.) - -- $[0].resource_attributes object - Resource attributes (service.name, deployment.environment, etc.) - -- $[0].scope_attributes object - Instrumentation scope attributes (optional) - -- - -- Example: json_extract(examples, '$[0].field_name') - examples TEXT, - -- JSON rules that match incoming logs to this event. Each matcher specifies a field path, operator, and value. - -- JSON array of objects. Each element: - -- $[0].field_path[] string[] - Path to field as array of segments - -- $[0].match_type string - Match operator: exact, contains, starts_with, ends_with, regex, exists, missing - -- $[0].match_value string - Value to match against - -- $[0].case_insensitive boolean - Whether matching is case-insensitive (optional) - -- $[0].negate boolean - Whether to invert match result (optional) - -- - -- Example: json_extract(matchers, '$[0].field_name') - matchers TEXT, - name TEXT, -- Snake_case identifier unique per service, e.g. nginx_access_log - service_id TEXT, -- Service that produces this event - severity TEXT, -- Predominant log severity level, derived from example records. Nullable when examples have no severity info. Values: debug, info, warn, error, other. - signal_purpose TEXT -- What role this event serves: diagnostic (investigate incidents), operational (system behavior), lifecycle (state transitions), ephemeral (transient state). -); - --- Single message in a chat conversation. Append-only — never updated or deleted. -CREATE TABLE messages ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from conversation.account_id. - -- Array of typed content blocks: text, thinking, tool_use, tool_result - -- JSON array of objects. Each element: - -- $[0].type string - Block type discriminator: text, thinking, tool_use, tool_result - -- $[0].text - Text content (when type=text) - -- $[0].text.content string - Text content - -- $[0].thinking - AI reasoning content (when type=thinking) - -- $[0].thinking.content string - AI reasoning content - -- $[0].tool_use - Tool call (when type=tool_use) - -- $[0].tool_use.id string - Unique tool call identifier - -- $[0].tool_use.name string - Tool name - -- $[0].tool_use.input[] number[] - Tool input parameters as JSON object - -- $[0].tool_result - Tool response (when type=tool_result) - -- $[0].tool_result.tool_use_id string - ID of the tool use this result corresponds to - -- $[0].tool_result.is_error boolean - Whether the tool call failed - -- $[0].tool_result.error string - Human-readable error message (when is_error=true) - -- $[0].tool_result.content[] number[] - Structured result data (when is_error=false) - -- - -- Example: json_extract(content, '$[0].field_name') - content TEXT, - conversation_id TEXT, -- Conversation this message belongs to - created_at TEXT, -- When the message was created - model TEXT, -- AI model that produced this message. Null for user messages. - role TEXT, -- Who sent this message. user: human-originated, assistant: AI-originated. - stop_reason TEXT -- Why the assistant stopped generating. end_turn: completed response, tool_use: paused to call a tool. Null for user messages. -); - --- Cache table for service_statuses view. Refreshed by cron service. -CREATE TABLE service_statuses_cache ( - id TEXT, - account_id TEXT, -- Account ID (denormalized from service) - datadog_account_id TEXT, -- The Datadog account performing discovery - estimated_bytes_reduction_per_hour REAL, -- Estimated bytes reduction from active policies - estimated_cost_reduction_per_hour_bytes_usd REAL, -- Estimated bytes-based USD/hour savings from active policies - estimated_cost_reduction_per_hour_usd REAL, -- Estimated total USD/hour savings from active policies - estimated_cost_reduction_per_hour_volume_usd REAL, -- Estimated volume-based USD/hour savings from active policies - estimated_volume_reduction_per_hour REAL, -- Estimated volume reduction from active policies - health TEXT, -- Overall health of the service. DISABLED (integration turned off), INACTIVE (no data received), OK (healthy). - log_event_analyzed_count INTEGER, -- Number of log events that have been analyzed - log_event_bytes_per_hour REAL, -- Discovered log event throughput in bytes/hour from rolling 7-day window - log_event_cost_per_hour_bytes_usd REAL, -- Discovered log event ingestion cost in USD/hour - log_event_cost_per_hour_usd REAL, -- Discovered log event total cost in USD/hour (bytes + volume) - log_event_cost_per_hour_volume_usd REAL, -- Discovered log event indexing cost in USD/hour - log_event_count INTEGER, -- Total number of log events discovered for this service - log_event_volume_per_hour REAL, -- Discovered log event throughput in events/hour from rolling 7-day window - observed_bytes_per_hour_after REAL, -- Measured bytes/hour after policy approval - observed_bytes_per_hour_before REAL, -- Measured bytes/hour before first policy approval - observed_cost_per_hour_after_bytes_usd REAL, -- Measured bytes-based USD/hour cost after approval - observed_cost_per_hour_after_usd REAL, -- Measured total USD/hour cost after approval - observed_cost_per_hour_after_volume_usd REAL, -- Measured volume-based USD/hour cost after approval - observed_cost_per_hour_before_bytes_usd REAL, -- Measured bytes-based USD/hour cost before approval - observed_cost_per_hour_before_usd REAL, -- Measured total USD/hour cost before approval - observed_cost_per_hour_before_volume_usd REAL, -- Measured volume-based USD/hour cost before approval - observed_volume_per_hour_after REAL, -- Measured events/hour after policy approval - observed_volume_per_hour_before REAL, -- Measured events/hour before first policy approval - policy_approved_count INTEGER, -- Policies approved by user - policy_dismissed_count INTEGER, -- Policies dismissed by user - policy_pending_count INTEGER, -- Policies awaiting user action - policy_pending_critical_count INTEGER, -- Pending policies with critical compliance severity - policy_pending_high_count INTEGER, -- Pending policies with high compliance severity - policy_pending_low_count INTEGER, -- Pending policies with low compliance severity - policy_pending_medium_count INTEGER, -- Pending policies with medium compliance severity - service_cost_per_hour_volume_usd REAL, -- Service-level indexing cost in USD/hour based on total service volume - service_debug_volume_per_hour REAL, -- Debug-level events/hour from rolling 7-day window - service_error_volume_per_hour REAL, -- Error-level events/hour from rolling 7-day window - service_id TEXT, -- The service this status belongs to - service_info_volume_per_hour REAL, -- Info-level events/hour from rolling 7-day window - service_other_volume_per_hour REAL, -- Other-level events/hour (trace, fatal, critical, unknown) from rolling 7-day window - service_volume_per_hour REAL, -- Ground-truth service throughput in events/hour from service_log_volumes rolling 7-day window - service_warn_volume_per_hour REAL -- Warn-level events/hour from rolling 7-day window -); - --- Application or microservice that produces logs. Central entity in the data catalog. -CREATE TABLE services ( - id TEXT, -- Unique identifier of the service - account_id TEXT, -- Parent account this service belongs to - created_at TEXT, -- When the service was created - description TEXT, -- AI-generated description of what this service does and its telemetry characteristics - enabled INTEGER, -- Whether log analysis and policy generation is active for this service - initial_weekly_log_count INTEGER, -- Approximate weekly log count from initial discovery (7-day period from Datadog) - name TEXT -- Service identifier in telemetry (e.g., 'checkout-service') -); - --- Group of users within a workspace that reviews policies and manages services -CREATE TABLE teams ( - id TEXT, -- Unique identifier of the team - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from workspace.account_id. - created_at TEXT, -- When the team was created - name TEXT, -- Human-readable name within the workspace - workspace_id TEXT -- Parent workspace this team belongs to -); - --- User's saved reference to a view, elevating it from conversation history to their personal collection -CREATE TABLE view_favorites ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from view.account_id. - created_at TEXT, -- When the view was favorited - user_id TEXT, -- WorkOS user ID who favorited this view - view_id TEXT -- The view being favorited -); - --- Saved SQL query against the local catalog, created by the AI assistant. Immutable — editing creates a fork. -CREATE TABLE views ( - id TEXT, -- Unique identifier - account_id TEXT, -- Denormalized for tenant isolation. Auto-set via trigger from message.account_id. - conversation_id TEXT, -- Denormalized from message for easier queries - created_at TEXT, -- When the view was created - created_by TEXT, -- WorkOS user ID who triggered this view creation - entity_type TEXT, -- Which catalog entity this view queries. service: applications, log_event: event patterns, policy: quality recommendations. - forked_from_id TEXT, -- Parent view if this is a refinement/iteration - message_id TEXT, -- Assistant message that created this view via show_view tool call - query TEXT -- Raw SQL query executed against the client's local SQLite database -); - --- Purpose-aligned environment for reviewing and classifying telemetry. Each workspace has its own policies and teams. -CREATE TABLE workspaces ( - id TEXT, -- Unique identifier of the workspace - account_id TEXT, -- Parent account this workspace belongs to - created_at TEXT, -- When the workspace was created - name TEXT, -- Human-readable name within the account - purpose TEXT -- Primary purpose determining evaluation strategy. observability: performance and reliability, security: threat detection, compliance: regulatory requirements. -); - diff --git a/internal/app/chattools/query_test.go b/internal/app/chattools/query_test.go deleted file mode 100644 index 57a78428..00000000 --- a/internal/app/chattools/query_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -func TestCheckQueryPlan(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - // Simulate PowerSync pattern: underlying tables store JSON, views expose columns. - // Expression indexes exist but SQLite can't use them through views for JOINs. - for _, ddl := range []string{ - `CREATE TABLE ps_data__parent (id TEXT PRIMARY KEY, data TEXT)`, - `CREATE TABLE ps_data__child (id TEXT PRIMARY KEY, data TEXT)`, - `CREATE INDEX idx_child_parent_id ON ps_data__child (CAST(json_extract(data, '$.parent_id') AS TEXT))`, - `CREATE VIEW parent AS SELECT id, CAST(json_extract(data, '$.name') AS TEXT) as name FROM ps_data__parent`, - `CREATE VIEW child AS SELECT id, CAST(json_extract(data, '$.parent_id') AS TEXT) as parent_id, CAST(json_extract(data, '$.value') AS TEXT) as value FROM ps_data__child`, - } { - if _, err := db.Raw().ExecContext(ctx, ddl); err != nil { - t.Fatalf("DDL: %v", err) - } - } - - tool := NewQueryTool(db, logtest.NewScope(t)) - - t.Run("simple select passes", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, "SELECT * FROM parent") - if err != nil { - t.Errorf("expected no error, got: %v", err) - } - }) - - t.Run("PK join passes", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, - "SELECT c.value FROM child c JOIN parent p ON p.id = c.parent_id") - if err != nil { - t.Errorf("expected no error for PK join, got: %v", err) - } - }) - - t.Run("non-PK left join rejected", func(t *testing.T) { - t.Parallel() - // LEFT JOIN on child.parent_id through a view — full table scan. - err := tool.checkQueryPlan(ctx, - "SELECT p.name FROM parent p LEFT JOIN child c ON c.parent_id = p.id") - if err == nil { - t.Fatal("expected error for SCAN join, got nil") - } - if !strings.Contains(err.Error(), "query rejected") { - t.Errorf("expected 'query rejected' in error, got: %v", err) - } - if !strings.Contains(err.Error(), "SCAN") { - t.Errorf("expected 'SCAN' in error details, got: %v", err) - } - }) - - t.Run("invalid SQL returns nil", func(t *testing.T) { - t.Parallel() - err := tool.checkQueryPlan(ctx, "NOT VALID SQL AT ALL") - if err != nil { - t.Errorf("expected nil for invalid SQL, got: %v", err) - } - }) -} - -func TestQueryToolExecute_CapsLargeResults(t *testing.T) { - t.Parallel() - - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - if _, err := db.Raw().ExecContext(ctx, `CREATE TABLE test_rows (id INTEGER PRIMARY KEY, payload TEXT)`); err != nil { - t.Fatalf("create table: %v", err) - } - - payload := strings.Repeat("x", 2000) - for i := 0; i < 200; i++ { - if _, err := db.Raw().ExecContext(ctx, `INSERT INTO test_rows (payload) VALUES (?)`, fmt.Sprintf("%s-%d", payload, i)); err != nil { - t.Fatalf("insert row %d: %v", i, err) - } - } - - tool := NewQueryTool(db, logtest.NewScope(t)) - input, err := json.Marshal(map[string]any{ - "sql": "SELECT id, payload FROM test_rows ORDER BY id", - "status": "running", - "result": "done", - }) - if err != nil { - t.Fatalf("marshal input: %v", err) - } - - result, err := tool.Execute(input) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if len(result.Rows) == 0 { - t.Fatalf("expected at least one row") - } - if result.RowsDropped == 0 { - t.Fatalf("expected rows to be dropped by cap") - } -} diff --git a/internal/app/chattools/reads.go b/internal/app/chattools/reads.go new file mode 100644 index 00000000..841e6758 --- /dev/null +++ b/internal/app/chattools/reads.go @@ -0,0 +1,214 @@ +package tools + +import ( + "encoding/json" + "fmt" + + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" + "github.com/usetero/cli/internal/boundary/chat" + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/domain/tools" +) + +// The read tools expose the control-plane catalog to the chat agent over +// GraphQL. Each returns structured rows in the tool result (fed back to the +// model) and a one-line summary for the conversation UI. + +func deref(p *float64) any { + if p == nil { + return nil + } + return *p +} + +// NewListServicesTool lists enabled services with their current status. +func NewListServicesTool(services graphql.ServiceSet) ActionTool { + def := chat.Tool{ + Name: "list_services", + Description: "List enabled services with health, log-event counts, throughput, and cost. Use this to answer questions about the account's services.", + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } + + executor := func(_ json.RawMessage) (tools.Result, error) { + ctx, cancel := withToolTimeout() + defer cancel() + + statuses, err := services.Status.ListServiceStatuses(ctx) + if err != nil { + return tools.Result{}, fmt.Errorf("list services: %w", err) + } + + rows := make([]map[string]any, 0, len(statuses)) + for _, s := range statuses { + rows = append(rows, map[string]any{ + "id": s.ID, + "name": s.Name, + "health": string(s.Health), + "log_events": s.LogEventCount, + "events_per_hour": deref(s.ServiceVolumePerHour), + "cost_per_hour_usd": deref(s.ServiceCostPerHourVolumeUSD), + "analyzed_log_events": s.LogEventAnalyzedCount, + }) + } + return tools.Result{Content: map[string]any{"services": rows, "count": len(rows)}}, nil + } + + return NewActionTool(def, executor, listConfig("Listing services", "services", "service")) +} + +// NewListIssuesTool lists active issues with detail. +func NewListIssuesTool(services graphql.ServiceSet) ActionTool { + def := chat.Tool{ + Name: "list_issues", + Description: "List active issues (highest priority first) with title, priority, owning service, and cost. Use this to answer questions about open issues.", + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } + + executor := func(_ json.RawMessage) (tools.Result, error) { + ctx, cancel := withToolTimeout() + defer cancel() + + issues, err := services.Issues.List(ctx) + if err != nil { + return tools.Result{}, fmt.Errorf("list issues: %w", err) + } + + rows := make([]map[string]any, 0, len(issues)) + for _, i := range issues { + rows = append(rows, map[string]any{ + "id": i.ID, + "display_id": i.DisplayID, + "title": i.Title, + "priority": string(i.Priority), + "service": i.ServiceName, + "cost_per_hour_usd": deref(i.CostPerHour), + }) + } + return tools.Result{Content: map[string]any{"issues": rows, "count": len(rows)}}, nil + } + + return NewActionTool(def, executor, listConfig("Listing issues", "issues", "issue")) +} + +// NewListChecksTool lists product checks with posture. +func NewListChecksTool(services graphql.ServiceSet) ActionTool { + def := chat.Tool{ + Name: "list_checks", + Description: "List product checks with their domain (cost/compliance) and account-scoped posture (open findings, active issues, affected services, current cost).", + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } + + executor := func(_ json.RawMessage) (tools.Result, error) { + ctx, cancel := withToolTimeout() + defer cancel() + + catalog, err := services.Checks.List(ctx) + if err != nil { + return tools.Result{}, fmt.Errorf("list checks: %w", err) + } + + rows := make([]map[string]any, 0, len(catalog.Checks)) + for _, c := range catalog.Checks { + rows = append(rows, map[string]any{ + "id": c.ID, + "name": c.Name, + "domain": string(c.Domain), + "open_findings": c.OpenFindingCount, + "active_issues": c.ActiveIssueCount, + "affected_services": c.AffectedServiceCount, + "cost_per_hour_usd": deref(c.CurrentCostPerHour), + }) + } + return tools.Result{Content: map[string]any{"checks": rows, "count": len(rows)}}, nil + } + + return NewActionTool(def, executor, listConfig("Listing checks", "checks", "check")) +} + +// NewListEdgeInstancesTool lists the account's edge instances. +func NewListEdgeInstancesTool(services graphql.ServiceSet) ActionTool { + def := chat.Tool{ + Name: "list_edge_instances", + Description: "List edge instances syncing policies from this account, with the service they run and when they last synced.", + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } + + executor := func(_ json.RawMessage) (tools.Result, error) { + ctx, cancel := withToolTimeout() + defer cancel() + + fleet, err := services.EdgeInstances.List(ctx) + if err != nil { + return tools.Result{}, fmt.Errorf("list edge instances: %w", err) + } + + rows := make([]map[string]any, 0, len(fleet.Instances)) + for _, inst := range fleet.Instances { + rows = append(rows, map[string]any{ + "id": inst.ID, + "instance_id": inst.InstanceID, + "service": inst.ServiceName, + "namespace": inst.ServiceNamespace, + "last_sync_at": inst.LastSyncAt, + }) + } + return tools.Result{Content: map[string]any{"edge_instances": rows, "count": len(rows)}}, nil + } + + return NewActionTool(def, executor, listConfig("Listing edge instances", "edge instances", "edge instance")) +} + +// NewAccountStatusTool returns the account-level status summary. +func NewAccountStatusTool(services graphql.ServiceSet) ActionTool { + def := chat.Tool{ + Name: "account_status", + Description: "Get the account's overall status: readiness, service counts, log-event coverage, and total throughput/cost. Use this for high-level questions about the account.", + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } + + executor := func(_ json.RawMessage) (tools.Result, error) { + ctx, cancel := withToolTimeout() + defer cancel() + + summary, err := services.Status.GetAccountSummary(ctx) + if err != nil { + return tools.Result{}, fmt.Errorf("account status: %w", err) + } + + return tools.Result{Content: map[string]any{ + "ready_for_use": summary.ReadyForUse, + "health": string(summary.Health), + "services": summary.ServiceCount, + "active_services": summary.ActiveServices, + "ok_services": summary.OkServices, + "disabled_services": summary.DisabledServices, + "inactive_services": summary.InactiveServices, + "log_events": summary.EventCount, + "analyzed_log_events": summary.AnalyzedCount, + "events_per_hour": deref(summary.TotalVolumePerHour), + "cost_per_hour_usd": deref(summary.TotalCostPerHour), + }}, nil + } + + config := action.Config{ + DisplayName: func(json.RawMessage) string { return "Account status" }, + Status: func(json.RawMessage) string { return "Checking account status" }, + Result: func(tools.Result) string { return "Account status" }, + } + return NewActionTool(def, executor, config) +} + +// listConfig builds an action.Config for a list tool with a {count} result. +func listConfig(status, plural, singular string) action.Config { + return action.Config{ + DisplayName: func(json.RawMessage) string { return status }, + Status: func(json.RawMessage) string { return status }, + Result: func(result tools.Result) string { + n, _ := result.Content["count"].(int) + if n == 1 { + return fmt.Sprintf("Found 1 %s", singular) + } + return fmt.Sprintf("Found %d %s", n, plural) + }, + } +} diff --git a/internal/app/chattools/registry.go b/internal/app/chattools/registry.go index 9dacc0db..9c5304fd 100644 --- a/internal/app/chattools/registry.go +++ b/internal/app/chattools/registry.go @@ -17,29 +17,18 @@ type ActionTool struct { // Registry holds tool instances and provides definitions. type Registry struct { - Query *QueryTool // kept for direct access from query UI model - Show *ShowTool // kept for direct access from show UI model actions map[string]ActionTool } -// NewRegistry creates a registry with the query tool, show tool, and a map of action tools. -func NewRegistry(query *QueryTool, show *ShowTool, actions map[string]ActionTool) *Registry { - return &Registry{ - Query: query, - Show: show, - actions: actions, - } +// NewRegistry creates a registry from a map of action tools. All chat tools +// are GraphQL-backed action tools; there is no local-SQL query surface. +func NewRegistry(actions map[string]ActionTool) *Registry { + return &Registry{actions: actions} } // Definitions returns tool definitions for the chat API. func (r *Registry) Definitions() []chat.Tool { - var defs []chat.Tool - if r.Query != nil { - defs = append(defs, r.Query.Definition()) - } - if r.Show != nil { - defs = append(defs, r.Show.Definition()) - } + defs := make([]chat.Tool, 0, len(r.actions)) for _, a := range r.actions { defs = append(defs, a.Def) } diff --git a/internal/app/chattools/runtime.go b/internal/app/chattools/runtime.go index 56f8c3c1..eaad1123 100644 --- a/internal/app/chattools/runtime.go +++ b/internal/app/chattools/runtime.go @@ -3,12 +3,10 @@ package tools import ( "context" "time" - - "github.com/usetero/cli/internal/sqlite" ) -const toolDBTimeout = 3 * time.Second +const toolTimeout = 3 * time.Second func withToolTimeout() (context.Context, context.CancelFunc) { - return sqlite.WithTimeout(context.Background(), toolDBTimeout) + return context.WithTimeout(context.Background(), toolTimeout) } diff --git a/internal/app/chattools/show.go b/internal/app/chattools/show.go deleted file mode 100644 index a5f3938a..00000000 --- a/internal/app/chattools/show.go +++ /dev/null @@ -1,219 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// ShowTool resolves entity IDs and fetches card data from the local catalog. -type ShowTool struct { - db sqlite.DB - resolvers map[tools.EntityType]entityResolver -} - -// entityResolver fetches card data for a specific entity type. -type entityResolver struct { - fetch func(ctx context.Context, id string) (tools.ShowResult, error) -} - -// NewShowTool creates a new show tool. -func NewShowTool(db sqlite.DB) *ShowTool { - policyStatuses := db.LogEventPolicyStatuses() - return &ShowTool{ - db: db, - resolvers: map[tools.EntityType]entityResolver{ - tools.EntityPolicy: {fetch: func(ctx context.Context, id string) (tools.ShowResult, error) { - return fetchPolicyCard(ctx, policyStatuses, id) - }}, - }, - } -} - -// Name returns the tool name. -func (t *ShowTool) Name() string { return "show" } - -// Definition returns the tool definition for the chat API. -func (t *ShowTool) Definition() chat.Tool { - return chat.Tool{ - Name: t.Name(), - Description: `Show a single entity as a rich, formatted card rendered inline in the conversation. The user sees the card directly — do NOT repeat or summarize the card contents in your response. Just reference it conversationally. - -## How it works - -The card fetches and displays all relevant data automatically from the entity ID. You provide the entity type and either an ID or a lookup query. - -## Input - -Provide either: -- "id" — the entity's UUID (if you already have it from a previous query result) -- "sql" — a SELECT that returns exactly one row with an "id" column (when you need to look up the entity) - -## Entity: policy - -Look up policy IDs from log_event_policy_statuses_cache: - SELECT policy_id AS id FROM log_event_policy_statuses_cache WHERE service_name = '...' AND log_event_name = '...' AND category = '...' - -The card displays: category, service, log event, action, status, severity, volume, throughput, and estimated savings. - -## When to use show vs query - -- Use "show" when presenting a specific entity to the user — it renders a styled card. -- Use "query" for data exploration, aggregations, comparisons, or when you need raw tabular results.`, - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "entity": { - Type: "string", - Enum: []string{"policy"}, - Description: "The entity type to show.", - }, - "id": { - Type: "string", - Description: "UUID of the entity. Provide this OR sql, not both.", - }, - "sql": { - Type: "string", - Description: "SQL query returning exactly one row with an 'id' column. Used when you need to look up the entity. Provide this OR id, not both.", - }, - "title": { - Type: "string", - Description: "Optional title shown above the card.", - }, - }, - []string{"entity"}, - ), - } -} - -// Execute resolves the entity ID and fetches card data. -func (t *ShowTool) Execute(input json.RawMessage) (tools.ShowResult, error) { - var in tools.ShowInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.ShowResult{}, err - } - - resolver, ok := t.resolvers[in.Entity] - if !ok { - return tools.ShowResult{}, fmt.Errorf("unsupported entity type: %q", in.Entity) - } - - id := in.ID - if id == "" && in.SQL != "" { - resolved, err := t.resolveIDFromSQL(in.SQL) - if err != nil { - return tools.ShowResult{}, err - } - id = resolved - } - if id == "" { - return tools.ShowResult{}, fmt.Errorf("either id or sql must be provided") - } - - ctx, cancel := withToolTimeout() - defer cancel() - result, err := resolver.fetch(ctx, id) - if err != nil { - return tools.ShowResult{}, err - } - result.Title = in.Title - return result, nil -} - -// resolveIDFromSQL runs a SQL query and extracts the id column from the single result row. -func (t *ShowTool) resolveIDFromSQL(query string) (string, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - if err := t.checkQueryPlan(ctx, query); err != nil { - return "", err - } - - rows, err := t.db.ReadRaw().QueryContext(ctx, query) - if err != nil { - return "", fmt.Errorf("sql lookup failed: %w", err) - } - defer rows.Close() - - cols, err := rows.Columns() - if err != nil { - return "", err - } - - idIdx := -1 - for i, col := range cols { - if col == "id" { - idIdx = i - break - } - } - if idIdx == -1 { - return "", fmt.Errorf("sql query must return an 'id' column; got columns: %v", cols) - } - - if !rows.Next() { - return "", fmt.Errorf("sql query returned no rows") - } - - values := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range values { - ptrs[i] = &values[i] - } - if err := rows.Scan(ptrs...); err != nil { - return "", err - } - - if rows.Next() { - return "", fmt.Errorf("sql query returned more than one row; must return exactly one") - } - - if err := rows.Err(); err != nil { - return "", err - } - - id, ok := values[idIdx].(string) - if !ok { - return "", fmt.Errorf("id column is not a string: %v", values[idIdx]) - } - return id, nil -} - -// checkQueryPlan rejects queries with full table scans on JOINed tables. -func (t *ShowTool) checkQueryPlan(ctx context.Context, query string) error { - rows, err := t.db.ReadRaw().QueryContext(ctx, "EXPLAIN QUERY PLAN "+query) - if err != nil { - return nil // let the actual query surface the error - } - defer rows.Close() - - var scans []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - return nil - } - if strings.Contains(detail, "SCAN") && strings.Contains(detail, "JOIN") { - scans = append(scans, detail) - } - } - - if err := rows.Err(); err != nil { - return nil - } - - if len(scans) == 0 { - return nil - } - - return fmt.Errorf( - "query rejected: full table scan detected on a JOINed table, "+ - "problem: %s, fix: replace the JOIN with a correlated subquery", - strings.Join(scans, "; "), - ) -} diff --git a/internal/app/chattools/show_policy.go b/internal/app/chattools/show_policy.go deleted file mode 100644 index 2309ca2e..00000000 --- a/internal/app/chattools/show_policy.go +++ /dev/null @@ -1,208 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "strings" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/format" - "github.com/usetero/cli/internal/sqlite" -) - -// fetchPolicyCard loads a policy card via the typed wrapper. -func fetchPolicyCard(ctx context.Context, statuses sqlite.LogEventPolicyStatuses, id string) (tools.ShowResult, error) { - card, err := statuses.GetPolicyCard(ctx, id) - if err != nil { - return tools.ShowResult{}, fmt.Errorf("fetch policy card: %w", err) - } - - policy := domain.ParsePolicy(card) - idShort := shortID(policy.ID.String()) - - // Build the data map for the AI context. The AI sees these fields so it can - // reference the policy conversationally without repeating the card. - data := map[string]any{ - "policy": policy, // typed policy for TUI rendering - "policy_id": policy.ID.String(), - "service_name": policy.ServiceName, - "log_event_name": policy.LogEventName, - "category": policy.Category, - "category_type": string(policy.CategoryType), - "action": string(policy.Action), - "status": string(policy.Status), - } - - if policy.CategoryDisplayName != "" { - data["category_display_name"] = policy.CategoryDisplayName - } - if policy.Severity != "" { - data["severity"] = string(policy.Severity) - } - if policy.Analysis != nil { - data["rationale"] = policy.Analysis.Rationale() - if subtitle := policy.Analysis.Subtitle(); subtitle != "" { - data["subtitle"] = subtitle - } - if detail := policy.Analysis.ActionDetail(); detail != "" { - data["action_detail"] = detail - } - } - if policy.VolumePerHour != nil { - data["volume_per_hour"] = *policy.VolumePerHour - } - if policy.BytesPerHour != nil { - data["bytes_per_hour"] = *policy.BytesPerHour - } - if policy.EstimatedCostPerHour != nil { - data["estimated_cost_per_hour"] = *policy.EstimatedCostPerHour - } - if policy.EstimatedVolumePerHour != nil { - data["estimated_volume_per_hour"] = *policy.EstimatedVolumePerHour - } - if policy.EstimatedBytesPerHour != nil { - data["estimated_bytes_per_hour"] = *policy.EstimatedBytesPerHour - } - if policy.SurvivalRate != nil { - data["survival_rate"] = *policy.SurvivalRate - } - - // Recommendation — same methods the card's viewRecommendation uses. - if h := policy.Headline(); h != "" { - data["headline"] = h - } - if m := policy.Mechanism(); m != "" { - data["mechanism"] = m - } - - // Impact — same method the card's viewImpact uses. - if impact := policy.Impact(); impact != nil { - impactMap := map[string]string{} - if impact.VolumeFrom != "" { - impactMap["volume_from"] = impact.VolumeFrom - impactMap["volume_to"] = impact.VolumeTo - impactMap["volume_reduction"] = impact.VolumePct - } - if impact.StorageFrom != "" { - impactMap["storage_from"] = impact.StorageFrom - impactMap["storage_to"] = impact.StorageTo - impactMap["storage_reduction"] = impact.StoragePct - } - if impact.Savings != "" { - impactMap["savings"] = impact.Savings - } - data["impact"] = impactMap - } - - // Evidence — same method the card's viewEvidence uses. - if ev := domain.BuildEvidence(policy); ev != nil { - data["evidence"] = summarizeEvidence(ev) - } - - // Build card summary for AI context. - categoryName := policy.CategoryDisplayName - if categoryName == "" { - categoryName = format.TitleCase(string(policy.Category)) - } - summary := buildCardSummary(idShort, categoryName, policy) - - return tools.ShowResult{ - Entity: tools.EntityPolicy, - ID: policy.ID.String(), - IDShort: idShort, - CardSummary: summary, - Data: data, - }, nil -} - -// shortID returns the first 4 hex characters of a UUID (stripping dashes). -func shortID(uuid string) string { - hex := strings.ReplaceAll(uuid, "-", "") - if len(hex) > 4 { - return hex[:4] - } - return hex -} - -// buildCardSummary creates a human-readable description of what the card shows, -// so the AI knows what's on the user's screen without repeating it. -func buildCardSummary(idShort, categoryName string, p *domain.Policy) string { - var parts []string - parts = append(parts, fmt.Sprintf( - "Showing policy pol-%s: %s on %s/%s", - idShort, categoryName, p.ServiceName, p.LogEventName, - )) - - sections := []string{"category", "rationale"} - if domain.BuildEvidence(p) != nil { - sections = append(sections, "sample log") - } - if p.Action != "" && p.Action != domain.PolicyActionNone { - sections = append(sections, fmt.Sprintf("action (%s)", string(p.Action))) - } - if p.VolumePerHour != nil { - sections = append(sections, fmt.Sprintf("volume (%s evt/hr)", format.Volume(*p.VolumePerHour))) - } - if cost := p.CostPerYear(); cost != "" { - sections = append(sections, fmt.Sprintf("savings (%s)", cost)) - } - parts = append(parts, "Card displays: "+strings.Join(sections, ", ")+".") - parts = append(parts, "Status: "+string(p.Status)+".") - - if p.Analysis != nil { - if subtitle := p.Analysis.Subtitle(); subtitle != "" { - parts = append(parts, "Key detail: "+subtitle+".") - } - } - - return strings.Join(parts, " ") -} - -// summarizeEvidence creates a flat map describing the evidence shown on the card. -// Uses the same domain types that the policycard's viewEvidence dispatches on. -func summarizeEvidence(ev domain.Evidence) map[string]any { - switch ev := ev.(type) { - case *domain.ConstantVariesEvidence: - constantKeys := make([]string, len(ev.Constant)) - for i, f := range ev.Constant { - constantKeys[i] = f.Key + "=" + f.Value - } - varyingKeys := make([]string, len(ev.Varying)) - for i, f := range ev.Varying { - varyingKeys[i] = f.Key - } - return map[string]any{ - "type": "constant_varies", - "example_count": ev.ExampleCount, - "constant_count": len(ev.Constant), - "varying_count": len(ev.Varying), - "total_fields": len(ev.Constant) + len(ev.Varying), - "constant_fields": constantKeys, - "varying_fields": varyingKeys, - } - case *domain.HighlightedExampleEvidence: - relevant := make([]string, len(ev.RelevantKeys)) - for i, k := range ev.RelevantKeys { - relevant[i] = k.String() - } - return map[string]any{ - "type": "highlighted_example", - "total_fields": len(ev.Attrs), - "relevant_fields": relevant, - } - case *domain.FieldListEvidence: - fields := make([]string, len(ev.Fields)) - for i, f := range ev.Fields { - fields[i] = f.Key - } - return map[string]any{ - "type": "field_list", - "fields": fields, - "total_bytes": ev.TotalBytes, - "bytes_fraction": ev.BytesFraction, - } - default: - return nil - } -} diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index e492f612..dea62060 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -59,13 +59,17 @@ func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { m.statusBar.SetDB(m.db), ) - // Create tool registry with executors. Service enable/disable is a - // synchronous control-plane mutation; policy approval moved to the issue - // model and is no longer a chat action. + // Create tool registry. All tools are GraphQL-backed: the read tools query + // the control-plane catalog and set_service_enabled is a synchronous + // mutation. Policy approval moved to the issue model and is no longer a + // chat action. m.toolRegistry = chattools.NewRegistry( - chattools.NewQueryTool(m.db, m.scope), - chattools.NewShowTool(m.db), map[string]chattools.ActionTool{ + "list_services": chattools.NewListServicesTool(m.services), + "list_issues": chattools.NewListIssuesTool(m.services), + "list_checks": chattools.NewListChecksTool(m.services), + "list_edge_instances": chattools.NewListEdgeInstancesTool(m.services), + "account_status": chattools.NewAccountStatusTool(m.services), "set_service_enabled": chattools.NewSetServiceEnabledAction(m.services.Services), }, ) diff --git a/internal/boundary/graphql/apitest/mock_client.go b/internal/boundary/graphql/apitest/mock_client.go index cc603424..78db89fc 100644 --- a/internal/boundary/graphql/apitest/mock_client.go +++ b/internal/boundary/graphql/apitest/mock_client.go @@ -24,6 +24,7 @@ type MockClient struct { EnableServiceFunc func(ctx context.Context, serviceID string) (*gen.EnableServiceResponse, error) DisableServiceFunc func(ctx context.Context, serviceID string) (*gen.DisableServiceResponse, error) GetIssueSummaryFunc func(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListIssuesFunc func(ctx context.Context, first int) (*gen.ListIssuesResponse, error) ListChecksFunc func(ctx context.Context) (*gen.ListChecksResponse, error) ListEdgeInstancesFunc func(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) GetAccountStatusSummaryFunc func(ctx context.Context) (*gen.GetAccountStatusSummaryResponse, error) @@ -136,6 +137,13 @@ func (m *MockClient) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryR return nil, nil } +func (m *MockClient) ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) { + if m.ListIssuesFunc != nil { + return m.ListIssuesFunc(ctx, first) + } + return nil, nil +} + func (m *MockClient) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { if m.ListChecksFunc != nil { return m.ListChecksFunc(ctx) diff --git a/internal/boundary/graphql/client.go b/internal/boundary/graphql/client.go index d898a116..bcdaa714 100644 --- a/internal/boundary/graphql/client.go +++ b/internal/boundary/graphql/client.go @@ -51,6 +51,7 @@ type Client interface { // Product surface reads GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryResponse, error) + ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) ListEdgeInstances(ctx context.Context) (*gen.ListEdgeInstancesResponse, error) @@ -283,6 +284,14 @@ func (c *client) GetIssueSummary(ctx context.Context) (*gen.GetIssueSummaryRespo return gen.GetIssueSummary(ctx, gql) } +func (c *client) ListIssues(ctx context.Context, first int) (*gen.ListIssuesResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ListIssues(ctx, gql, first) +} + func (c *client) ListChecks(ctx context.Context) (*gen.ListChecksResponse, error) { gql, err := c.gql(ctx) if err != nil { diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index a8f44def..dc8b570f 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -1293,6 +1293,102 @@ func (v *ListEdgeInstancesResponse) GetEdgeInstances() ListEdgeInstancesEdgeInst return v.EdgeInstances } +// ListIssuesIssuesIssueConnection includes the requested fields of the GraphQL type IssueConnection. +type ListIssuesIssuesIssueConnection struct { + TotalCount int `json:"totalCount"` + Edges []ListIssuesIssuesIssueConnectionEdgesIssueEdge `json:"edges"` +} + +// GetTotalCount returns ListIssuesIssuesIssueConnection.TotalCount, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnection) GetTotalCount() int { return v.TotalCount } + +// GetEdges returns ListIssuesIssuesIssueConnection.Edges, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnection) GetEdges() []ListIssuesIssuesIssueConnectionEdgesIssueEdge { + return v.Edges +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdge includes the requested fields of the GraphQL type IssueEdge. +type ListIssuesIssuesIssueConnectionEdgesIssueEdge struct { + Node ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue `json:"node"` +} + +// GetNode returns ListIssuesIssuesIssueConnectionEdgesIssueEdge.Node, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdge) GetNode() ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue { + return v.Node +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue includes the requested fields of the GraphQL type Issue. +type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue struct { + // Unique identifier + Id string `json:"id"` + // Stable account-local issue identifier for product links and support workflows. + DisplayID string `json:"displayID"` + // Short user-facing title for this issue. + Title string `json:"title"` + // How much attention a kept finding deserves. Values: low = Legitimate finding, + // but low urgency or prominence.; medium = Legitimate finding with clear but not + // top-tier urgency.; high = Legitimate finding that deserves strong user attention. + Priority IssuePriority `json:"priority"` + Service *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService `json:"service"` + Cost ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals `json:"cost"` +} + +// GetId returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Id, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetId() string { return v.Id } + +// GetDisplayID returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.DisplayID, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetDisplayID() string { + return v.DisplayID +} + +// GetTitle returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Title, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetTitle() string { return v.Title } + +// GetPriority returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Priority, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetPriority() IssuePriority { + return v.Priority +} + +// GetService returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Service, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetService() *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService { + return v.Service +} + +// GetCost returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Cost, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetCost() ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals { + return v.Cost +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. +type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals struct { + TotalUsdPerHour *float64 `json:"totalUsdPerHour"` +} + +// GetTotalUsdPerHour returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { + return v.TotalUsdPerHour +} + +// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService includes the requested fields of the GraphQL type Service. +type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService struct { + // Service identifier in telemetry (e.g., 'checkout-service') + Name string `json:"name"` +} + +// GetName returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService.Name, and is useful for accessing the field via an interface. +func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService) GetName() string { + return v.Name +} + +// ListIssuesResponse is returned by ListIssues on success. +type ListIssuesResponse struct { + // Query Issues records in your account. + Issues ListIssuesIssuesIssueConnection `json:"issues"` +} + +// GetIssues returns ListIssuesResponse.Issues, and is useful for accessing the field via an interface. +func (v *ListIssuesResponse) GetIssues() ListIssuesIssuesIssueConnection { return v.Issues } + // ListOrganizationsOrganizationsOrganizationConnection includes the requested fields of the GraphQL type OrganizationConnection. type ListOrganizationsOrganizationsOrganizationConnection struct { Edges []ListOrganizationsOrganizationsOrganizationConnectionEdgesOrganizationEdge `json:"edges"` @@ -1819,6 +1915,14 @@ type __ListAccountsInput struct { // GetOrganizationID returns __ListAccountsInput.OrganizationID, and is useful for accessing the field via an interface. func (v *__ListAccountsInput) GetOrganizationID() string { return v.OrganizationID } +// __ListIssuesInput is used internally by genqlient +type __ListIssuesInput struct { + First int `json:"first"` +} + +// GetFirst returns __ListIssuesInput.First, and is useful for accessing the field via an interface. +func (v *__ListIssuesInput) GetFirst() int { return v.First } + // __ListServiceLogEventsInput is used internally by genqlient type __ListServiceLogEventsInput struct { ServiceID string `json:"serviceID"` @@ -2482,6 +2586,55 @@ func ListEdgeInstances( return data_, err_ } +// The query executed by ListIssues. +const ListIssues_Operation = ` +query ListIssues ($first: Int!) { + issues(first: $first, where: {closedAtIsNil:true,ignoredAtIsNil:true}, orderBy: {field:PRIORITY,direction:DESC}) { + totalCount + edges { + node { + id + displayID + title + priority + service { + name + } + cost { + totalUsdPerHour + } + } + } + } +} +` + +// Individual active issues with detail, for the chat agent's read tool. +func ListIssues( + ctx_ context.Context, + client_ graphql.Client, + first int, +) (data_ *ListIssuesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListIssues", + Query: ListIssues_Operation, + Variables: &__ListIssuesInput{ + First: first, + }, + } + + data_ = &ListIssuesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by ListOrganizations. const ListOrganizations_Operation = ` query ListOrganizations { diff --git a/internal/boundary/graphql/gen/queries/issues.graphql b/internal/boundary/graphql/gen/queries/issues.graphql index c862389f..cf44603e 100644 --- a/internal/boundary/graphql/gen/queries/issues.graphql +++ b/internal/boundary/graphql/gen/queries/issues.graphql @@ -17,3 +17,28 @@ query GetIssueSummary { } } } + +# Individual active issues with detail, for the chat agent's read tool. +query ListIssues($first: Int!) { + issues( + first: $first + where: { closedAtIsNil: true, ignoredAtIsNil: true } + orderBy: { field: PRIORITY, direction: DESC } + ) { + totalCount + edges { + node { + id + displayID + title + priority + service { + name + } + cost { + totalUsdPerHour + } + } + } + } +} diff --git a/internal/boundary/graphql/issue_service.go b/internal/boundary/graphql/issue_service.go index 97d0c461..27059e43 100644 --- a/internal/boundary/graphql/issue_service.go +++ b/internal/boundary/graphql/issue_service.go @@ -8,9 +8,13 @@ import ( "github.com/usetero/cli/internal/log" ) +// maxIssues bounds the issue-list read for the chat agent. +const maxIssues = 50 + // Issues provides access to the account's active issues. type Issues interface { GetSummary(ctx context.Context) (domain.IssueSummary, error) + List(ctx context.Context) ([]domain.Issue, error) } // IssueService reads issue aggregates from the control plane. @@ -58,3 +62,32 @@ func (s *IssueService) GetSummary(ctx context.Context) (domain.IssueSummary, err log.Int("high", int(summary.HighCount))) return summary, nil } + +// List fetches the active issues with detail, highest priority first. +func (s *IssueService) List(ctx context.Context) ([]domain.Issue, error) { + s.scope.Debug("fetching issues from API") + resp, err := s.client.ListIssues(ctx, maxIssues) + if err != nil { + s.scope.Error("failed to fetch issues", "error", err) + return nil, err + } + + issues := make([]domain.Issue, 0, len(resp.Issues.Edges)) + for _, edge := range resp.Issues.Edges { + node := edge.Node + issue := domain.Issue{ + ID: node.Id, + DisplayID: node.DisplayID, + Title: node.Title, + Priority: domain.IssuePriority(node.Priority), + CostPerHour: node.Cost.TotalUsdPerHour, + } + if node.Service != nil { + issue.ServiceName = node.Service.Name + } + issues = append(issues, issue) + } + + s.scope.Debug("fetched issues", log.Int("count", len(issues))) + return issues, nil +} diff --git a/internal/domain/issue.go b/internal/domain/issue.go index 484a4108..16085071 100644 --- a/internal/domain/issue.go +++ b/internal/domain/issue.go @@ -11,6 +11,16 @@ const ( func (p IssuePriority) String() string { return string(p) } +// Issue is a single active issue with the detail the chat agent surfaces. +type Issue struct { + ID string + DisplayID string + Title string + Priority IssuePriority + ServiceName string + CostPerHour *float64 +} + // IssueSummary is the server-computed aggregate state for active issues // (issues whose closedAt and ignoredAt are both nil). The control plane // computes the count and per-priority breakdown; the CLI never aggregates From 66f24d6bddf63b19b1dc43a2be4d478af299da34 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 12:01:45 -0700 Subject: [PATCH 11/20] refactor: delete PowerSync engine and local SQLite substrate The CLI is now a thin GraphQL client with no local database or sync engine. Remove: - internal/powersync (sync engine, CGo extension + binaries, db/crud) - internal/boundary/powersync (sync client) - internal/sqlite (local store, storage service, generated surfaces) - the onboarding sync gate + sync status indicator - powersync/sqlite wiring in app, statusbar, cmd, and the internal powersync capture/sanitize debug commands Onboarding now completes at workspace selection (no 'waiting for first sync' step) and drops straight into chat. The app runtime opens a session context and scopes the GraphQL services to the account instead of opening a database and starting a syncer. Workspace selection becomes the terminal bootstrap transition; the workspace concept itself is removed in a follow-up. Tidy go.mod and the Taskfile's dead generate/replay/capture tasks. --- Taskfile.yml | 62 +-- internal/app/app.go | 19 +- internal/app/app_onboarding_controls.go | 7 +- internal/app/events/sync.go | 8 - internal/app/onboarding/gate_definitions.go | 3 - .../app/onboarding/gate_definitions_test.go | 1 - .../app/onboarding/gate_requirements_test.go | 3 +- internal/app/onboarding/onboarding.go | 7 - internal/app/onboarding/sync/model.go | 70 --- internal/app/onboarding/sync/step_contract.go | 9 - internal/app/onboarding/sync/sync_test.go | 57 --- internal/app/onboarding/sync/update.go | 29 -- internal/app/onboarding/sync/view.go | 52 --- internal/app/onboarding/transition_cmds.go | 4 +- internal/app/onboarding/transitions_test.go | 47 +- internal/app/runtime_database.go | 27 -- internal/app/runtime_session_test.go | 147 ++---- internal/app/runtime_shutdown.go | 11 - internal/app/runtime_sync.go | 44 +- internal/app/statusbar/dispatch_test.go | 7 +- internal/app/statusbar/statusbar.go | 20 +- internal/app/statusbar/statusbar_data.go | 37 +- internal/app/statusbar/statusbar_test.go | 64 +-- internal/app/statusbar/statusbar_view.go | 10 +- .../app/statusbar/syncstatus/syncstatus.go | 301 ------------ .../statusbar/syncstatus/syncstatus_test.go | 38 -- internal/app/statusbar/tabpoll/tabpoll.go | 5 +- internal/app/view_overlay_test.go | 5 - internal/architecture/dependencies_test.go | 4 - .../boundary/powersync/apitest/mock_client.go | 69 --- internal/boundary/powersync/client.go | 251 ---------- internal/boundary/powersync/indexes.go | 82 ---- internal/boundary/powersync/schema.go | 261 ----------- internal/cmd/debug.go | 8 - internal/cmd/internal.go | 1 - internal/cmd/internal_powersync.go | 146 ------ internal/cmd/internal_powersync_sanitize.go | 230 --------- .../cmd/internal_powersync_sanitize_test.go | 103 ---- internal/cmd/internal_powersync_test.go | 34 -- internal/cmd/reset.go | 26 +- internal/cmd/root.go | 13 +- internal/core/bootstrap/event_adapter.go | 2 - internal/core/bootstrap/event_adapter_test.go | 2 +- internal/core/bootstrap/events.go | 14 +- internal/core/bootstrap/events_test.go | 16 +- internal/core/bootstrap/gate_requirements.go | 2 - .../core/bootstrap/gate_requirements_test.go | 2 +- internal/core/bootstrap/gates.go | 1 - internal/core/bootstrap/messages.go | 3 - internal/core/bootstrap/requirements_test.go | 4 +- internal/core/bootstrap/state.go | 6 +- internal/core/bootstrap/transitions_test.go | 5 +- .../uploader_flow_integration_test.go | 89 ---- .../live/powersync/syncer_live_test.go | 152 ------ .../live/upload/uploader_live_test.go | 169 ------- internal/powersync/db/batch.go | 45 -- internal/powersync/db/batch_test.go | 157 ------- internal/powersync/db/crud.go | 221 --------- internal/powersync/db/crud_test.go | 311 ------------- internal/powersync/db/dbtest/db.go | 39 -- internal/powersync/doc.go | 26 -- internal/powersync/extension/controller.go | 175 ------- .../extension/controller_correctness_test.go | 163 ------- .../powersync/extension/controller_test.go | 299 ------------ internal/powersync/extension/extension.go | 112 ----- .../extensions/libpowersync_aarch64.linux.so | Bin 460688 -> 0 bytes .../libpowersync_aarch64.macos.dylib | Bin 367024 -> 0 bytes .../extensions/libpowersync_x64.linux.so | Bin 481184 -> 0 bytes .../extensions/libpowersync_x64.macos.dylib | Bin 399556 -> 0 bytes internal/powersync/extension/generate/main.go | 62 --- internal/powersync/extension/protocol.go | 179 ------- internal/powersync/extension/protocol_test.go | 184 -------- internal/powersync/extension/schema.json | 1 - .../testdata/checkpoint_lines.ndjson | 3 - .../extension/testdata/dev-sanitized.ndjson | 2 - internal/powersync/powersynctest/db.go | 28 -- .../powersync/powersynctest/mock_syncer.go | 58 --- .../powersynctest/mock_token_refresher.go | 53 --- internal/powersync/state.go | 98 ---- internal/powersync/state_test.go | 59 --- internal/powersync/stream_capture.go | 126 ----- internal/powersync/stream_capture_test.go | 58 --- internal/powersync/syncer.go | 219 --------- internal/powersync/syncer_controlplane.go | 69 --- .../powersync/syncer_controlplane_test.go | 243 ---------- internal/powersync/syncer_instructions.go | 57 --- .../powersync/syncer_instructions_test.go | 155 ------- internal/powersync/syncer_lifecycle_test.go | 309 ------------ internal/powersync/syncer_retry_test.go | 327 ------------- internal/powersync/syncer_run.go | 111 ----- internal/powersync/syncer_shutdown_test.go | 86 ---- internal/powersync/syncer_stream.go | 129 ----- internal/powersync/syncer_stream_test.go | 169 ------- internal/sqlite/compliance_policies.go | 82 ---- internal/sqlite/conversations.go | 76 --- internal/sqlite/database.go | 439 ------------------ internal/sqlite/datadog_account_statuses.go | 84 ---- internal/sqlite/db_test.go | 237 ---------- internal/sqlite/doc.go | 16 - internal/sqlite/error.go | 53 --- .../sqlite/gen/compliance_policies.sql.go | 117 ----- internal/sqlite/gen/conversations.sql.go | 135 ------ .../gen/datadog_account_statuses.sql.go | 157 ------- internal/sqlite/gen/db.go | 31 -- internal/sqlite/gen/log_event_policies.sql.go | 55 --- .../log_event_policy_category_statuses.sql.go | 103 ---- .../gen/log_event_policy_statuses.sql.go | 142 ------ internal/sqlite/gen/log_event_statuses.sql.go | 82 ---- internal/sqlite/gen/log_events.sql.go | 21 - internal/sqlite/gen/messages.sql.go | 219 --------- internal/sqlite/gen/models.go | 325 ------------- internal/sqlite/gen/policy_cards.sql.go | 132 ------ internal/sqlite/gen/querier.go | 60 --- internal/sqlite/gen/service_statuses.sql.go | 322 ------------- internal/sqlite/gen/services.sql.go | 124 ----- internal/sqlite/generate/main.go | 249 ---------- internal/sqlite/log_event_policies.go | 56 --- .../log_event_policy_category_statuses.go | 83 ---- internal/sqlite/log_event_policy_statuses.go | 105 ----- internal/sqlite/log_event_statuses.go | 45 -- internal/sqlite/log_events.go | 26 -- internal/sqlite/messages.go | 213 --------- internal/sqlite/queries/AGENTS.md | 29 -- internal/sqlite/queries/CLAUDE.md | 31 -- .../sqlite/queries/compliance_policies.sql | 34 -- internal/sqlite/queries/conversations.sql | 23 - .../queries/datadog_account_statuses.sql | 58 --- .../sqlite/queries/log_event_policies.sql | 12 - .../log_event_policy_category_statuses.sql | 26 -- .../queries/log_event_policy_statuses.sql | 34 -- .../sqlite/queries/log_event_statuses.sql | 18 - internal/sqlite/queries/log_events.sql | 2 - internal/sqlite/queries/messages.sql | 37 -- internal/sqlite/queries/policy_cards.sql | 40 -- internal/sqlite/queries/service_statuses.sql | 103 ---- internal/sqlite/queries/services.sql | 14 - internal/sqlite/schema.sql | 322 ------------- internal/sqlite/service_statuses.go | 149 ------ internal/sqlite/services.go | 57 --- internal/sqlite/sqlitetest/db.go | 25 - internal/sqlite/sqlitetest/mock_db.go | 170 ------- internal/sqlite/storage.go | 74 --- internal/sqlite/storage_test.go | 160 ------- internal/sqlite/tables.go | 13 - internal/sqlite/timeout.go | 68 --- 145 files changed, 110 insertions(+), 12300 deletions(-) delete mode 100644 internal/app/events/sync.go delete mode 100644 internal/app/onboarding/sync/model.go delete mode 100644 internal/app/onboarding/sync/step_contract.go delete mode 100644 internal/app/onboarding/sync/sync_test.go delete mode 100644 internal/app/onboarding/sync/update.go delete mode 100644 internal/app/onboarding/sync/view.go delete mode 100644 internal/app/runtime_database.go delete mode 100644 internal/app/statusbar/syncstatus/syncstatus.go delete mode 100644 internal/app/statusbar/syncstatus/syncstatus_test.go delete mode 100644 internal/boundary/powersync/apitest/mock_client.go delete mode 100644 internal/boundary/powersync/client.go delete mode 100644 internal/boundary/powersync/indexes.go delete mode 100644 internal/boundary/powersync/schema.go delete mode 100644 internal/cmd/internal_powersync.go delete mode 100644 internal/cmd/internal_powersync_sanitize.go delete mode 100644 internal/cmd/internal_powersync_sanitize_test.go delete mode 100644 internal/cmd/internal_powersync_test.go delete mode 100644 internal/integration/hermetic/uploader_flow_integration_test.go delete mode 100644 internal/integration/live/powersync/syncer_live_test.go delete mode 100644 internal/integration/live/upload/uploader_live_test.go delete mode 100644 internal/powersync/db/batch.go delete mode 100644 internal/powersync/db/batch_test.go delete mode 100644 internal/powersync/db/crud.go delete mode 100644 internal/powersync/db/crud_test.go delete mode 100644 internal/powersync/db/dbtest/db.go delete mode 100644 internal/powersync/doc.go delete mode 100644 internal/powersync/extension/controller.go delete mode 100644 internal/powersync/extension/controller_correctness_test.go delete mode 100644 internal/powersync/extension/controller_test.go delete mode 100644 internal/powersync/extension/extension.go delete mode 100644 internal/powersync/extension/extensions/libpowersync_aarch64.linux.so delete mode 100644 internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib delete mode 100644 internal/powersync/extension/extensions/libpowersync_x64.linux.so delete mode 100644 internal/powersync/extension/extensions/libpowersync_x64.macos.dylib delete mode 100644 internal/powersync/extension/generate/main.go delete mode 100644 internal/powersync/extension/protocol.go delete mode 100644 internal/powersync/extension/protocol_test.go delete mode 100644 internal/powersync/extension/schema.json delete mode 100644 internal/powersync/extension/testdata/checkpoint_lines.ndjson delete mode 100644 internal/powersync/extension/testdata/dev-sanitized.ndjson delete mode 100644 internal/powersync/powersynctest/db.go delete mode 100644 internal/powersync/powersynctest/mock_syncer.go delete mode 100644 internal/powersync/powersynctest/mock_token_refresher.go delete mode 100644 internal/powersync/state.go delete mode 100644 internal/powersync/state_test.go delete mode 100644 internal/powersync/stream_capture.go delete mode 100644 internal/powersync/stream_capture_test.go delete mode 100644 internal/powersync/syncer.go delete mode 100644 internal/powersync/syncer_controlplane.go delete mode 100644 internal/powersync/syncer_controlplane_test.go delete mode 100644 internal/powersync/syncer_instructions.go delete mode 100644 internal/powersync/syncer_instructions_test.go delete mode 100644 internal/powersync/syncer_lifecycle_test.go delete mode 100644 internal/powersync/syncer_retry_test.go delete mode 100644 internal/powersync/syncer_run.go delete mode 100644 internal/powersync/syncer_shutdown_test.go delete mode 100644 internal/powersync/syncer_stream.go delete mode 100644 internal/powersync/syncer_stream_test.go delete mode 100644 internal/sqlite/compliance_policies.go delete mode 100644 internal/sqlite/conversations.go delete mode 100644 internal/sqlite/database.go delete mode 100644 internal/sqlite/datadog_account_statuses.go delete mode 100644 internal/sqlite/db_test.go delete mode 100644 internal/sqlite/doc.go delete mode 100644 internal/sqlite/error.go delete mode 100644 internal/sqlite/gen/compliance_policies.sql.go delete mode 100644 internal/sqlite/gen/conversations.sql.go delete mode 100644 internal/sqlite/gen/datadog_account_statuses.sql.go delete mode 100644 internal/sqlite/gen/db.go delete mode 100644 internal/sqlite/gen/log_event_policies.sql.go delete mode 100644 internal/sqlite/gen/log_event_policy_category_statuses.sql.go delete mode 100644 internal/sqlite/gen/log_event_policy_statuses.sql.go delete mode 100644 internal/sqlite/gen/log_event_statuses.sql.go delete mode 100644 internal/sqlite/gen/log_events.sql.go delete mode 100644 internal/sqlite/gen/messages.sql.go delete mode 100644 internal/sqlite/gen/models.go delete mode 100644 internal/sqlite/gen/policy_cards.sql.go delete mode 100644 internal/sqlite/gen/querier.go delete mode 100644 internal/sqlite/gen/service_statuses.sql.go delete mode 100644 internal/sqlite/gen/services.sql.go delete mode 100644 internal/sqlite/generate/main.go delete mode 100644 internal/sqlite/log_event_policies.go delete mode 100644 internal/sqlite/log_event_policy_category_statuses.go delete mode 100644 internal/sqlite/log_event_policy_statuses.go delete mode 100644 internal/sqlite/log_event_statuses.go delete mode 100644 internal/sqlite/log_events.go delete mode 100644 internal/sqlite/messages.go delete mode 100644 internal/sqlite/queries/AGENTS.md delete mode 100644 internal/sqlite/queries/CLAUDE.md delete mode 100644 internal/sqlite/queries/compliance_policies.sql delete mode 100644 internal/sqlite/queries/conversations.sql delete mode 100644 internal/sqlite/queries/datadog_account_statuses.sql delete mode 100644 internal/sqlite/queries/log_event_policies.sql delete mode 100644 internal/sqlite/queries/log_event_policy_category_statuses.sql delete mode 100644 internal/sqlite/queries/log_event_policy_statuses.sql delete mode 100644 internal/sqlite/queries/log_event_statuses.sql delete mode 100644 internal/sqlite/queries/log_events.sql delete mode 100644 internal/sqlite/queries/messages.sql delete mode 100644 internal/sqlite/queries/policy_cards.sql delete mode 100644 internal/sqlite/queries/service_statuses.sql delete mode 100644 internal/sqlite/queries/services.sql delete mode 100644 internal/sqlite/schema.sql delete mode 100644 internal/sqlite/service_statuses.go delete mode 100644 internal/sqlite/services.go delete mode 100644 internal/sqlite/sqlitetest/db.go delete mode 100644 internal/sqlite/sqlitetest/mock_db.go delete mode 100644 internal/sqlite/storage.go delete mode 100644 internal/sqlite/storage_test.go delete mode 100644 internal/sqlite/tables.go delete mode 100644 internal/sqlite/timeout.go diff --git a/Taskfile.yml b/Taskfile.yml index 41f179d3..7c144181 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,10 +39,9 @@ tasks: # =========================================================================== generate: - desc: Generate all code (GraphQL client + SQLite schema) + desc: Generate all code (GraphQL client) cmds: - task: generate:client - - task: generate:schema generate:client: desc: Generate GraphQL client (requires control plane at localhost:18081) @@ -51,13 +50,6 @@ tasks: - npx get-graphql-schema http://localhost:18081/graphql > schema.graphql - go run github.com/Khan/genqlient - generate:schema: - desc: Generate PowerSync and SQLite schema (uses local control plane) - cmds: - - go generate ./internal/powersync - - go generate ./internal/sqlite - - sed '/^-- Auto-generated/d; /^-- Run .task generate/d' ../control-plane/internal/powersync/schema.sql > internal/chat/tools/query_schema.sql - # =========================================================================== # Build # =========================================================================== @@ -183,24 +175,11 @@ tasks: cmds: - './scripts/with-env.sh gotestsum --format short-verbose -- -v -tags=correctness -run "^TestCorrectness_" ./...' - test:correctness:powersync-replay: - desc: Run deterministic PowerSync fixture replay correctness test - cmds: - - | - if [ -n "{{.FIXTURE}}" ]; then - TERO_POWERSYNC_FIXTURE_PATH="{{.FIXTURE}}" ./scripts/with-env.sh gotestsum --format short-verbose -- \ - -v -tags=correctness -run "^TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic$" ./internal/powersync/extension - else - ./scripts/with-env.sh gotestsum --format short-verbose -- \ - -v -tags=correctness -run "^TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic$" ./internal/powersync/extension - fi - test:all: - desc: Run CI test suite (unit + hermetic integration + correctness replay) + desc: Run CI test suite (unit + hermetic integration) cmds: - task: test - task: test:integration - - task: test:correctness:powersync-replay test:all:full: desc: Run full test suite including live integration checks @@ -243,34 +222,6 @@ tasks: cmds: - "./scripts/with-env.sh go run ./cmd/tero auth switch {{.CLI_ARGS}}" - internal:powersync:capture: - desc: Capture raw PowerSync stream to NDJSON (TERO_ENV=dev|prd) - vars: - OUTPUT: '{{.OUTPUT | default "fixtures/powersync/capture.ndjson"}}' - DURATION: '{{.DURATION | default "90s"}}' - MAX_BYTES: '{{.MAX_BYTES | default "26214400"}}' - cmds: - - | - CMD="./scripts/with-env.sh go run ./cmd/tero internal powersync capture --output {{.OUTPUT}} --duration {{.DURATION}} --max-bytes {{.MAX_BYTES}}" - if [ -n "{{.ACCOUNT_ID}}" ]; then - CMD="$CMD --account-id {{.ACCOUNT_ID}}" - fi - eval "$CMD" - - internal:powersync:sanitize-fixture: - desc: Sanitize raw PowerSync fixture to commit-safe NDJSON - cmds: - - | - if [ -z "{{.INPUT}}" ] || [ -z "{{.OUTPUT}}" ]; then - echo "INPUT and OUTPUT are required" - exit 1 - fi - CMD="./scripts/with-env.sh go run ./cmd/tero internal powersync sanitize-fixture --input {{.INPUT}} --output {{.OUTPUT}}" - if [ -n "{{.MAX_LINES}}" ]; then - CMD="$CMD --max-lines {{.MAX_LINES}}" - fi - eval "$CMD" - # =========================================================================== # Admin (internal tooling, not shipped) # =========================================================================== @@ -285,15 +236,6 @@ tasks: cmds: - "./scripts/with-env.sh go run ./cmd/admin leave-org {{.CLI_ARGS}}" - # =========================================================================== - # Dependencies - # =========================================================================== - - update:powersync: - desc: Update PowerSync SQLite extension to latest release - cmds: - - ./scripts/update-powersync.sh {{.CLI_ARGS}} - # =========================================================================== # Cleanup # =========================================================================== diff --git a/internal/app/app.go b/internal/app/app.go index 28e60def..a6f4779e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,9 +23,7 @@ import ( "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/update" ) @@ -58,9 +56,7 @@ type Model struct { // Dependencies cfg *config.CLIConfig - storage sqlite.Storage authService auth.Auth - syncer powersync.Syncer services graphql.ServiceSet userPrefs preferences.UserPreferences orgPrefs preferences.OrgPreferences @@ -68,7 +64,6 @@ type Model struct { // Runtime (created after account selection / onboarding) sessionCancel context.CancelFunc sessionCtx context.Context - db sqlite.DB chatClient chatboundary.Client runtimeDeps usecase.RuntimeDeps toolRegistry *chattools.Registry @@ -104,8 +99,6 @@ func New( authService auth.Auth, userPrefs preferences.UserPreferences, orgPrefs preferences.OrgPreferences, - storage sqlite.Storage, - syncer powersync.Syncer, scope log.Scope, ) *Model { if ctx == nil { @@ -123,12 +116,6 @@ func New( if orgPrefs == nil { panic("orgPrefs is nil") } - if storage == nil { - panic("storage is nil") - } - if syncer == nil { - panic("syncer is nil") - } scope = scope.Child("app") @@ -138,16 +125,14 @@ func New( scope: scope, version: version, cfg: cfg, - storage: storage, authService: authService, - syncer: syncer, services: services, userPrefs: userPrefs, orgPrefs: orgPrefs, - statusBar: statusbar.New(theme, scope, syncer, cfg.APIEndpoint, cfg.Env), + statusBar: statusbar.New(theme, scope, cfg.APIEndpoint, cfg.Env), toast: toast.New(theme), keyBar: keybar.New(theme, scope), - onboarding: onboarding.New(ctx, theme, services, userPrefs, orgPrefs, authService, syncer, scope), + onboarding: onboarding.New(ctx, theme, services, userPrefs, orgPrefs, authService, scope), state: stateOnboarding, } } diff --git a/internal/app/app_onboarding_controls.go b/internal/app/app_onboarding_controls.go index 15ffabb1..bdab5868 100644 --- a/internal/app/app_onboarding_controls.go +++ b/internal/app/app_onboarding_controls.go @@ -10,7 +10,6 @@ import ( "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" ) // activateOrg sets the active org, reloads org prefs/storage for the new @@ -25,7 +24,6 @@ func (m *Model) activateOrg(orgID domain.OrganizationID, msg tea.Msg) tea.Cmd { m.scope.Error("failed to reload config for org", "error", err) } else { m.orgPrefs = preferences.NewOrgService(cfg, m.scope) - m.storage = sqlite.NewStorageService(cfg) if m.onboarding != nil { m.onboarding.SetOrgPreferences(m.orgPrefs) } @@ -76,17 +74,16 @@ func (m *Model) switchAccount() tea.Cmd { func (m *Model) restartOnboarding() tea.Cmd { m.shutdown() - m.db = nil m.chatClient = nil m.runtimeDeps = usecase.RuntimeDeps{} m.toolRegistry = nil m.chat = nil m.services = m.services.WithAccountID("") // clear stale account scope - m.statusBar = statusbar.New(m.theme, m.scope, m.syncer, m.cfg.APIEndpoint, m.cfg.Env) + m.statusBar = statusbar.New(m.theme, m.scope, m.cfg.APIEndpoint, m.cfg.Env) m.windowTitle = "" - m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.userPrefs, m.orgPrefs, m.authService, m.syncer, m.scope) + m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.userPrefs, m.orgPrefs, m.authService, m.scope) m.state = stateOnboarding m.updateLayout() diff --git a/internal/app/events/sync.go b/internal/app/events/sync.go deleted file mode 100644 index 788064a3..00000000 --- a/internal/app/events/sync.go +++ /dev/null @@ -1,8 +0,0 @@ -package events - -import "github.com/usetero/cli/internal/powersync" - -// SyncStateChanged is emitted when sync state changes. -type SyncStateChanged struct { - State powersync.State -} diff --git a/internal/app/onboarding/gate_definitions.go b/internal/app/onboarding/gate_definitions.go index fbb04274..eac67a12 100644 --- a/internal/app/onboarding/gate_definitions.go +++ b/internal/app/onboarding/gate_definitions.go @@ -10,7 +10,6 @@ import ( "github.com/usetero/cli/internal/app/onboarding/preflight" "github.com/usetero/cli/internal/app/onboarding/role" "github.com/usetero/cli/internal/app/onboarding/runtimeinit" - "github.com/usetero/cli/internal/app/onboarding/sync" "github.com/usetero/cli/internal/app/onboarding/workspaces" "github.com/usetero/cli/internal/core/bootstrap" ) @@ -55,8 +54,6 @@ func (m *Model) newStepForGate(gate Gate) (Step, error) { return datadog.NewDiscovery(m.ctx, m.theme, m.state.DDAccount, m.services, m.scope), nil case bootstrap.GateWorkspaceSelect: return workspaces.NewSelect(m.ctx, m.theme, *m.state.Account, m.services, m.orgPrefs, m.scope), nil - case bootstrap.GateSync: - return sync.New(m.theme, m.syncer, m.scope), nil default: return nil, fmt.Errorf("unsupported gate %q", gate) } diff --git a/internal/app/onboarding/gate_definitions_test.go b/internal/app/onboarding/gate_definitions_test.go index 45a86984..e989c982 100644 --- a/internal/app/onboarding/gate_definitions_test.go +++ b/internal/app/onboarding/gate_definitions_test.go @@ -32,7 +32,6 @@ func TestNewStepForGateCoverage(t *testing.T) { bootstrap.GateDatadogAppKey, bootstrap.GateDatadogDiscovery, bootstrap.GateWorkspaceSelect, - bootstrap.GateSync, } for _, gate := range expected { diff --git a/internal/app/onboarding/gate_requirements_test.go b/internal/app/onboarding/gate_requirements_test.go index 18a1e807..38e34a6d 100644 --- a/internal/app/onboarding/gate_requirements_test.go +++ b/internal/app/onboarding/gate_requirements_test.go @@ -21,8 +21,7 @@ func TestRewindGateFor(t *testing.T) { {name: "datadog api rewinds to region when site missing", target: bootstrap.GateDatadogAPIKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogRegion}, {name: "datadog app rewinds to api key when api key missing", target: bootstrap.GateDatadogAppKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1"), DDSite: "US1"}, want: bootstrap.GateDatadogAPIKey}, {name: "discovery rewinds to datadog check without dd account", target: bootstrap.GateDatadogDiscovery, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogCheck}, - {name: "sync rewinds to workspace", target: bootstrap.GateSync, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateWorkspaceSelect}, - {name: "sync stays when requirements met", target: bootstrap.GateSync, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1"), Workspace: ptrWorkspace("ws-1")}, want: bootstrap.GateSync}, + {name: "workspace select rewinds to account without account", target: bootstrap.GateWorkspaceSelect, state: bootstrap.State{Org: ptrOrg("org-1")}, want: bootstrap.GateAccountSelect}, } for _, tc := range tests { diff --git a/internal/app/onboarding/onboarding.go b/internal/app/onboarding/onboarding.go index e58f8e42..50dcb746 100644 --- a/internal/app/onboarding/onboarding.go +++ b/internal/app/onboarding/onboarding.go @@ -11,7 +11,6 @@ import ( graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" "github.com/usetero/cli/internal/styles" ) @@ -25,7 +24,6 @@ type Model struct { userPrefs preferences.UserPreferences orgPrefs preferences.OrgPreferences auth iauth.Auth - syncer powersync.Syncer scope log.Scope // Accumulated state from step completions @@ -50,7 +48,6 @@ func New( userPrefs preferences.UserPreferences, orgPrefs preferences.OrgPreferences, authService iauth.Auth, - syncer powersync.Syncer, scope log.Scope, ) *Model { if ctx == nil { @@ -65,9 +62,6 @@ func New( if authService == nil { panic("authService is nil") } - if syncer == nil { - panic("syncer is nil") - } scope = scope.Child("onboarding") @@ -78,7 +72,6 @@ func New( userPrefs: userPrefs, orgPrefs: orgPrefs, auth: authService, - syncer: syncer, scope: scope, } } diff --git a/internal/app/onboarding/sync/model.go b/internal/app/onboarding/sync/model.go deleted file mode 100644 index 0006c68e..00000000 --- a/internal/app/onboarding/sync/model.go +++ /dev/null @@ -1,70 +0,0 @@ -// Package sync provides the sync step for onboarding. -package sync - -import ( - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/progress" -) - -// Model waits for sync to complete. -type Model struct { - theme styles.Theme - syncer powersync.Syncer - scope log.Scope - spinner spinner.Model - progress *progress.Model - width int - height int -} - -// New creates a new sync step. -func New(theme styles.Theme, syncer powersync.Syncer, scope log.Scope) *Model { - if syncer == nil { - panic("syncer is nil") - } - - scope.Debug("initialized") - - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(theme.Accent).Background(theme.Bg) - - return &Model{ - theme: theme, - syncer: syncer, - scope: scope, - spinner: sp, - progress: progress.New(theme, 50), - } -} - -// Init starts the spinner and checks if already ready. -func (m *Model) Init() tea.Cmd { - if m.syncer.IsReady() { - m.scope.Info("sync already complete") - return func() tea.Msg { return bootstrap.SyncComplete{} } - } - m.scope.Debug("waiting for sync") - return m.spinner.Tick -} - -// SetSize updates dimensions. -func (m *Model) SetSize(width, height int) { - m.width = width - m.height = height - m.progress.SetWidth(min(width, 50)) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - // Sync is automatic, no user action needed. - return nil -} diff --git a/internal/app/onboarding/sync/step_contract.go b/internal/app/onboarding/sync/step_contract.go deleted file mode 100644 index a22c637f..00000000 --- a/internal/app/onboarding/sync/step_contract.go +++ /dev/null @@ -1,9 +0,0 @@ -package sync - -import onbstatus "github.com/usetero/cli/internal/app/onboarding/status" - -func (m *Model) Hidden() bool { return false } - -func (m *Model) Status() onbstatus.StepStatus { - return onbstatus.StepStatus{Title: "Getting ready", Details: "Syncing your account data..."} -} diff --git a/internal/app/onboarding/sync/sync_test.go b/internal/app/onboarding/sync/sync_test.go deleted file mode 100644 index 7ffd6f1d..00000000 --- a/internal/app/onboarding/sync/sync_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package sync - -import ( - "testing" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/styles" -) - -func TestInitEmitsSyncCompleteWhenReady(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - syncer.IsReadyFunc = func() bool { return true } - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Init() - if cmd == nil { - t.Fatal("expected non-nil init cmd") - } - msg := cmd() - if _, ok := msg.(bootstrap.SyncComplete); !ok { - t.Fatalf("expected SyncComplete message, got %T", msg) - } -} - -func TestUpdateEmitsSyncCompleteOnReadyState(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Update(appevents.SyncStateChanged{State: powersync.NewReady()}) - if cmd == nil { - t.Fatal("expected non-nil command on ready state") - } - msg := cmd() - if _, ok := msg.(bootstrap.SyncComplete); !ok { - t.Fatalf("expected SyncComplete message, got %T", msg) - } -} - -func TestUpdateIgnoresNonReadySyncState(t *testing.T) { - t.Parallel() - - syncer := powersynctest.NewMockSyncer() - m := New(styles.NewTheme(true), syncer, logtest.NewScope(t)) - - cmd := m.Update(appevents.SyncStateChanged{State: powersync.NewConnecting()}) - if cmd != nil { - t.Fatal("expected nil command for non-ready sync state") - } -} diff --git a/internal/app/onboarding/sync/update.go b/internal/app/onboarding/sync/update.go deleted file mode 100644 index 9e73ac95..00000000 --- a/internal/app/onboarding/sync/update.go +++ /dev/null @@ -1,29 +0,0 @@ -package sync - -import ( - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/powersync" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case appevents.SyncStateChanged: - if _, ok := msg.State.(*powersync.Ready); ok { - m.scope.Info("sync completed") - return func() tea.Msg { return bootstrap.SyncComplete{} } - } - return nil - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return cmd - } - - return m.progress.Update(msg) -} diff --git a/internal/app/onboarding/sync/view.go b/internal/app/onboarding/sync/view.go deleted file mode 100644 index bc95db90..00000000 --- a/internal/app/onboarding/sync/view.go +++ /dev/null @@ -1,52 +0,0 @@ -package sync - -import ( - "fmt" - - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/powersync" -) - -// View renders the sync UI. -func (m *Model) View() string { - s := m.theme.Styles - title := s.Title.Render("Getting ready") - - switch state := m.syncer.State().(type) { - case *powersync.Ready: - return lipgloss.JoinVertical(lipgloss.Left, title, "", s.Success.Render("Ready!")) - - case *powersync.Error: - return lipgloss.JoinVertical(lipgloss.Left, title, "", s.Error.Render(fmt.Sprintf("Error: %v", state.Err))) - - case *powersync.Connecting: - statusLine := m.spinner.View() + " " + s.Body.Render("Connecting...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - - case *powersync.Syncing: - msg := "Syncing your account data..." - if state.Progress != nil && state.Progress.Total > 0 { - msg = fmt.Sprintf("Syncing your account data... (%s)", state.Progress) - } - statusLine := m.spinner.View() + " " + s.Body.Render(msg) - parts := []string{title, "", statusLine} - - if state.Progress != nil && state.Progress.Total > 0 { - pct := float64(state.Progress.Downloaded) / float64(state.Progress.Total) * 100 - progressBar := m.progress.ViewAs(pct) - countText := fmt.Sprintf("%d / %d rows", state.Progress.Downloaded, state.Progress.Total) - parts = append(parts, "", progressBar, "", s.Help.Render(countText)) - } - - return lipgloss.JoinVertical(lipgloss.Left, parts...) - - case *powersync.Reconnecting: - statusLine := m.spinner.View() + " " + s.Body.Render("Reconnecting...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - - default: - statusLine := m.spinner.View() + " " + s.Body.Render("Initializing sync engine...") - return lipgloss.JoinVertical(lipgloss.Left, title, "", statusLine) - } -} diff --git a/internal/app/onboarding/transition_cmds.go b/internal/app/onboarding/transition_cmds.go index 4ee3d58c..b09ab645 100644 --- a/internal/app/onboarding/transition_cmds.go +++ b/internal/app/onboarding/transition_cmds.go @@ -34,8 +34,8 @@ func (m *Model) commandForTransition(event bootstrap.Event, transition bootstrap } } case bootstrap.TransitionNoop: - if event.Kind == bootstrap.EventSyncComplete { - m.scope.Error("sync completed without required onboarding state", + if event.Kind == bootstrap.EventWorkspaceSelected { + m.scope.Error("workspace selected without required onboarding state", slog.Bool("has_user", m.state.User != nil), slog.Bool("has_org", m.state.Org != nil), slog.Bool("has_account", m.state.Account != nil), diff --git a/internal/app/onboarding/transitions_test.go b/internal/app/onboarding/transitions_test.go index 08709ade..88659f95 100644 --- a/internal/app/onboarding/transitions_test.go +++ b/internal/app/onboarding/transitions_test.go @@ -11,7 +11,6 @@ import ( "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/preferences/preferencestest" "github.com/usetero/cli/internal/styles" ) @@ -126,23 +125,27 @@ func TestHandleTransitionDatadogBranchRouting(t *testing.T) { } } -func TestHandleTransitionWorkspaceSelectedSetsState(t *testing.T) { +func TestHandleTransitionWorkspaceSelectedCompletesOnboarding(t *testing.T) { t.Parallel() m := newTestModel(t) + m.state.User = ptrUser("user-1") m.state.Org = ptrOrg("org-1") m.state.Account = ptrAccount("acc-1") workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - if cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: workspace}); cmd == nil { + // Workspace selection is the terminal step: it completes onboarding rather + // than advancing to a sync gate. + cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: workspace}) + if cmd == nil { t.Fatal("expected transition command") } - if m.gate != bootstrap.GateSync { - t.Fatalf("gate = %s, want %s", m.gate, bootstrap.GateSync) - } if m.state.Workspace == nil || m.state.Workspace.ID != workspace.ID { t.Fatalf("workspace state not set correctly: %+v", m.state.Workspace) } + if _, ok := cmd().(bootstrap.OnboardingComplete); !ok { + t.Fatalf("expected OnboardingComplete command") + } } func TestHandleTransitionDatadogState(t *testing.T) { @@ -226,38 +229,15 @@ func TestHandleTransitionOrgSelectedClearsServiceAccountScope(t *testing.T) { } } -func TestHandleTransitionSyncComplete(t *testing.T) { - t.Parallel() - - m := newTestModel(t) - m.state.User = ptrUser("user-1") - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - m.state.Workspace = ptrWorkspace("ws-1") - - cmd := m.handleTransition(bootstrap.SyncComplete{}) - if cmd == nil { - t.Fatal("expected completion command") - } - msg := cmd() - complete, ok := msg.(bootstrap.OnboardingComplete) - if !ok { - t.Fatalf("message type = %T, want bootstrap.OnboardingComplete", msg) - } - if complete.Org.ID != "org-1" || complete.Account.ID != "acc-1" || complete.Workspace.ID != "ws-1" || complete.User.ID != "user-1" { - t.Fatalf("unexpected completion payload: %+v", complete) - } -} - -func TestHandleTransitionSyncCompleteMissingStateNoops(t *testing.T) { +func TestHandleTransitionWorkspaceSelectedMissingStateNoops(t *testing.T) { t.Parallel() m := newTestModel(t) m.state.User = ptrUser("user-1") m.state.Org = ptrOrg("org-1") - // Missing account/workspace should not panic or emit completion payload. + // Missing account should not panic or emit a completion payload. - cmd := m.handleTransition(bootstrap.SyncComplete{}) + cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: domain.Workspace{ID: "ws-1"}}) if cmd != nil { t.Fatal("expected nil command when completion state is incomplete") } @@ -278,9 +258,8 @@ func newTestModelWithClient(t *testing.T) (*Model, *apitest.MockClient) { userPrefs := preferencestest.NewMockUserPreferences() orgPrefs := preferencestest.NewMockOrgPreferences() authSvc := &authtest.MockAuth{} - syncer := powersynctest.NewMockSyncer() - m := New(context.Background(), styles.NewTheme(true), services, userPrefs, orgPrefs, authSvc, syncer, scope) + m := New(context.Background(), styles.NewTheme(true), services, userPrefs, orgPrefs, authSvc, scope) m.SetSize(120, 40) return m, client } diff --git a/internal/app/runtime_database.go b/internal/app/runtime_database.go deleted file mode 100644 index 3aafa1c1..00000000 --- a/internal/app/runtime_database.go +++ /dev/null @@ -1,27 +0,0 @@ -package app - -import "github.com/usetero/cli/internal/sqlite" - -// openDatabase opens the SQLite database for the given account. -func (m *Model) openDatabase(accountID string) error { - if m.db != nil { - if err := m.db.Close(); err != nil { - m.scope.Warn("failed to close previous database", "error", err) - } - m.db = nil - } - - dbPath, err := m.storage.DatabasePath(accountID) - if err != nil { - return err - } - - db, err := sqlite.Open(m.ctx, dbPath) - if err != nil { - return err - } - - m.db = db - m.scope.Info("database opened", "path", dbPath) - return nil -} diff --git a/internal/app/runtime_session_test.go b/internal/app/runtime_session_test.go index 077c71f3..14647c05 100644 --- a/internal/app/runtime_session_test.go +++ b/internal/app/runtime_session_test.go @@ -2,82 +2,19 @@ package app import ( "context" - "path/filepath" "testing" - "github.com/usetero/cli/internal/auth/authtest" graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/boundary/graphql/apitest" - "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/sqlite/sqlitetest" ) -type testStorage struct { - dbPath string -} - -func (s testStorage) DatabasePath(accountID string) (string, error) { - return s.dbPath, nil -} - -func (s testStorage) ClearDatabase(accountID string) error { - return nil -} - -func (s testStorage) Clear() error { - return nil -} - -func TestOpenDatabase_ClosesPreviousDatabase(t *testing.T) { - ctx := context.Background() - tmp := t.TempDir() - storage := testStorage{dbPath: filepath.Join(tmp, "next.sqlite")} - prev := sqlitetest.NewMockDB() - - m := &Model{ - ctx: ctx, - scope: logtest.NewScope(t), - storage: storage, - db: prev, - } - - if err := m.openDatabase("acc_123"); err != nil { - t.Fatalf("openDatabase() error = %v", err) - } - - if !prev.Closed { - t.Fatalf("expected previous database to be closed") - } - if m.db == nil { - t.Fatalf("expected new database to be set") - } - if m.db == prev { - t.Fatalf("expected new database instance") - } - - if err := m.db.Close(); err != nil { - t.Fatalf("close new database: %v", err) - } -} - -func TestShutdown_CleansRuntimeResources(t *testing.T) { - db := sqlitetest.NewMockDB() - syncer := powersynctest.NewMockSyncer() - syncerStopped := false - syncer.StopFunc = func() { - syncerStopped = true - } +func TestShutdown_CancelsSession(t *testing.T) { cancelled := false - m := &Model{ scope: logtest.NewScope(t), - syncer: syncer, sessionCancel: func() { cancelled = true }, - db: db, } m.shutdown() @@ -85,44 +22,18 @@ func TestShutdown_CleansRuntimeResources(t *testing.T) { if !cancelled { t.Fatalf("expected session cancel to be called") } - if !syncerStopped { - t.Fatalf("expected syncer stop to be called") - } - if !db.Closed { - t.Fatalf("expected db to be closed") - } if m.sessionCancel != nil { t.Fatalf("expected sessionCancel to be cleared") } - if m.db != nil { - t.Fatalf("expected db to be nil") - } } -func TestStartSync_RequiresOpenDatabase(t *testing.T) { - m := &Model{} - - if err := m.startSync("acc_123"); err == nil { - t.Fatalf("expected error when db is not open") - } +func TestShutdown_NoSessionIsSafe(t *testing.T) { + m := &Model{scope: logtest.NewScope(t)} + m.shutdown() // must not panic with no active session } -func TestStartSync_InitializesSession(t *testing.T) { +func TestStartSession_ScopesServicesToAccount(t *testing.T) { scope := logtest.NewScope(t) - db := sqlitetest.OpenBareDB(t) - - syncer := powersynctest.NewMockSyncer() - startCalled := false - syncer.StartFunc = func(ctx context.Context, gotDB sqlite.DB, accountID string, onFirstSync func()) error { - startCalled = true - if gotDB != db { - t.Fatalf("syncer received unexpected db instance") - } - if accountID != "acc_123" { - t.Fatalf("syncer received accountID=%q", accountID) - } - return nil - } mockClient := apitest.NewMockClient() var scopedAccountID domain.AccountID @@ -130,35 +41,43 @@ func TestStartSync_InitializesSession(t *testing.T) { scopedAccountID = accountID } - authService := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }, - } - m := &Model{ - ctx: context.Background(), - scope: scope, - cfg: &config.CLIConfig{PowerSyncEndpoint: "https://powersync.example.com"}, - db: db, - syncer: syncer, - services: graphql.NewServiceSetFromClient(mockClient, scope), - authService: authService, + ctx: context.Background(), + scope: scope, + services: graphql.NewServiceSetFromClient(mockClient, scope), } - if err := m.startSync("acc_123"); err != nil { - t.Fatalf("startSync() error = %v", err) - } + m.startSession("acc_123") t.Cleanup(m.shutdown) - if !startCalled { - t.Fatalf("expected syncer start to be called") - } if m.sessionCancel == nil { t.Fatalf("expected session cancel to be initialized") } + if m.sessionCtx == nil { + t.Fatalf("expected session context to be initialized") + } if scopedAccountID != domain.AccountID("acc_123") { t.Fatalf("expected services account scope to be set, got %q", scopedAccountID) } } + +func TestStartSession_ReplacesPreviousSession(t *testing.T) { + scope := logtest.NewScope(t) + m := &Model{ + ctx: context.Background(), + scope: scope, + services: graphql.NewServiceSetFromClient(apitest.NewMockClient(), scope), + } + + m.startSession("acc_1") + firstCtx := m.sessionCtx + m.startSession("acc_2") + t.Cleanup(m.shutdown) + + if firstCtx.Err() == nil { + t.Fatalf("expected previous session context to be cancelled") + } + if m.sessionCtx == firstCtx { + t.Fatalf("expected a new session context") + } +} diff --git a/internal/app/runtime_shutdown.go b/internal/app/runtime_shutdown.go index 1f33c6c8..3db80b4b 100644 --- a/internal/app/runtime_shutdown.go +++ b/internal/app/runtime_shutdown.go @@ -1,20 +1,9 @@ package app func (m *Model) shutdown() { - db := m.db - if m.sessionCancel != nil { m.sessionCancel() m.sessionCancel = nil } m.sessionCtx = nil - if m.syncer != nil { - m.syncer.Stop() - } - if db != nil { - if err := db.Close(); err != nil { - m.scope.Warn("failed to close database", "error", err) - } - } - m.db = nil } diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index dea62060..a5343491 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -2,7 +2,6 @@ package app import ( "context" - "fmt" tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/app/chat/usecase" @@ -11,53 +10,30 @@ import ( "github.com/usetero/cli/internal/domain" ) -// startSync starts the syncer with the open database. -func (m *Model) startSync(accountID string) error { - if m.db == nil { - return fmt.Errorf("database not open") - } +// startSession scopes the API services to the account and opens a session +// context that is cancelled on shutdown. There is no local database or sync +// engine: reads and writes go straight to the control plane over GraphQL. +func (m *Model) startSession(accountID string) { if m.sessionCancel != nil { m.shutdown() - if err := m.openDatabase(accountID); err != nil { - return err - } } - // Create a session context that is cancelled on shutdown. sessionCtx, cancel := context.WithCancel(m.ctx) m.sessionCtx = sessionCtx m.sessionCancel = cancel - if err := m.syncer.Start(sessionCtx, m.db, accountID, nil); err != nil { - cancel() - m.sessionCtx = nil - m.sessionCancel = nil - return err - } - m.scope.Info("syncer started", "account_id", accountID) - // Scope API services to the active account. m.services = m.services.WithAccountID(domain.AccountID(accountID)) - - return nil + m.scope.Info("session started", "account_id", accountID) } -// ensureRuntime opens account database, starts sync, and initializes dependent runtime services. +// ensureRuntime scopes the session to the account and initializes dependent +// runtime services (status surfaces, chat tools, chat client). func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { - if err := m.openDatabase(accountID); err != nil { - return nil, err - } - - if err := m.startSync(accountID); err != nil { - return nil, err - } + m.startSession(accountID) - // Start status polling: drawer tabs read from the account-scoped - // control-plane services; the sync indicator still reads the runtime db. - catalogCmd := tea.Batch( - m.statusBar.SetServices(m.services), - m.statusBar.SetDB(m.db), - ) + // Drawer tabs read from the account-scoped control-plane services. + catalogCmd := m.statusBar.SetServices(m.services) // Create tool registry. All tools are GraphQL-backed: the read tools query // the control-plane catalog and set_service_enabled is a synchronous diff --git a/internal/app/statusbar/dispatch_test.go b/internal/app/statusbar/dispatch_test.go index 23f05db2..43723f54 100644 --- a/internal/app/statusbar/dispatch_test.go +++ b/internal/app/statusbar/dispatch_test.go @@ -7,14 +7,13 @@ import ( graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/styles" ) func TestToggleDrawerRequiresData(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") m.tabs = []drawerTab{ stubDrawerTab{label: "A", hasData: false}, stubDrawerTab{label: "B", hasData: true}, @@ -34,7 +33,7 @@ func TestToggleDrawerRequiresData(t *testing.T) { func TestHandleEscDelegatesToActiveTab(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") closed := false m.tabs = []drawerTab{ stubDrawerTab{ @@ -58,7 +57,7 @@ func TestHandleEscDelegatesToActiveTab(t *testing.T) { func TestHandleKeyPressUsesInteractiveTabsOnly(t *testing.T) { t.Parallel() - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") called := false m.tabs = []drawerTab{ stubDrawerTab{ diff --git a/internal/app/statusbar/statusbar.go b/internal/app/statusbar/statusbar.go index 10125965..b420ac1f 100644 --- a/internal/app/statusbar/statusbar.go +++ b/internal/app/statusbar/statusbar.go @@ -2,23 +2,13 @@ package statusbar import ( - "time" - "github.com/usetero/cli/internal/app/statusbar/services" "github.com/usetero/cli/internal/app/statusbar/surfaces" - "github.com/usetero/cli/internal/app/statusbar/syncstatus" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/styles" ) const diag = "╱" -const workspaceCountTimeout = 2 * time.Second - -type workspaceCountLoadedMsg struct { - count int64 - err error -} // Tab indices for the drawer. const ( @@ -39,7 +29,6 @@ type Model struct { scope log.Scope env string tabs []drawerTab - syncStatus *syncstatus.Model issuesStatus *surfaces.Model checksStatus *surfaces.Model servicesStatus *services.Model @@ -48,9 +37,8 @@ type Model struct { width int // Account context - org string - workspace string - workspaceCount int64 + org string + workspace string // Conversation title string @@ -64,13 +52,13 @@ type Model struct { } // New creates a new statusbar. -func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, host string, env string) *Model { +func New(theme styles.Theme, scope log.Scope, host string, env string) *Model { + _ = host scope = scope.Child("statusbar") m := &Model{ theme: theme, scope: scope, env: env, - syncStatus: syncstatus.New(theme, scope, syncer, host), issuesStatus: surfaces.NewIssues(theme, scope), checksStatus: surfaces.NewChecks(theme, scope), servicesStatus: services.New(theme, scope), diff --git a/internal/app/statusbar/statusbar_data.go b/internal/app/statusbar/statusbar_data.go index 50422781..0b4ad042 100644 --- a/internal/app/statusbar/statusbar_data.go +++ b/internal/app/statusbar/statusbar_data.go @@ -1,13 +1,10 @@ package statusbar import ( - "context" - tea "charm.land/bubbletea/v2" graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/sqlite" ) // SetServices points the drawer tabs at the account-scoped control-plane @@ -20,19 +17,9 @@ func (m *Model) SetServices(services graphql.ServiceSet) tea.Cmd { return tea.Batch(cmds...) } -// SetDB feeds the sync indicator and workspace count, which still read the -// local runtime database. The drawer tabs are fed via SetServices. -// -// syncStatus is fed alongside the drawer tabs even though it is no longer a -// drawer tab itself: its compact sync dot lives in the brand segment and its -// pending-upload count needs the runtime database. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - return tea.Batch(m.fetchWorkspaceCount(db), m.syncStatus.SetDB(db)) -} - // Init initializes child models. func (m *Model) Init() tea.Cmd { - cmds := []tea.Cmd{m.syncStatus.Init()} + cmds := make([]tea.Cmd, 0, len(m.tabs)) for _, tab := range m.tabs { cmds = append(cmds, tab.Init()) } @@ -43,7 +30,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) Update(msg tea.Msg) tea.Cmd { m.ingestStatusMessages(msg) - cmds := []tea.Cmd{m.syncStatus.Update(msg)} + cmds := make([]tea.Cmd, 0, len(m.tabs)) for _, tab := range m.tabs { cmds = append(cmds, tab.Update(msg)) } @@ -58,26 +45,6 @@ func (m *Model) ingestStatusMessages(msg tea.Msg) { m.org = msg.Org.Name case bootstrap.WorkspaceSelected: m.workspace = msg.Workspace.Name - case workspaceCountLoadedMsg: - if msg.err != nil { - m.scope.Error("scan workspace count", "err", msg.err) - break - } - m.workspaceCount = msg.count - } -} - -func (m *Model) fetchWorkspaceCount(db sqlite.DB) tea.Cmd { - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), workspaceCountTimeout) - defer cancel() - - var count int64 - row := db.QueryRow(ctx, "SELECT COUNT(*) FROM workspaces") - if err := row.Scan(&count); err != nil { - return workspaceCountLoadedMsg{err: err} - } - return workspaceCountLoadedMsg{count: count} } } diff --git a/internal/app/statusbar/statusbar_test.go b/internal/app/statusbar/statusbar_test.go index 42817555..e1bbd0a8 100644 --- a/internal/app/statusbar/statusbar_test.go +++ b/internal/app/statusbar/statusbar_test.go @@ -1,79 +1,25 @@ package statusbar import ( - "context" "strings" "testing" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" "github.com/usetero/cli/internal/styles" ) -func TestFetchWorkspaceCountAndUpdate(t *testing.T) { - db := sqlitetest.OpenBareDB(t) - ctx := context.Background() - - if _, err := db.Exec(ctx, "CREATE TABLE workspaces (id TEXT PRIMARY KEY)"); err != nil { - t.Fatalf("create workspaces table: %v", err) - } - if _, err := db.Exec(ctx, "INSERT INTO workspaces (id) VALUES (?), (?)", "w1", "w2"); err != nil { - t.Fatalf("insert workspaces: %v", err) - } - - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") - msg := m.fetchWorkspaceCount(db)() - countMsg, ok := msg.(workspaceCountLoadedMsg) - if !ok { - t.Fatalf("expected workspaceCountLoadedMsg, got %T", msg) - } - if countMsg.err != nil { - t.Fatalf("unexpected fetch error: %v", countMsg.err) - } - if countMsg.count != 2 { - t.Fatalf("expected count=2, got %d", countMsg.count) - } +func TestRenderOrgContext(t *testing.T) { + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") m.org = "Acme" - m.workspace = "Prod" - m.Update(countMsg) - view := m.renderOrgWorkspace() - if !strings.Contains(view, "Acme / Prod") { - t.Fatalf("expected org/workspace view, got %q", view) - } -} - -func TestFetchWorkspaceCountReturnsErrorWhenTableMissing(t *testing.T) { - db := sqlitetest.OpenBareDB(t) - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") - - msg := m.fetchWorkspaceCount(db)() - countMsg, ok := msg.(workspaceCountLoadedMsg) - if !ok { - t.Fatalf("expected workspaceCountLoadedMsg, got %T", msg) - } - if countMsg.err == nil { - t.Fatalf("expected error when workspaces table is missing") - } -} - -func TestSyncStatusStaysWiredOutsideDrawerTabs(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") - - // syncStatus renders the brand sync dot but is no longer a drawer tab, so - // the lifecycle must reach it independently of m.tabs. Clearing the tabs - // isolates that wiring: with the syncer present, Init must still start the - // sync poll loop. - m.tabs = nil - if m.Init() == nil { - t.Fatal("Init() must start syncStatus polling even with no drawer tabs") + if !strings.Contains(view, "Acme") { + t.Fatalf("expected org in view, got %q", view) } } func TestBuildTabsMirrorsProductSurfaces(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com", "dev") + m := New(styles.NewTheme(true), logtest.NewScope(t), "https://api.example.com", "dev") want := []struct { group string diff --git a/internal/app/statusbar/statusbar_view.go b/internal/app/statusbar/statusbar_view.go index db8fab93..8d016e1b 100644 --- a/internal/app/statusbar/statusbar_view.go +++ b/internal/app/statusbar/statusbar_view.go @@ -204,11 +204,6 @@ func (m *Model) renderBrand() string { brand += " " + envStyle.Render(strings.ToUpper(m.env)) } - syncView := m.syncStatus.CompactView() - if syncView != "" { - brand += " " + syncView - } - if m.org != "" { brand += " " + m.renderOrgWorkspace() } @@ -216,13 +211,10 @@ func (m *Model) renderBrand() string { return brand } -// renderOrgWorkspace renders org context. Includes workspace when multiple exist. +// renderOrgWorkspace renders the org context for the brand segment. func (m *Model) renderOrgWorkspace() string { colors := m.theme style := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - if m.workspace != "" && m.workspaceCount > 1 { - return style.Render(m.org + " / " + m.workspace) - } return style.Render(m.org) } diff --git a/internal/app/statusbar/syncstatus/syncstatus.go b/internal/app/statusbar/syncstatus/syncstatus.go deleted file mode 100644 index 67426a18..00000000 --- a/internal/app/statusbar/syncstatus/syncstatus.go +++ /dev/null @@ -1,301 +0,0 @@ -// Package syncstatus renders sync connection status. -package syncstatus - -import ( - "context" - "fmt" - "image/color" - "net/url" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/styles" -) - -const ( - pollInterval = 500 * time.Millisecond - pendingPollInterval = 2 * time.Second - dbTimeout = 2 * time.Second -) - -// syncPollTickMsg triggers a sync status check. -type syncPollTickMsg struct{} - -// pendingUploadsPollTickMsg triggers a pending-upload count refresh. -type pendingUploadsPollTickMsg struct{} - -// pendingUploadsLoadedMsg carries the result of an async pending-upload count. -type pendingUploadsLoadedMsg struct { - total int64 -} - -// Model renders sync connection status. -type Model struct { - theme styles.Theme - scope log.Scope - syncer powersync.Syncer - db sqlite.DB - host string - - // Cached state for change detection - lastState powersync.State - totalPending int64 - pendingFetch bool -} - -// New creates a new sync status model. -func New(theme styles.Theme, scope log.Scope, syncer powersync.Syncer, endpoint string) *Model { - host := endpoint - if u, err := url.Parse(endpoint); err == nil && u.Host != "" { - host = u.Host - } - - return &Model{ - theme: theme, - scope: scope.Child("syncstatus"), - syncer: syncer, - host: host, - } -} - -// SetDB sets the database for record count polling. -func (m *Model) SetDB(db sqlite.DB) tea.Cmd { - m.db = db - return nil -} - -// Init starts polling sync status. -func (m *Model) Init() tea.Cmd { - if m.syncer == nil { - return nil - } - return tea.Batch(m.poll(), m.pollPending()) -} - -func (m *Model) poll() tea.Cmd { - return tea.Tick(pollInterval, func(time.Time) tea.Msg { - return syncPollTickMsg{} - }) -} - -func (m *Model) pollPending() tea.Cmd { - return tea.Tick(pendingPollInterval, func(time.Time) tea.Msg { - return pendingUploadsPollTickMsg{} - }) -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case syncPollTickMsg: - if m.syncer == nil { - return nil - } - - // syncer.State() is an atomic load — safe to call inline. - currentState := m.syncer.State() - stateChanged := m.stateChanged(currentState) - if stateChanged { - m.lastState = currentState - } - - cmds := []tea.Cmd{m.poll()} - if stateChanged { - cmds = append(cmds, func() tea.Msg { return appevents.SyncStateChanged{State: currentState} }) - - if errState, ok := currentState.(*powersync.Error); ok { - cmds = append(cmds, appevents.PublishErrorToastCmd("Sync error", errState.Err, true)) - } - } - - return tea.Batch(cmds...) - - case pendingUploadsPollTickMsg: - if m.db == nil { - return m.pollPending() - } - if m.pendingFetch { - return m.pollPending() - } - m.pendingFetch = true - return tea.Batch(m.pollPending(), m.fetchPending()) - - case pendingUploadsLoadedMsg: - m.pendingFetch = false - m.totalPending = msg.total - } - - return nil -} - -// fetchPending returns a Cmd that queries pending upload counts off the event loop. -func (m *Model) fetchPending() tea.Cmd { - if m.db == nil { - return nil - } - db := m.db - scope := m.scope - return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), dbTimeout) - defer cancel() - pending, err := db.PendingUploadCounts(ctx) - if err != nil { - scope.Error("pending upload counts", "err", err) - return pendingUploadsLoadedMsg{} - } - - var total int64 - for _, count := range pending { - total += count - } - return pendingUploadsLoadedMsg{total: total} - } -} - -// stateChanged returns true if the sync state has meaningfully changed. -func (m *Model) stateChanged(current powersync.State) bool { - if m.lastState == nil { - return current != nil - } - if current == nil { - return true - } - - switch last := m.lastState.(type) { - case *powersync.Disconnected: - _, ok := current.(*powersync.Disconnected) - return !ok - case *powersync.Connecting: - _, ok := current.(*powersync.Connecting) - return !ok - case *powersync.Syncing: - _, ok := current.(*powersync.Syncing) - return !ok - case *powersync.Ready: - _, ok := current.(*powersync.Ready) - return !ok - case *powersync.Reconnecting: - cur, ok := current.(*powersync.Reconnecting) - return !ok || cur.Degraded != last.Degraded - case *powersync.Error: - _, ok := current.(*powersync.Error) - return !ok - } - return true -} - -// HasData returns true when the syncer has reported at least one state update. -func (m *Model) HasData() bool { - return m.lastState != nil -} - -// CompactView renders the sync status for the statusbar: "● api.usetero.com" or "● syncing 45%" -func (m *Model) CompactView() string { - if m.lastState == nil { - return "" - } - - colors := m.theme - - switch state := m.lastState.(type) { - case *powersync.Disconnected: - return "" - - case *powersync.Connecting: - return dot(colors.Warning, colors.Bg) - - case *powersync.Syncing: - return dot(colors.Warning, colors.Bg) - - case *powersync.Ready: - return dot(colors.Success, colors.Bg) - - case *powersync.Reconnecting: - if state.Degraded { - return dot(colors.Error, colors.Bg) - } - return dot(colors.Warning, colors.Bg) - - case *powersync.Error: - return dot(colors.Error, colors.Bg) - - default: - return "" - } -} - -// ExpandedView renders the detailed sync status for the drawer. -func (m *Model) ExpandedView(width, _ int) string { - if m.lastState == nil { - return "" - } - - colors := m.theme - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - var headline string - var description string - - switch state := m.lastState.(type) { - case *powersync.Disconnected: - headline = dot(colors.TextSubtle, colors.Bg) + " " + text.Render("Disconnected") - description = "Sync has not started yet." - - case *powersync.Connecting: - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Connecting...") - description = "Establishing connection to the control plane." - - case *powersync.Syncing: - if state.Progress != nil && state.Progress.Total > 0 { - pct := state.Progress.Downloaded * 100 / state.Progress.Total - headline = dot(colors.Warning, colors.Bg) + " " + text.Render(fmt.Sprintf("Syncing your data... %d%%", pct)) - description = fmt.Sprintf("%d / %d rows downloaded.", state.Progress.Downloaded, state.Progress.Total) - } else { - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Syncing your data...") - description = "Downloading from the control plane." - } - - case *powersync.Ready: - headline = dot(colors.Success, colors.Bg) + " " + text.Render("Connected") - description = "Your data is synced and up to date." - - case *powersync.Reconnecting: - if state.Degraded { - headline = dot(colors.Error, colors.Bg) + " " + text.Render("Connection issues") - description = "Multiple retries failed. Still trying to reconnect." - } else { - headline = dot(colors.Warning, colors.Bg) + " " + text.Render("Reconnecting...") - description = "Temporarily lost connection. Retrying automatically." - } - - case *powersync.Error: - errStyle := lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - headline = dot(colors.Error, colors.Bg) + " " + errStyle.Render("Sync failed") - description = state.Err.Error() - } - - var lines []string - lines = append(lines, headline) - lines = append(lines, "") - lines = append(lines, muted.Render(description)) - - if m.totalPending > 0 { - lines = append(lines, "") - warn := lipgloss.NewStyle().Foreground(colors.Warning).Background(colors.Bg) - lines = append(lines, warn.Render(fmt.Sprintf("%d pending uploads", m.totalPending))) - } - - return strings.Join(lines, "\n") -} - -func dot(c color.Color, bg color.Color) string { - return lipgloss.NewStyle().Foreground(c).Background(bg).Render("●") -} diff --git a/internal/app/statusbar/syncstatus/syncstatus_test.go b/internal/app/statusbar/syncstatus/syncstatus_test.go deleted file mode 100644 index 4af7b0d5..00000000 --- a/internal/app/statusbar/syncstatus/syncstatus_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package syncstatus - -import ( - "testing" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite/sqlitetest" - "github.com/usetero/cli/internal/styles" -) - -func TestPendingPollDoesNotOverlapFetches(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com") - db := sqlitetest.OpenBareDB(t) - m.SetDB(db) - - m.pendingFetch = true - if cmd := m.Update(pendingUploadsPollTickMsg{}); cmd == nil { - t.Fatalf("expected pending poll to keep polling even while fetch is in-flight") - } - if !m.pendingFetch { - t.Fatalf("expected in-flight flag to remain set") - } -} - -func TestPendingUploadsLoadedMsgClearsInFlightFlag(t *testing.T) { - m := New(styles.NewTheme(true), logtest.NewScope(t), powersynctest.NewMockSyncer(), "https://api.example.com") - m.pendingFetch = true - - m.Update(pendingUploadsLoadedMsg{total: 42}) - - if m.pendingFetch { - t.Fatalf("expected pending message to clear in-flight flag") - } - if m.totalPending != 42 { - t.Fatalf("expected pending total to update, got %d", m.totalPending) - } -} diff --git a/internal/app/statusbar/tabpoll/tabpoll.go b/internal/app/statusbar/tabpoll/tabpoll.go index 304f23d3..ef6be036 100644 --- a/internal/app/statusbar/tabpoll/tabpoll.go +++ b/internal/app/statusbar/tabpoll/tabpoll.go @@ -6,7 +6,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/usetero/cli/internal/app/statusbar/listdetail" - "github.com/usetero/cli/internal/sqlite" ) // PollMsg triggers a tab data refresh tick. @@ -30,7 +29,7 @@ func Tick(source string, interval time.Duration) tea.Cmd { // Fetch executes a typed data fetch with a timeout and returns DataMsg[T]. func Fetch[T any](timeout time.Duration, fetch func(ctx context.Context) (T, error)) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data, err := fetch(ctx) return DataMsg[T]{Data: data, Err: err} @@ -40,7 +39,7 @@ func Fetch[T any](timeout time.Duration, fetch func(ctx context.Context) (T, err // FetchDetail executes a typed detail fetch with a timeout and maps result+error into a tea.Msg. func FetchDetail[T any](timeout time.Duration, fetch func(ctx context.Context) (T, error), mapMsg func(data T, err error) tea.Msg) tea.Cmd { return func() tea.Msg { - ctx, cancel := sqlite.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data, err := fetch(ctx) return mapMsg(data, err) diff --git a/internal/app/view_overlay_test.go b/internal/app/view_overlay_test.go index f7243c30..838206c5 100644 --- a/internal/app/view_overlay_test.go +++ b/internal/app/view_overlay_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "path/filepath" "strings" "testing" @@ -11,7 +10,6 @@ import ( "github.com/usetero/cli/internal/boundary/graphql/apitest" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/powersynctest" "github.com/usetero/cli/internal/preferences/preferencestest" "github.com/usetero/cli/internal/styles" ) @@ -77,7 +75,6 @@ func newViewTestModel(t *testing.T) *Model { userPrefs := preferencestest.NewMockUserPreferences() orgPrefs := preferencestest.NewMockOrgPreferences() authSvc := &authtest.MockAuth{} - syncer := powersynctest.NewMockSyncer() m := New( context.Background(), @@ -91,8 +88,6 @@ func newViewTestModel(t *testing.T) *Model { authSvc, userPrefs, orgPrefs, - testStorage{dbPath: filepath.Join(t.TempDir(), "view-test.sqlite")}, - syncer, scope, ) m.width = 120 diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go index 42924d18..975ccdc9 100644 --- a/internal/architecture/dependencies_test.go +++ b/internal/architecture/dependencies_test.go @@ -23,7 +23,6 @@ func TestDependencyBoundaries(t *testing.T) { graphqlRoot := filepath.Join(root, "internal", "boundary", "graphql") chatBoundaryRoot := filepath.Join(root, "internal", "boundary", "chat") - powersyncBoundaryRoot := filepath.Join(root, "internal", "boundary", "powersync") coreRoot := filepath.Join(root, "internal", "core") chatRoot := filepath.Join(root, "internal", "app", "chat") @@ -33,9 +32,6 @@ func TestDependencyBoundaries(t *testing.T) { assertNoForbiddenImports(t, chatBoundaryRoot, []string{ "github.com/usetero/cli/internal/app/", }) - assertNoForbiddenImports(t, powersyncBoundaryRoot, []string{ - "github.com/usetero/cli/internal/app/", - }) assertNoForbiddenImports(t, coreRoot, []string{ "github.com/usetero/cli/internal/app/", "github.com/usetero/cli/internal/boundary/graphql/", diff --git a/internal/boundary/powersync/apitest/mock_client.go b/internal/boundary/powersync/apitest/mock_client.go deleted file mode 100644 index 37d31bf7..00000000 --- a/internal/boundary/powersync/apitest/mock_client.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package apitest provides test doubles for the powersync/api package. -package apitest - -import ( - "context" - - psapi "github.com/usetero/cli/internal/boundary/powersync" -) - -// MockClient is a test double for psapi.Client. -type MockClient struct { - // SyncStreamFunc is called when SyncStream is invoked. - SyncStreamFunc func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error - - // GetWriteCheckpointFunc is called when GetWriteCheckpoint is invoked. - GetWriteCheckpointFunc func(ctx context.Context, clientID string) (string, error) - - // Token is the current token set via SetToken. - Token string - - // SyncStreamCalls records the number of times SyncStream was called. - SyncStreamCalls int - - // GetWriteCheckpointCalls records the number of times GetWriteCheckpoint was called. - GetWriteCheckpointCalls int -} - -// Ensure MockClient implements psapi.Client. -var _ psapi.Client = (*MockClient)(nil) - -// NewMockClient creates a new MockClient with sensible defaults. -func NewMockClient() *MockClient { - return &MockClient{ - SyncStreamFunc: func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - <-ctx.Done() - return ctx.Err() - }, - } -} - -// SyncStream implements psapi.Client. -func (m *MockClient) SyncStream(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - m.SyncStreamCalls++ - if m.SyncStreamFunc != nil { - return m.SyncStreamFunc(ctx, req, handler) - } - return nil -} - -// GetWriteCheckpoint implements psapi.Client. -func (m *MockClient) GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) { - m.GetWriteCheckpointCalls++ - if m.GetWriteCheckpointFunc != nil { - return m.GetWriteCheckpointFunc(ctx, clientID) - } - return "1", nil -} - -// SetToken implements psapi.Client. -func (m *MockClient) SetToken(token string) { - m.Token = token -} - -// NewMockClientFactory returns a client factory that always returns the given mock. -func NewMockClientFactory(mock *MockClient) func(endpoint string) psapi.Client { - return func(endpoint string) psapi.Client { - return mock - } -} diff --git a/internal/boundary/powersync/client.go b/internal/boundary/powersync/client.go deleted file mode 100644 index 0e6ebdfd..00000000 --- a/internal/boundary/powersync/client.go +++ /dev/null @@ -1,251 +0,0 @@ -// Package api provides HTTP client access to the PowerSync service. -package powersync - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" -) - -// Client provides HTTP access to the PowerSync service. -type Client interface { - SyncStream(ctx context.Context, req *SyncStreamRequest, handler LineHandler) error - GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) - SetToken(token string) -} - -// HTTPDoer is the interface for making HTTP requests. -type HTTPDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -// NewClient creates a new PowerSync client. -func NewClient(endpoint string) Client { - return &httpClient{ - endpoint: endpoint, - http: http.DefaultClient, - } -} - -// httpClient is the concrete implementation of Client. -type httpClient struct { - endpoint string - token string - http HTTPDoer -} - -// SetToken updates the authentication token. -func (c *httpClient) SetToken(token string) { - c.token = token -} - -// SetHTTPDoer sets a custom HTTP client (for testing). -func (c *httpClient) SetHTTPDoer(doer HTTPDoer) { - c.http = doer -} - -// LineHandler is called for each line received from the sync stream. -type LineHandler func(line []byte) error - -// SyncStream opens a streaming connection to the sync endpoint. -// It calls handler for each line received until the stream ends, -// context is cancelled, or handler returns an error. -func (c *httpClient) SyncStream(ctx context.Context, req *SyncStreamRequest, handler LineHandler) error { - u, err := url.Parse(c.endpoint) - if err != nil { - return fmt.Errorf("parse endpoint: %w", err) - } - u.Path = "/sync/stream" - - body, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+c.token) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "application/x-ndjson") - httpReq.Body = io.NopCloser(bytes.NewReader(body)) - httpReq.ContentLength = int64(len(body)) - - resp, err := c.http.Do(httpReq) - if err != nil { - return &Error{ - Kind: ErrorKindTransient, - Message: "connection failed", - Err: err, - } - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return &Error{ - Kind: classifyHTTPStatus(resp.StatusCode), - StatusCode: resp.StatusCode, - Message: string(respBody), - } - } - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - line := scanner.Bytes() - if len(line) == 0 { - continue - } - - if err := handler(line); err != nil { - return err - } - } - - if ctx.Err() != nil { - return ctx.Err() - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("read stream: %w", err) - } - - return nil -} - -// GetWriteCheckpoint fetches a write checkpoint from the server. -// The checkpoint represents the server's acknowledgment of uploaded changes. -func (c *httpClient) GetWriteCheckpoint(ctx context.Context, clientID string) (string, error) { - u, err := url.Parse(c.endpoint) - if err != nil { - return "", fmt.Errorf("parse endpoint: %w", err) - } - u.Path = "/write-checkpoint2.json" - u.RawQuery = "client_id=" + url.QueryEscape(clientID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return "", fmt.Errorf("create request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+c.token) - - resp, err := c.http.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - WriteCheckpoint string `json:"write_checkpoint"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("parse response: %w", err) - } - - return result.WriteCheckpoint, nil -} - -// SyncStreamRequest is the request body for the sync stream endpoint. -type SyncStreamRequest struct { - Buckets []BucketRequest `json:"buckets"` - IncludeChecksum bool `json:"include_checksum"` - RawData bool `json:"raw_data"` - BinaryData bool `json:"binary_data"` - ClientID string `json:"client_id"` - Parameters map[string]any `json:"parameters,omitempty"` - Streams *StreamSubscription `json:"streams,omitempty"` - AppMetadata json.RawMessage `json:"app_metadata,omitempty"` -} - -// BucketRequest specifies a bucket to sync and the last known checkpoint. -type BucketRequest struct { - Name string `json:"name"` - After string `json:"after"` -} - -// StreamSubscription defines stream subscription preferences. -type StreamSubscription struct { - IncludeDefaults bool `json:"include_defaults"` - Subscriptions []RequestedStreamSubscription `json:"subscriptions"` -} - -// RequestedStreamSubscription is a request to subscribe to a stream. -type RequestedStreamSubscription struct { - Stream string `json:"stream"` - Parameters string `json:"parameters,omitempty"` - OverridePriority *int `json:"override_priority,omitempty"` -} - -// ErrorKind classifies client errors. -type ErrorKind int - -const ( - ErrorKindTransient ErrorKind = iota // Retry with backoff - ErrorKindAuth // Refresh token and retry - ErrorKindPermanent // Don't retry -) - -// Error represents an error from the PowerSync service. -type Error struct { - Kind ErrorKind - StatusCode int - Message string - Err error -} - -func (e *Error) Error() string { - if e.StatusCode > 0 { - return fmt.Sprintf("powersync api: %d: %s", e.StatusCode, e.Message) - } - if e.Err != nil { - return fmt.Sprintf("powersync api: %v", e.Err) - } - return fmt.Sprintf("powersync api: %s", e.Message) -} - -func (e *Error) Unwrap() error { - return e.Err -} - -func (e *Error) IsAuth() bool { - return e.Kind == ErrorKindAuth -} - -func (e *Error) IsTransient() bool { - return e.Kind == ErrorKindTransient -} - -func (e *Error) IsPermanent() bool { - return e.Kind == ErrorKindPermanent -} - -func classifyHTTPStatus(statusCode int) ErrorKind { - switch { - case statusCode == 401 || statusCode == 403: - return ErrorKindAuth - case statusCode >= 500 || statusCode == 429: - return ErrorKindTransient - default: - return ErrorKindPermanent - } -} diff --git a/internal/boundary/powersync/indexes.go b/internal/boundary/powersync/indexes.go deleted file mode 100644 index d8e866ef..00000000 --- a/internal/boundary/powersync/indexes.go +++ /dev/null @@ -1,82 +0,0 @@ -package powersync - -// clientIndexes defines indexes for client-side query performance. -// These are not part of the server schema — they optimize local SQLite queries. -// The map key is the table name, the value is a list of indexes to create. -var clientIndexes = map[string][]SchemaIndex{ - "services": { - {Name: "name", Columns: []SchemaIndexColumn{ - {Name: "name", Ascending: true, Type: "text"}, - }}, - }, - - "log_events": { - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - // Filtered by status, category, objectivity, risk_level in AI-generated queries. - // Joined on log_event_id. - "log_event_policy_statuses_cache": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - {Name: "category_status", Columns: []SchemaIndexColumn{ - {Name: "category", Ascending: true, Type: "text"}, - {Name: "status", Ascending: true, Type: "text"}, - }}, - }, - - "log_event_statuses_cache": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - "service_statuses_cache": { - {Name: "service_id", Columns: []SchemaIndexColumn{ - {Name: "service_id", Ascending: true, Type: "text"}, - }}, - }, - - "log_event_policies": { - {Name: "log_event_id", Columns: []SchemaIndexColumn{ - {Name: "log_event_id", Ascending: true, Type: "text"}, - }}, - }, - - "messages": { - {Name: "conversation_id", Columns: []SchemaIndexColumn{ - {Name: "conversation_id", Ascending: true, Type: "text"}, - }}, - }, - - "conversations": { - {Name: "account_id", Columns: []SchemaIndexColumn{ - {Name: "account_id", Ascending: true, Type: "text"}, - }}, - }, - - "datadog_account_statuses_cache": { - {Name: "datadog_account_id", Columns: []SchemaIndexColumn{ - {Name: "datadog_account_id", Ascending: true, Type: "text"}, - }}, - }, -} - -// applyClientIndexes merges client-side indexes into fetched schema tables. -// Tables without indexes get an empty slice so JSON encodes as [] not null. -func applyClientIndexes(tables []SchemaTable) []SchemaTable { - for i, table := range tables { - if indexes, ok := clientIndexes[table.Name]; ok { - tables[i].Indexes = indexes - } else { - tables[i].Indexes = []SchemaIndex{} - } - } - return tables -} diff --git a/internal/boundary/powersync/schema.go b/internal/boundary/powersync/schema.go deleted file mode 100644 index 840f648c..00000000 --- a/internal/boundary/powersync/schema.go +++ /dev/null @@ -1,261 +0,0 @@ -package powersync - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" -) - -// SQLite type constants from PowerSync schema API. -const ( - sqliteTypeText = 2 - sqliteTypeInteger = 4 - sqliteTypeReal = 8 -) - -// SchemaTable represents a table in the PowerSync schema. -type SchemaTable struct { - Name string `json:"name"` - Columns []SchemaColumn `json:"columns"` - Indexes []SchemaIndex `json:"indexes"` -} - -// SchemaColumn represents a column in a PowerSync table. -type SchemaColumn struct { - Name string `json:"name"` - Type string `json:"type"` // "text", "integer", "real" -} - -// SchemaIndex represents an index on a PowerSync table. -type SchemaIndex struct { - Name string `json:"name"` - Columns []SchemaIndexColumn `json:"columns"` -} - -// SchemaIndexColumn represents a column in an index. -type SchemaIndexColumn struct { - Name string `json:"name"` - Ascending bool `json:"ascending"` - Type string `json:"type"` -} - -// schemaResponse is the response from /api/admin/v1/schema. -type schemaResponse struct { - Data struct { - Connections []struct { - Schemas []struct { - Name string `json:"name"` - Tables []struct { - Name string `json:"name"` - Columns []struct { - Name string `json:"name"` - SQLiteType int `json:"sqlite_type"` - PgType string `json:"pg_type"` - } `json:"columns"` - } `json:"tables"` - } `json:"schemas"` - } `json:"connections"` - } `json:"data"` -} - -// syncRulesResponse is the response from /api/sync-rules/v1/current. -type syncRulesResponse struct { - Data struct { - Current struct { - BucketDefinitions []struct { - DataQueries []struct { - Table struct { - TablePattern string `json:"tablePattern"` - } `json:"table"` - Columns []string `json:"columns"` - } `json:"data_queries"` - } `json:"bucket_definitions"` - } `json:"current"` - } `json:"data"` -} - -// columnType holds type information for a column. -type columnType struct { - sqliteType int - pgType string -} - -// FetchSchemaJSON fetches the schema from the PowerSync service and returns it as JSON. -// The returned JSON is in the format expected by powersync_replace_schema(). -func FetchSchemaJSON(ctx context.Context, endpoint, token string) (string, error) { - // Fetch column types from schema API - columnTypes, err := fetchColumnTypes(ctx, endpoint, token) - if err != nil { - return "", fmt.Errorf("fetch column types: %w", err) - } - - // Fetch synced tables from sync rules API - tables, err := fetchSyncedTables(ctx, endpoint, token, columnTypes) - if err != nil { - return "", fmt.Errorf("fetch synced tables: %w", err) - } - - // Apply client-side indexes for query performance - tables = applyClientIndexes(tables) - - // Build schema JSON - schema := struct { - Tables []SchemaTable `json:"tables"` - }{ - Tables: tables, - } - - data, err := json.Marshal(schema) - if err != nil { - return "", fmt.Errorf("marshal schema: %w", err) - } - - return string(data), nil -} - -// fetchColumnTypes fetches column type information from the schema API. -func fetchColumnTypes(ctx context.Context, endpoint, token string) (map[string]map[string]columnType, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/api/admin/v1/schema", strings.NewReader("{}")) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("schema API returned %d: %s", resp.StatusCode, body) - } - - var result schemaResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Build column type map - columnTypes := make(map[string]map[string]columnType) - for _, conn := range result.Data.Connections { - for _, schema := range conn.Schemas { - for _, table := range schema.Tables { - if columnTypes[table.Name] == nil { - columnTypes[table.Name] = make(map[string]columnType) - } - for _, col := range table.Columns { - columnTypes[table.Name][col.Name] = columnType{ - sqliteType: col.SQLiteType, - pgType: col.PgType, - } - } - } - } - } - - return columnTypes, nil -} - -// fetchSyncedTables fetches the synced tables from the sync rules API. -func fetchSyncedTables(ctx context.Context, endpoint, token string, columnTypes map[string]map[string]columnType) ([]SchemaTable, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/api/sync-rules/v1/current", nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sync-rules API returned %d: %s", resp.StatusCode, body) - } - - var result syncRulesResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Collect unique tables and their columns - tableColumns := make(map[string][]string) - for _, bucket := range result.Data.Current.BucketDefinitions { - for _, query := range bucket.DataQueries { - tableName := query.Table.TablePattern - existing := tableColumns[tableName] - for _, col := range query.Columns { - if !contains(existing, col) { - existing = append(existing, col) - } - } - tableColumns[tableName] = existing - } - } - - // Build schema tables - var tables []SchemaTable - for tableName, columns := range tableColumns { - // Sort columns: id first, then alphabetically - sort.Slice(columns, func(i, j int) bool { - if columns[i] == "id" { - return true - } - if columns[j] == "id" { - return false - } - return columns[i] < columns[j] - }) - - var schemaColumns []SchemaColumn - for _, colName := range columns { - colType := columnTypes[tableName][colName] - schemaColumns = append(schemaColumns, SchemaColumn{ - Name: colName, - Type: toSQLiteTypeName(colType.sqliteType), - }) - } - - tables = append(tables, SchemaTable{ - Name: tableName, - Columns: schemaColumns, - }) - } - - // Sort tables by name for deterministic output - sort.Slice(tables, func(i, j int) bool { - return tables[i].Name < tables[j].Name - }) - - return tables, nil -} - -// toSQLiteTypeName converts a SQLite type constant to a string. -func toSQLiteTypeName(sqliteType int) string { - switch sqliteType { - case sqliteTypeInteger: - return "integer" - case sqliteTypeReal: - return "real" - default: - return "text" - } -} - -func contains(slice []string, s string) bool { - for _, item := range slice { - if item == s { - return true - } - } - return false -} diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go index 8448f2c2..b65e5a07 100644 --- a/internal/cmd/debug.go +++ b/internal/cmd/debug.go @@ -8,7 +8,6 @@ import ( "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -258,13 +257,6 @@ func newDebugPathsCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Comma if err == nil { baseDir, _ := orgCfg.BaseDir() fmt.Println(kv(s, "Base Dir", baseDir)) - - orgPrefs := preferences.NewOrgService(orgCfg, scope) - if accountID := orgPrefs.GetDefaultAccountID(); accountID != "" { - storage := sqlite.NewStorageService(orgCfg) - dbPath, _ := storage.DatabasePath(accountID.String()) - fmt.Println(kv(s, "Database", dbPath)) - } } } diff --git a/internal/cmd/internal.go b/internal/cmd/internal.go index 9b7d17b8..58ff3c1e 100644 --- a/internal/cmd/internal.go +++ b/internal/cmd/internal.go @@ -17,7 +17,6 @@ func NewInternalCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command } internalCmd.AddCommand(NewInternalInspectCmd(scope, cliConfig)) - internalCmd.AddCommand(NewInternalPowerSyncCmd(scope, cliConfig)) return internalCmd } diff --git a/internal/cmd/internal_powersync.go b/internal/cmd/internal_powersync.go deleted file mode 100644 index 02b121ba..00000000 --- a/internal/cmd/internal_powersync.go +++ /dev/null @@ -1,146 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "github.com/spf13/cobra" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" -) - -func NewInternalPowerSyncCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { - scope = scope.Child("powersync") - - cmd := &cobra.Command{ - Use: "powersync", - Short: "PowerSync operational commands", - } - cmd.AddCommand(newInternalPowerSyncCaptureCmd(scope, cliConfig)) - cmd.AddCommand(newInternalPowerSyncSanitizeFixtureCmd(scope)) - return cmd -} - -func newInternalPowerSyncCaptureCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { - var ( - accountID string - output string - duration time.Duration - maxBytes int64 - ) - - cmd := &cobra.Command{ - Use: "capture", - Short: "Capture raw PowerSync NDJSON stream lines to a fixture file", - RunE: func(cmd *cobra.Command, _ []string) error { - if output == "" { - return fmt.Errorf("--output is required") - } - if duration <= 0 { - return fmt.Errorf("--duration must be > 0") - } - if maxBytes <= 0 { - return fmt.Errorf("--max-bytes must be > 0") - } - - env := cliConfig.Environment() - - resolvedOutput, err := resolveCaptureOutputPath(env, output) - if err != nil { - return err - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - return fmt.Errorf("load org preferences: %w", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, scope) - - if accountID == "" { - accountID = orgPrefs.GetDefaultAccountID().String() - } - if accountID == "" { - return fmt.Errorf("no account configured; pass --account-id or complete onboarding first") - } - - authService := newAuthService(cliConfig, scope) - - storage := sqlite.NewStorageService(orgCfg) - dbPath, err := storage.DatabasePath(accountID) - if err != nil { - return fmt.Errorf("resolve database path: %w", err) - } - db, err := sqlite.Open(cmd.Context(), dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - capture, err := powersync.NewNDJSONStreamCapture(resolvedOutput, maxBytes, scope) - if err != nil { - return fmt.Errorf("create stream capture: %w", err) - } - - syncer := powersync.NewSyncer( - cliConfig.PowerSyncEndpoint, - authService, - scope, - powersync.WithStreamCapture(capture), - ) - - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() - - if err := syncer.Start(ctx, db, accountID, nil); err != nil { - return fmt.Errorf("start syncer: %w", err) - } - defer syncer.Stop() - - fmt.Printf("Capturing PowerSync stream for account %s\n", accountID) - fmt.Printf("Output: %s\n", resolvedOutput) - fmt.Printf("Duration: %s\n", duration) - fmt.Printf("Max bytes: %d\n", maxBytes) - - sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() - - timer := time.NewTimer(duration) - defer timer.Stop() - - select { - case <-timer.C: - fmt.Println("Capture complete") - case <-sigCtx.Done(): - fmt.Println("Capture interrupted") - } - - return nil - }, - } - - cmd.Flags().StringVar(&accountID, "account-id", "", "Account ID to sync (defaults to org preference)") - cmd.Flags().StringVar(&output, "output", "", "Output path for NDJSON fixture (required)") - cmd.Flags().DurationVar(&duration, "duration", 90*time.Second, "Capture duration") - cmd.Flags().Int64Var(&maxBytes, "max-bytes", 25*1024*1024, "Maximum capture file size in bytes") - - return cmd -} - -func resolveCaptureOutputPath(env, output string) (string, error) { - if filepath.IsAbs(output) { - return output, nil - } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(homeDir, ".tero", "environments", env, output), nil -} diff --git a/internal/cmd/internal_powersync_sanitize.go b/internal/cmd/internal_powersync_sanitize.go deleted file mode 100644 index 5b5bc21f..00000000 --- a/internal/cmd/internal_powersync_sanitize.go +++ /dev/null @@ -1,230 +0,0 @@ -package cmd - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - - "github.com/spf13/cobra" - "github.com/usetero/cli/internal/log" -) - -var digitsOnly = regexp.MustCompile(`^\d+$`) - -var preserveStringByKey = map[string]struct{}{ - "op": {}, - "op_id": {}, - "after": {}, - "next_after": {}, - "last_op_id": {}, - "write_checkpoint": {}, -} - -func newInternalPowerSyncSanitizeFixtureCmd(scope log.Scope) *cobra.Command { - var ( - input string - output string - maxLines int - ) - - cmd := &cobra.Command{ - Use: "sanitize-fixture", - Short: "Sanitize raw PowerSync NDJSON into commit-safe fixture data", - RunE: func(cmd *cobra.Command, _ []string) error { - if input == "" { - return fmt.Errorf("--input is required") - } - if output == "" { - return fmt.Errorf("--output is required") - } - if maxLines < 0 { - return fmt.Errorf("--max-lines must be >= 0") - } - - if !filepath.IsAbs(input) { - input = filepath.Clean(input) - } - if !filepath.IsAbs(output) { - output = filepath.Clean(output) - } - - lines, err := sanitizeFixtureFile(input, output, maxLines) - if err != nil { - return err - } - scope.Info("sanitized powersync fixture", "input", input, "output", output, "lines", lines) - fmt.Printf("Sanitized %d lines\nInput: %s\nOutput: %s\n", lines, input, output) - return nil - }, - } - - cmd.Flags().StringVar(&input, "input", "", "Path to raw NDJSON fixture (required)") - cmd.Flags().StringVar(&output, "output", "", "Path to sanitized NDJSON fixture (required)") - cmd.Flags().IntVar(&maxLines, "max-lines", 0, "Maximum number of lines to sanitize (0 = all)") - - return cmd -} - -func sanitizeFixtureFile(inputPath, outputPath string, maxLines int) (int, error) { - in, err := os.Open(inputPath) - if err != nil { - return 0, fmt.Errorf("open input fixture: %w", err) - } - defer in.Close() - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return 0, fmt.Errorf("create output directory: %w", err) - } - out, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return 0, fmt.Errorf("open output fixture: %w", err) - } - defer out.Close() - - scanner := bufio.NewScanner(in) - scanner.Buffer(make([]byte, 64*1024), 64*1024*1024) - writer := bufio.NewWriter(out) - defer writer.Flush() - - s := newSanitizer() - written := 0 - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - sanitized, err := s.sanitizeLine(line) - if err != nil { - return written, fmt.Errorf("sanitize line %d: %w", written+1, err) - } - if sanitized == "" { - continue - } - if _, err := writer.WriteString(sanitized); err != nil { - return written, fmt.Errorf("write output line %d: %w", written+1, err) - } - if err := writer.WriteByte('\n'); err != nil { - return written, fmt.Errorf("write newline %d: %w", written+1, err) - } - - written++ - if maxLines > 0 && written >= maxLines { - break - } - } - if err := scanner.Err(); err != nil { - return written, fmt.Errorf("read input fixture: %w", err) - } - if err := writer.Flush(); err != nil { - return written, fmt.Errorf("flush output fixture: %w", err) - } - return written, nil -} - -type fixtureSanitizer struct { - cache map[string]string -} - -func newSanitizer() *fixtureSanitizer { - return &fixtureSanitizer{cache: make(map[string]string)} -} - -func (s *fixtureSanitizer) sanitizeLine(line string) (string, error) { - var v any - if err := json.Unmarshal([]byte(line), &v); err != nil { - return "", fmt.Errorf("invalid json: %w", err) - } - if shouldDropReplayLine(v) { - return "", nil - } - clean := s.sanitizeValue("", v) - out, err := json.Marshal(clean) - if err != nil { - return "", fmt.Errorf("marshal sanitized line: %w", err) - } - return string(out), nil -} - -func shouldDropReplayLine(v any) bool { - m, ok := v.(map[string]any) - if !ok { - return false - } - _, hasData := m["data"] - _, hasCheckpoint := m["checkpoint"] - _, hasCheckpointComplete := m["checkpoint_complete"] - return !hasData && !hasCheckpoint && !hasCheckpointComplete -} - -func (s *fixtureSanitizer) sanitizeValue(key string, value any) any { - switch v := value.(type) { - case map[string]any: - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - out := make(map[string]any, len(v)) - for _, k := range keys { - out[k] = s.sanitizeValue(k, v[k]) - } - return out - case []any: - out := make([]any, len(v)) - for i := range v { - out[i] = s.sanitizeValue("", v[i]) - } - return out - case string: - return s.sanitizeString(key, v) - default: - return value - } -} - -func (s *fixtureSanitizer) sanitizeString(key, raw string) string { - if raw == "" { - return raw - } - if _, ok := preserveStringByKey[key]; ok { - return raw - } - if digitsOnly.MatchString(raw) { - return raw - } - - if maybeJSON(raw) { - var nested any - if err := json.Unmarshal([]byte(raw), &nested); err == nil { - nestedClean := s.sanitizeValue("", nested) - if out, err := json.Marshal(nestedClean); err == nil { - return string(out) - } - } - } - - if token, ok := s.cache[raw]; ok { - return token - } - - sum := sha256.Sum256([]byte(raw)) - token := "redacted_" + hex.EncodeToString(sum[:6]) - s.cache[raw] = token - return token -} - -func maybeJSON(s string) bool { - if len(s) < 2 { - return false - } - first := s[0] - last := s[len(s)-1] - return (first == '{' && last == '}') || (first == '[' && last == ']') -} diff --git a/internal/cmd/internal_powersync_sanitize_test.go b/internal/cmd/internal_powersync_sanitize_test.go deleted file mode 100644 index 8d1ef68d..00000000 --- a/internal/cmd/internal_powersync_sanitize_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestSanitizeLine_DeterministicAndSafe(t *testing.T) { - s := newSanitizer() - - line := `{"checkpoint":{"last_op_id":"42","buckets":[{"bucket":"29#account_data[\"acc-123\"]"}]},"data":{"op":"PUT","object_id":"user@example.com","data":"{\"account_id\":\"acc-123\",\"url\":\"https://api.usetero.com/v1\"}"}}` - got1, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - got2, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() second error = %v", err) - } - if got1 != got2 { - t.Fatalf("sanitize output must be deterministic\ngot1=%s\ngot2=%s", got1, got2) - } - - if !strings.Contains(got1, `"last_op_id":"42"`) { - t.Fatalf("expected preserved numeric checkpoint field, got: %s", got1) - } - if strings.Contains(got1, "user@example.com") { - t.Fatalf("expected sensitive value to be redacted, got: %s", got1) - } - if strings.Contains(got1, "api.usetero.com") { - t.Fatalf("expected nested JSON string content to be redacted, got: %s", got1) - } -} - -func TestSanitizeFixtureFile_MaxLines(t *testing.T) { - in := filepath.Join(t.TempDir(), "in.ndjson") - out := filepath.Join(t.TempDir(), "out.ndjson") - - content := strings.Join([]string{ - `{"token_expires_in":3600}`, - `{"checkpoint":{"last_op_id":"0","buckets":[]}}`, - `{"checkpoint":{"last_op_id":"1","buckets":[]}}`, - }, "\n") + "\n" - if err := os.WriteFile(in, []byte(content), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - n, err := sanitizeFixtureFile(in, out, 2) - if err != nil { - t.Fatalf("sanitizeFixtureFile() error = %v", err) - } - if n != 2 { - t.Fatalf("line count = %d, want 2", n) - } - - data, err := os.ReadFile(out) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) != 2 { - t.Fatalf("output lines = %d, want 2", len(lines)) - } -} - -func TestSanitizeLine_DropsNonReplayMessages(t *testing.T) { - s := newSanitizer() - got, err := s.sanitizeLine(`{"token_expires_in":3600}`) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - if got != "" { - t.Fatalf("expected dropped line, got %q", got) - } -} - -func TestSanitizeLine_ArrayAndNestedJSONDeterministic(t *testing.T) { - s := newSanitizer() - - line := `{"data":{"op":"PUT","object_id":"user-123","tags":["env:prod","env:prod","service:payments"],"payload":"{\"owner\":\"user-123\",\"emails\":[\"a@example.com\",\"a@example.com\"],\"tokens\":[\"tok_abcdef\",\"tok_abcdef\"]}"}}` - got, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() error = %v", err) - } - - // Repeated sensitive values should map to the same token. - if strings.Count(got, "redacted_") < 3 { - t.Fatalf("expected multiple redactions, got: %s", got) - } - if strings.Contains(got, "a@example.com") || strings.Contains(got, "tok_abcdef") || strings.Contains(got, "user-123") { - t.Fatalf("expected sensitive values redacted, got: %s", got) - } - - got2, err := s.sanitizeLine(line) - if err != nil { - t.Fatalf("sanitizeLine() second error = %v", err) - } - if got != got2 { - t.Fatalf("non-deterministic output\ngot=%s\ngot2=%s", got, got2) - } -} diff --git a/internal/cmd/internal_powersync_test.go b/internal/cmd/internal_powersync_test.go deleted file mode 100644 index a4378905..00000000 --- a/internal/cmd/internal_powersync_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package cmd - -import ( - "path/filepath" - "testing" -) - -func TestResolveCaptureOutputPath_Absolute(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - abs := "/tmp/capture.ndjson" - - got, err := resolveCaptureOutputPath("prd", abs) - if err != nil { - t.Fatalf("resolveCaptureOutputPath() error = %v", err) - } - if got != abs { - t.Fatalf("path = %q, want %q", got, abs) - } -} - -func TestResolveCaptureOutputPath_Relative(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - got, err := resolveCaptureOutputPath("dev", filepath.Join("fixtures", "capture.ndjson")) - if err != nil { - t.Fatalf("resolveCaptureOutputPath() error = %v", err) - } - - want := filepath.Join(home, ".tero", "environments", "dev", "fixtures", "capture.ndjson") - if got != want { - t.Fatalf("path = %q, want %q", got, want) - } -} diff --git a/internal/cmd/reset.go b/internal/cmd/reset.go index 21a5f1f8..9a755509 100644 --- a/internal/cmd/reset.go +++ b/internal/cmd/reset.go @@ -10,7 +10,6 @@ import ( "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" ) @@ -42,7 +41,6 @@ func listOrgIDs(env string) ([]domain.OrganizationID, error) { func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("reset") - var includeDB bool cmd := &cobra.Command{ Use: "reset", @@ -59,22 +57,6 @@ func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { return fmt.Errorf("failed to list orgs: %w", err) } - // Clear databases and preferences for all orgs - clearedDBs := 0 - if includeDB { - for _, orgID := range orgIDs { - // Clear database - orgCfg, err := config.Load(env, orgID) - if err == nil { - storage := sqlite.NewStorageService(orgCfg) - if err := storage.Clear(); err != nil { - return fmt.Errorf("failed to clear database for org %s: %w", orgID, err) - } - clearedDBs++ - } - } - } - // Clear org preferences for all orgs for _, orgID := range orgIDs { orgCfg, err := config.LoadOrgPreferences(env, orgID) @@ -104,17 +86,11 @@ func NewResetCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { // Print results fmt.Println(s.Success.Render("✓ Reset complete")) - if includeDB { - fmt.Println(s.Help.Render(fmt.Sprintf("Cleared preferences, authentication, and %d database(s) for: %s", clearedDBs, env))) - } else { - fmt.Println(s.Help.Render("Cleared preferences and authentication for: " + env)) - } + fmt.Println(s.Help.Render("Cleared preferences and authentication for: " + env)) return nil }, } - cmd.Flags().BoolVar(&includeDB, "db", false, "Also delete the local SQLite database") - return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fde5ef61..bda1ffc8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -9,9 +9,7 @@ import ( "github.com/usetero/cli/internal/app" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" "github.com/usetero/cli/internal/tea/filter" ) @@ -67,15 +65,10 @@ Just run 'tero' to start an interactive chat session.`, authService := newAuthService(cliConfig, scope) services := newGraphQLServiceSet(cliConfig, authService, scope) - // Create storage for SQLite databases - storage := sqlite.NewStorageService(orgCfg) - - // Create PowerSync syncer - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authService, scope) - - // Create and run the TUI + // Create and run the TUI. The CLI is a thin GraphQL client: there is + // no local database or sync engine. p := tea.NewProgram( - app.New(ctx, cliConfig, theme, version, services, authService, userPrefs, orgPrefs, storage, syncer, scope), + app.New(ctx, cliConfig, theme, version, services, authService, userPrefs, orgPrefs, scope), tea.WithContext(ctx), tea.WithEnvironment(os.Environ()), tea.WithFilter(filter.Mouse), diff --git a/internal/core/bootstrap/event_adapter.go b/internal/core/bootstrap/event_adapter.go index f31b869f..a474ab5e 100644 --- a/internal/core/bootstrap/event_adapter.go +++ b/internal/core/bootstrap/event_adapter.go @@ -40,8 +40,6 @@ func EventFromMessage(msg Message) (Event, bool) { return Event{Kind: EventDatadogDiscoveryDone}, true case WorkspaceSelected: return Event{Kind: EventWorkspaceSelected, Workspace: msg.Workspace}, true - case SyncComplete: - return Event{Kind: EventSyncComplete}, true default: return Event{}, false } diff --git a/internal/core/bootstrap/event_adapter_test.go b/internal/core/bootstrap/event_adapter_test.go index b47f1f01..af651024 100644 --- a/internal/core/bootstrap/event_adapter_test.go +++ b/internal/core/bootstrap/event_adapter_test.go @@ -17,7 +17,7 @@ func TestEventFromMessage(t *testing.T) { {name: "authenticated", msg: Authenticated{}, kind: EventAuthenticated}, {name: "org selected", msg: OrgSelected{}, kind: EventOrgSelected}, {name: "runtime ready", msg: RuntimeReady{}, kind: EventRuntimeReady}, - {name: "sync complete", msg: SyncComplete{}, kind: EventSyncComplete}, + {name: "workspace selected", msg: WorkspaceSelected{}, kind: EventWorkspaceSelected}, } for _, tt := range tests { diff --git a/internal/core/bootstrap/events.go b/internal/core/bootstrap/events.go index ce0c28de..9fcc264f 100644 --- a/internal/core/bootstrap/events.go +++ b/internal/core/bootstrap/events.go @@ -26,7 +26,6 @@ const ( EventDatadogAccountCreated EventKind = "datadog_account_created" EventDatadogDiscoveryDone EventKind = "datadog_discovery_done" EventWorkspaceSelected EventKind = "workspace_selected" - EventSyncComplete EventKind = "sync_complete" ) // Event is the canonical transition input consumed by the bootstrap engine. @@ -112,14 +111,15 @@ func ApplyEvent(state State, event Event) Transition { nextState, next := ApplyDatadogDiscoveryComplete(state) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} case EventWorkspaceSelected: - nextState, next := ApplyWorkspaceSelected(state, event.Workspace) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} - case EventSyncComplete: - completion, ok := CompleteOnboarding(state) + // Workspace selection is the final onboarding step. The control plane + // has no sync to wait for, so completing here drops the agent straight + // into chat. + nextState := ApplyWorkspaceSelected(state, event.Workspace) + completion, ok := CompleteOnboarding(nextState) if !ok { - return Transition{Kind: TransitionNoop, State: state} + return Transition{Kind: TransitionNoop, State: nextState} } - return Transition{Kind: TransitionComplete, State: state, Completion: completion} + return Transition{Kind: TransitionComplete, State: nextState, Completion: completion} default: return Transition{Kind: TransitionNoop, State: state} } diff --git a/internal/core/bootstrap/events_test.go b/internal/core/bootstrap/events_test.go index 9cdcf2e0..4542c149 100644 --- a/internal/core/bootstrap/events_test.go +++ b/internal/core/bootstrap/events_test.go @@ -25,16 +25,16 @@ func TestApplyEventAuthenticated(t *testing.T) { } } -func TestApplyEventSyncComplete(t *testing.T) { +func TestApplyEventWorkspaceSelectedCompletes(t *testing.T) { t.Parallel() + // Workspace selection is the terminal onboarding step (no sync gate). state := State{ - User: &auth.User{ID: "user-1"}, - Org: &domain.Organization{ID: "org-1"}, - Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, } - got := ApplyEvent(state, Event{Kind: EventSyncComplete}) + got := ApplyEvent(state, Event{Kind: EventWorkspaceSelected, Workspace: domain.Workspace{ID: "ws-1"}}) if got.Kind != TransitionComplete { t.Fatalf("kind = %q, want %q", got.Kind, TransitionComplete) } @@ -46,10 +46,10 @@ func TestApplyEventSyncComplete(t *testing.T) { } } -func TestApplyEventSyncCompleteMissingStateNoops(t *testing.T) { +func TestApplyEventWorkspaceSelectedMissingStateNoops(t *testing.T) { t.Parallel() - got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventSyncComplete}) + got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventWorkspaceSelected, Workspace: domain.Workspace{ID: "ws-1"}}) if got.Kind != TransitionNoop { t.Fatalf("kind = %q, want %q", got.Kind, TransitionNoop) } diff --git a/internal/core/bootstrap/gate_requirements.go b/internal/core/bootstrap/gate_requirements.go index c66e5545..cdacf9a9 100644 --- a/internal/core/bootstrap/gate_requirements.go +++ b/internal/core/bootstrap/gate_requirements.go @@ -13,8 +13,6 @@ func RequirementForGate(gate Gate) GateRequirement { return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true, NeedsDDAPIKey: true} case GateDatadogDiscovery: return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true} - case GateSync: - return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true} default: return GateRequirement{} } diff --git a/internal/core/bootstrap/gate_requirements_test.go b/internal/core/bootstrap/gate_requirements_test.go index 2cea9d04..b44b2c40 100644 --- a/internal/core/bootstrap/gate_requirements_test.go +++ b/internal/core/bootstrap/gate_requirements_test.go @@ -15,7 +15,7 @@ func TestRequirementForGate(t *testing.T) { {gate: GateDatadogAPIKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true}}, {gate: GateDatadogAppKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true, NeedsDDAPIKey: true}}, {gate: GateDatadogDiscovery, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true}}, - {gate: GateSync, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true}}, + {gate: GateWorkspaceSelect, want: GateRequirement{NeedsOrg: true, NeedsAccount: true}}, } for _, tc := range cases { diff --git a/internal/core/bootstrap/gates.go b/internal/core/bootstrap/gates.go index 1d385744..be10efa3 100644 --- a/internal/core/bootstrap/gates.go +++ b/internal/core/bootstrap/gates.go @@ -18,7 +18,6 @@ const ( GateDatadogAppKey Gate = "datadog_app_key" GateDatadogDiscovery Gate = "datadog_discovery" GateWorkspaceSelect Gate = "workspace_select" - GateSync Gate = "sync" ) func (g Gate) String() string { return string(g) } diff --git a/internal/core/bootstrap/messages.go b/internal/core/bootstrap/messages.go index 4248666f..940f6ed5 100644 --- a/internal/core/bootstrap/messages.go +++ b/internal/core/bootstrap/messages.go @@ -74,8 +74,6 @@ type WorkspaceSelected struct { Workspace domain.Workspace } -type SyncComplete struct{} - type OnboardingComplete struct { User *auth.User Org domain.Organization @@ -118,5 +116,4 @@ func (DatadogAccountCreated) bootstrapMessage() {} func (DatadogDiscoveryComplete) bootstrapMessage() { } func (WorkspaceSelected) bootstrapMessage() {} -func (SyncComplete) bootstrapMessage() {} func (PreflightResolved) bootstrapMessage() {} diff --git a/internal/core/bootstrap/requirements_test.go b/internal/core/bootstrap/requirements_test.go index d22a85b6..df242e38 100644 --- a/internal/core/bootstrap/requirements_test.go +++ b/internal/core/bootstrap/requirements_test.go @@ -38,8 +38,8 @@ func TestRewindGate(t *testing.T) { want: GateDatadogAPIKey, }, { - name: "sync requirement rewinds to workspace when workspace missing", - target: GateSync, + name: "workspace requirement rewinds to workspace when workspace missing", + target: GateWorkspaceSelect, req: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true}, state: State{Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}}, want: GateWorkspaceSelect, diff --git a/internal/core/bootstrap/state.go b/internal/core/bootstrap/state.go index f81e1422..99969a30 100644 --- a/internal/core/bootstrap/state.go +++ b/internal/core/bootstrap/state.go @@ -116,9 +116,11 @@ func ApplyDatadogDiscoveryComplete(state State) (State, Gate) { return state, GateWorkspaceSelect } -func ApplyWorkspaceSelected(state State, workspace domain.Workspace) (State, Gate) { +// ApplyWorkspaceSelected records the selected workspace. It is the terminal +// onboarding step (there is no sync gate), so it returns only the next state. +func ApplyWorkspaceSelected(state State, workspace domain.Workspace) State { state.Workspace = &workspace - return state, GateSync + return state } func clearAccountScopedState(state State) State { diff --git a/internal/core/bootstrap/transitions_test.go b/internal/core/bootstrap/transitions_test.go index 88cca266..4d8675a7 100644 --- a/internal/core/bootstrap/transitions_test.go +++ b/internal/core/bootstrap/transitions_test.go @@ -52,10 +52,7 @@ func TestApplyWorkspaceSelected(t *testing.T) { t.Parallel() workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - state, next := ApplyWorkspaceSelected(State{}, workspace) - if next != GateSync { - t.Fatalf("next gate = %q, want %q", next, GateSync) - } + state := ApplyWorkspaceSelected(State{}, workspace) if state.Workspace == nil || state.Workspace.ID != workspace.ID { t.Fatalf("workspace not applied: %#v", state.Workspace) } diff --git a/internal/integration/hermetic/uploader_flow_integration_test.go b/internal/integration/hermetic/uploader_flow_integration_test.go deleted file mode 100644 index 0dcacc77..00000000 --- a/internal/integration/hermetic/uploader_flow_integration_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build integration - -package hermetic_test - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - graphql "github.com/usetero/cli/internal/boundary/graphql" - graphapitest "github.com/usetero/cli/internal/boundary/graphql/apitest" - psapitest "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - psdb "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/upload" -) - -func TestIntegration_UploaderHermeticFlow(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup local bucket: %v", err) - } - - convID := uuid.New().String() - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"conversations","id":"`+convID+`","data":{"workspace_id":"ws-1","title":"Integration"}}`) - - var createdConversationID domain.ConversationID - conversations := &graphapitest.MockConversations{ - CreateFunc: func(ctx context.Context, input graphql.CreateConversationInput) (*domain.Conversation, error) { - createdConversationID = domain.ConversationID(input.ID.String()) - return &domain.Conversation{ID: domain.ConversationID(input.ID.String())}, nil - }, - } - - uploader := upload.New( - database, - psapitest.NewMockClient(), - powersynctest.NewMockTokenRefresher("token"), - upload.MutationDeps{ - Conversations: conversations, - Messages: graphapitest.NewMockMessages(), - Services: graphapitest.NewMockAPIServiceServices(), - Policies: graphapitest.NewMockPolicies(), - }, - logtest.NewScope(t), - ) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { - done <- uploader.Run(runCtx) - }() - - select { - case event := <-uploader.Events(): - if _, ok := event.(upload.SyncingEvent); !ok { - t.Fatalf("expected SyncingEvent, got %T", event) - } - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for sync event") - } - - cancel() - if err := <-done; err != nil && err != context.Canceled { - t.Fatalf("uploader returned error: %v", err) - } - - if createdConversationID == "" { - t.Fatal("expected conversation mutation to be called") - } - - queue := psdb.NewCrudQueue(database) - entry, err := queue.GetNextEntry(ctx) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Fatal("expected CRUD queue to be empty after upload") - } -} diff --git a/internal/integration/live/powersync/syncer_live_test.go b/internal/integration/live/powersync/syncer_live_test.go deleted file mode 100644 index 0d4def8d..00000000 --- a/internal/integration/live/powersync/syncer_live_test.go +++ /dev/null @@ -1,152 +0,0 @@ -//go:build integration_live - -package powersynclive_test - -import ( - "context" - "testing" - "time" - - "github.com/usetero/cli/internal/auth" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/keyring" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/workos" -) - -// Live integration tests run against non-production services. -// -// Prerequisites: -// 1. task auth:login -// 2. task run (complete onboarding to set default account) -// 3. task test:integration:live -func TestIntegrationLive_Syncer(t *testing.T) { - cliConfig := config.LoadCLIConfig() - logger := logtest.NewScope(t) - env := cliConfig.Environment() - - t.Logf("Environment: %s", env) - t.Logf("API Endpoint: %s", cliConfig.APIEndpoint) - t.Logf("PowerSync Endpoint: %s", cliConfig.PowerSyncEndpoint) - - storage := keyring.New(env) - oauthProvider := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.ChatEndpoint, cliConfig.PowerSyncEndpoint) - authSvc := auth.NewService(oauthProvider, storage, logger) - - if _, err := authSvc.GetAccessToken(context.Background()); err != nil { - t.Fatalf("failed to get access token: %v (run: task auth:login)", err) - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - t.Fatalf("org preferences not found: %v (run: task run)", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, logger) - accountID := orgPrefs.GetDefaultAccountID() - if accountID == "" { - t.Fatalf("no default account (run: task run)") - } - - services := graphql.NewServiceSet(cliConfig.APIEndpoint+"/graphql", authSvc, logger).WithAccountID(accountID) - - account, err := services.Accounts.Get(context.Background(), accountID) - if err != nil { - t.Fatalf("failed to fetch account: %v", err) - } - if account == nil { - t.Fatalf("account %s not found", accountID) - } - - t.Logf("Account: %s (%s)", account.Name, account.ID) - - t.Run("connects and syncs data", func(t *testing.T) { - database := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authSvc, logtest.NewScope(t)) - - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - - firstSyncDone := make(chan struct{}) - err := syncer.Start(ctx, database, accountID.String(), func() { - close(firstSyncDone) - }) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - lastState := "" - for { - select { - case <-firstSyncDone: - t.Log("first sync completed") - goto done - case <-ticker.C: - state := syncer.State() - summary := summarizeState(state) - if summary != lastState { - t.Logf("State: %s", summary) - if errState, ok := state.(*powersync.Error); ok { - t.Fatalf("sync error: %v", errState.Err) - } - lastState = summary - } - case <-ctx.Done(): - t.Fatalf("timeout waiting for first sync, last state: %s", summarizeState(syncer.State())) - } - } - done: - - buckets, err := countBuckets(database) - if err != nil { - t.Fatalf("countBuckets() error = %v", err) - } - t.Logf("Synced %d buckets", buckets) - - if buckets == 0 { - t.Fatal("no buckets synced") - } - if !syncer.IsReady() { - t.Error("IsReady() should be true after first sync") - } - }) -} - -func countBuckets(db sqlite.DB) (int64, error) { - var count int64 - err := db.QueryRow(context.Background(), "SELECT COUNT(*) FROM ps_buckets").Scan(&count) - return count, err -} - -func summarizeState(state powersync.State) string { - switch s := state.(type) { - case *powersync.Disconnected: - return "disconnected" - case *powersync.Connecting: - return "connecting" - case *powersync.Syncing: - if s.Progress != nil { - return "syncing " + s.Progress.String() - } - return "syncing" - case *powersync.Ready: - return "ready" - case *powersync.Reconnecting: - if s.Degraded { - return "reconnecting (degraded)" - } - return "reconnecting" - case *powersync.Error: - return "error: " + s.Err.Error() - default: - return "unknown" - } -} diff --git a/internal/integration/live/upload/uploader_live_test.go b/internal/integration/live/upload/uploader_live_test.go deleted file mode 100644 index c0dd0f6e..00000000 --- a/internal/integration/live/upload/uploader_live_test.go +++ /dev/null @@ -1,169 +0,0 @@ -//go:build integration_live - -package uploadlive_test - -import ( - "context" - "testing" - "time" - - "github.com/usetero/cli/internal/auth" - graphql "github.com/usetero/cli/internal/boundary/graphql" - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/keyring" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - psdb "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/upload" - "github.com/usetero/cli/internal/workos" -) - -// Live integration tests run against non-production services. -// -// Prerequisites: -// 1. task auth:login -// 2. task run (complete onboarding to set default account/workspace) -// 3. task test:integration:live -func TestIntegrationLive_Upload(t *testing.T) { - ctx := context.Background() - logger := logtest.NewScope(t) - - cliConfig := config.LoadCLIConfig() - env := cliConfig.Environment() - - t.Logf("Environment: %s", env) - t.Logf("API Endpoint: %s", cliConfig.APIEndpoint) - t.Logf("PowerSync Endpoint: %s", cliConfig.PowerSyncEndpoint) - - storage := keyring.New(env) - oauthProvider := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.ChatEndpoint, cliConfig.PowerSyncEndpoint) - authSvc := auth.NewService(oauthProvider, storage, logger) - - if _, err := authSvc.GetAccessToken(ctx); err != nil { - t.Fatalf("failed to get access token: %v (run: task auth:login)", err) - } - - orgCfg, err := config.LoadOrgPreferences(env, config.ActiveOrgID(env)) - if err != nil { - t.Fatalf("org preferences not found: %v (run: task run)", err) - } - orgPrefs := preferences.NewOrgService(orgCfg, logger) - - accountID := orgPrefs.GetDefaultAccountID() - if accountID == "" { - t.Fatalf("no default account (run: task run)") - } - - workspaceID := orgPrefs.GetDefaultWorkspaceID() - if workspaceID == "" { - t.Fatalf("no default workspace (run: task run)") - } - - t.Logf("Account ID: %s", accountID) - t.Logf("Workspace ID: %s", workspaceID) - - services := graphql.NewServiceSet(cliConfig.APIEndpoint+"/graphql", authSvc, logger).WithAccountID(accountID) - - t.Run("mutation round-trip maintains healthy database", func(t *testing.T) { - database := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer(cliConfig.PowerSyncEndpoint, authSvc, logger) - - syncCtx, syncCancel := context.WithTimeout(ctx, 90*time.Second) - defer syncCancel() - - firstSyncDone := make(chan struct{}) - err := syncer.Start(syncCtx, database, accountID.String(), func() { - close(firstSyncDone) - }) - if err != nil { - t.Fatalf("failed to start sync: %v", err) - } - defer syncer.Stop() - - t.Log("waiting for initial sync") - select { - case <-firstSyncDone: - t.Log("initial sync complete") - case <-syncCtx.Done(): - t.Fatalf("timeout waiting for initial sync") - } - - queue := psdb.NewCrudQueue(database) - if err := queue.CheckHealth(ctx); err != nil { - t.Fatalf("database unhealthy before mutation: %v", err) - } - - uploader := upload.New( - database, - psapi.NewClient(cliConfig.PowerSyncEndpoint), - authSvc, - upload.MutationDeps{ - Conversations: services.Conversations, - Messages: services.Messages, - Services: services.Services, - Policies: services.Policies, - }, - logger, - upload.WithBatchCompletedHook(func(hookCtx context.Context) error { - return syncer.NotifyUploadCompleted(hookCtx) - }), - ) - - uploadCtx, uploadCancel := context.WithCancel(ctx) - defer uploadCancel() - - uploadDone := make(chan error, 1) - go func() { - uploadDone <- uploader.Run(uploadCtx) - }() - - t.Log("creating conversation") - convID, err := database.Conversations().Create(ctx, accountID, workspaceID) - if err != nil { - t.Fatalf("failed to create conversation: %v", err) - } - t.Logf("created conversation: %s", convID) - - t.Log("creating message") - msgID, err := database.Messages().CreateUserMessage(ctx, accountID, convID, "Hello from integration live test") - if err != nil { - t.Fatalf("failed to create message: %v", err) - } - t.Logf("created message: %s", msgID) - - t.Log("waiting for CRUD queue to drain") - drainTimeout := time.After(45 * time.Second) - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-drainTimeout: - hasPending, _ := queue.HasPendingUploads(ctx) - t.Fatalf("timeout waiting for CRUD queue to drain (hasPending=%v)", hasPending) - case <-ticker.C: - hasPending, err := queue.HasPendingUploads(ctx) - if err != nil { - t.Fatalf("failed to check pending uploads: %v", err) - } - if !hasPending { - t.Log("CRUD queue drained") - goto drained - } - } - } - drained: - - uploadCancel() - if err := <-uploadDone; err != nil && err != context.Canceled { - t.Fatalf("uploader stopped with error: %v", err) - } - - if err := queue.CheckHealth(ctx); err != nil { - t.Fatalf("database unhealthy after mutation: %v", err) - } - }) -} diff --git a/internal/powersync/db/batch.go b/internal/powersync/db/batch.go deleted file mode 100644 index 38955afd..00000000 --- a/internal/powersync/db/batch.go +++ /dev/null @@ -1,45 +0,0 @@ -package db - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// CompleteBatch finalizes a CRUD upload by atomically: -// 1. Deleting uploaded entries from ps_crud -// 2. Setting target_op to the write checkpoint -// -// This must be called after uploading entries and fetching a write checkpoint -// from the server. The checkpoint signals to sync that uploads are complete. -func CompleteBatch(ctx context.Context, db sqlite.DB, lastEntryID int64, checkpoint string) error { - return db.WithTx(ctx, func(tx *sqlite.Tx) error { - // Delete all uploaded entries - if _, err := tx.Exec(ctx, "DELETE FROM ps_crud WHERE id <= ?", lastEntryID); err != nil { - return fmt.Errorf("delete crud entries: %w", err) - } - - // Set target_op to the checkpoint - // This tells sync_local that we're waiting for this checkpoint from the server - if _, err := tx.Exec(ctx, - "UPDATE ps_buckets SET target_op = CAST(? AS INTEGER) WHERE name = '$local'", - checkpoint, - ); err != nil { - return fmt.Errorf("update target_op: %w", err) - } - - return nil - }) -} - -// GetClientID returns the PowerSync client ID for this database. -// This ID is used when fetching write checkpoints from the server. -func GetClientID(ctx context.Context, db sqlite.DB) (string, error) { - var clientID string - err := db.QueryRow(ctx, "SELECT powersync_client_id()").Scan(&clientID) - if err != nil { - return "", fmt.Errorf("get client id: %w", err) - } - return clientID, nil -} diff --git a/internal/powersync/db/batch_test.go b/internal/powersync/db/batch_test.go deleted file mode 100644 index 20cc0c75..00000000 --- a/internal/powersync/db/batch_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package db_test - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" -) - -func TestCompleteBatch(t *testing.T) { - t.Parallel() - - t.Run("deletes crud entries up to lastEntryID", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Insert some crud entries - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"PUT","type":"messages","id":"msg-3","data":{}}`) - - // Set up $local bucket - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - // Complete batch for entries 1 and 2 - err = db.CompleteBatch(ctx, database, 2, "100") - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - // Verify entries 1 and 2 are deleted, 3 remains - var count int - err = database.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - t.Fatalf("count crud: %v", err) - } - if count != 1 { - t.Errorf("expected 1 remaining entry, got %d", count) - } - - var remainingID int64 - err = database.QueryRow(ctx, "SELECT id FROM ps_crud").Scan(&remainingID) - if err != nil { - t.Fatalf("get remaining: %v", err) - } - if remainingID != 3 { - t.Errorf("expected entry 3 to remain, got %d", remainingID) - } - }) - - t.Run("updates target_op to checkpoint", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - err = db.CompleteBatch(ctx, database, 0, "42") - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - var targetOp int64 - err = database.QueryRow(ctx, "SELECT target_op FROM ps_buckets WHERE name = '$local'").Scan(&targetOp) - if err != nil { - t.Fatalf("get target_op: %v", err) - } - if targetOp != 42 { - t.Errorf("target_op = %d, want 42", targetOp) - } - }) - - t.Run("is atomic - rolls back on error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Insert a crud entry - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - // Don't create $local bucket - update should fail - err := db.CompleteBatch(ctx, database, 1, "100") - - // Should succeed (UPDATE affects 0 rows but doesn't error) - // This is actually fine - if there's no $local bucket, sync hasn't started - if err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - // Crud entry should still be deleted - var count int - err = database.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - t.Fatalf("count crud: %v", err) - } - if count != 0 { - t.Errorf("expected 0 entries, got %d", count) - } - }) -} - -func TestGetClientID(t *testing.T) { - t.Parallel() - - t.Run("returns client ID from powersync extension", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - clientID, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("GetClientID() error = %v", err) - } - - // Client ID should be a non-empty UUID-like string - if clientID == "" { - t.Error("GetClientID() returned empty string") - } - if len(clientID) < 32 { - t.Errorf("GetClientID() = %q, expected UUID-like string", clientID) - } - }) - - t.Run("returns same ID for same database", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - id1, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("first GetClientID() error = %v", err) - } - - id2, err := db.GetClientID(ctx, database) - if err != nil { - t.Fatalf("second GetClientID() error = %v", err) - } - - if id1 != id2 { - t.Errorf("client IDs differ: %q vs %q", id1, id2) - } - }) -} diff --git a/internal/powersync/db/crud.go b/internal/powersync/db/crud.go deleted file mode 100644 index 1e971a9d..00000000 --- a/internal/powersync/db/crud.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package db provides PowerSync local database operations. -package db - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// Op represents a CRUD operation type. -type Op string - -// CRUD operation types. -const ( - OpPut Op = "PUT" // Insert or replace - OpPatch Op = "PATCH" // Update - OpDelete Op = "DELETE" // Delete -) - -// CrudEntry represents a single entry in the ps_crud upload queue. -type CrudEntry struct { - // ID is the auto-incrementing client-side id. - ID int64 - // TxID is the transaction id. All operations in the same transaction share this. - TxID *int64 - // Op is the operation type: PUT, PATCH, or DELETE. - Op Op - // Table is the table name. - Table sqlite.Table - // RowID is the ID of the affected row. - RowID string - // Data contains the row data (for PUT/PATCH operations). - Data map[string]any - // Old contains previous values (for tables with trackPreviousValues enabled). - Old map[string]any - // Metadata contains client-side metadata (if trackMetadata was enabled). - Metadata *string -} - -// crudRow is the raw row from ps_crud. -type crudRow struct { - ID int64 `json:"id"` - TxID *int64 `json:"tx_id"` - Data string `json:"data"` -} - -// crudData is the JSON structure inside the data column. -type crudData struct { - Op string `json:"op"` - Type string `json:"type"` - ID string `json:"id"` - Data map[string]any `json:"data,omitempty"` - Old map[string]any `json:"old,omitempty"` - Metadata *string `json:"metadata,omitempty"` -} - -// CrudQueue provides access to the PowerSync CRUD upload queue. -type CrudQueue struct { - db sqlite.DB -} - -// NewCrudQueue creates a new CRUD queue accessor. -func NewCrudQueue(db sqlite.DB) *CrudQueue { - return &CrudQueue{ - db: db, - } -} - -// HasPendingUploads returns true if there are entries waiting to be uploaded. -func (q *CrudQueue) HasPendingUploads(ctx context.Context) (bool, error) { - var count int64 - err := q.db.QueryRow(ctx, "SELECT COUNT(*) FROM ps_crud").Scan(&count) - if err != nil { - return false, fmt.Errorf("check pending uploads: %w", err) - } - return count > 0, nil -} - -// GetNextEntry returns the next CRUD entry to process, or nil if the queue is empty. -func (q *CrudQueue) GetNextEntry(ctx context.Context) (*CrudEntry, error) { - var row crudRow - err := q.db.QueryRow(ctx, "SELECT id, tx_id, data FROM ps_crud ORDER BY id LIMIT 1").Scan( - &row.ID, &row.TxID, &row.Data, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("get next crud entry: %w", err) - } - - return q.parseEntry(row) -} - -// GetNextTransaction returns all CRUD entries for the next transaction. -// If entries exist without a transaction ID, returns just the first entry. -func (q *CrudQueue) GetNextTransaction(ctx context.Context) ([]CrudEntry, error) { - // First, get the minimum ID to find where to start - first, err := q.GetNextEntry(ctx) - if err != nil || first == nil { - return nil, err - } - - // If no transaction ID, return just this entry - if first.TxID == nil { - return []CrudEntry{*first}, nil - } - - // Get all entries with the same transaction ID - rows, err := q.db.Query(ctx, - "SELECT id, tx_id, data FROM ps_crud WHERE tx_id = ? ORDER BY id", - *first.TxID, - ) - if err != nil { - return nil, fmt.Errorf("get transaction entries: %w", err) - } - defer rows.Close() - - var entries []CrudEntry - for rows.Next() { - var row crudRow - if err := rows.Scan(&row.ID, &row.TxID, &row.Data); err != nil { - return nil, fmt.Errorf("scan crud row: %w", err) - } - entry, err := q.parseEntry(row) - if err != nil { - return nil, err - } - entries = append(entries, *entry) - } - - return entries, rows.Err() -} - -// GetAllEntries returns all pending CRUD entries in order. -func (q *CrudQueue) GetAllEntries(ctx context.Context) ([]*CrudEntry, error) { - rows, err := q.db.Query(ctx, "SELECT id, tx_id, data FROM ps_crud ORDER BY id") - if err != nil { - return nil, fmt.Errorf("query crud entries: %w", err) - } - defer rows.Close() - - var entries []*CrudEntry - for rows.Next() { - var row crudRow - if err := rows.Scan(&row.ID, &row.TxID, &row.Data); err != nil { - return nil, fmt.Errorf("scan crud row: %w", err) - } - entry, err := q.parseEntry(row) - if err != nil { - return nil, err - } - entries = append(entries, entry) - } - - return entries, rows.Err() -} - -// ErrDatabaseCorrupt indicates the database is in an inconsistent state -// from a crash during migration or write. The database should be deleted -// and re-synced from the server. -var ErrDatabaseCorrupt = fmt.Errorf("database corrupt") - -// CheckHealth verifies the database is in a consistent state. -// Returns ErrDatabaseCorrupt if corruption is detected from a crash. -func (q *CrudQueue) CheckHealth(ctx context.Context) error { - // Check 1: ps_tx must have a row with id=1 - // This is required for CRUD operations. Missing if crash during migration. - var txCount int - err := q.db.QueryRow(ctx, "SELECT COUNT(*) FROM ps_tx WHERE id = 1").Scan(&txCount) - if err != nil { - return fmt.Errorf("check ps_tx: %w", err) - } - if txCount == 0 { - return fmt.Errorf("%w: ps_tx missing required row", ErrDatabaseCorrupt) - } - - // Check 2: $local bucket should not be stuck - // Stuck = target_op > last_op with empty ps_crud (crash during upload) - hasPending, err := q.HasPendingUploads(ctx) - if err != nil { - return fmt.Errorf("check pending uploads: %w", err) - } - if !hasPending { - var targetOp, lastOp int64 - err = q.db.QueryRow(ctx, - "SELECT target_op, last_op FROM ps_buckets WHERE name = '$local'", - ).Scan(&targetOp, &lastOp) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("check local bucket: %w", err) - } - if err == nil && targetOp > lastOp { - return fmt.Errorf("%w: $local bucket stuck", ErrDatabaseCorrupt) - } - } - - return nil -} - -// parseEntry converts a raw database row into a CrudEntry. -func (q *CrudQueue) parseEntry(row crudRow) (*CrudEntry, error) { - var data crudData - if err := json.Unmarshal([]byte(row.Data), &data); err != nil { - return nil, fmt.Errorf("parse crud data: %w", err) - } - - return &CrudEntry{ - ID: row.ID, - TxID: row.TxID, - Op: Op(data.Op), - Table: sqlite.Table(data.Type), - RowID: data.ID, - Data: data.Data, - Old: data.Old, - Metadata: data.Metadata, - }, nil -} diff --git a/internal/powersync/db/crud_test.go b/internal/powersync/db/crud_test.go deleted file mode 100644 index 9f02b8ca..00000000 --- a/internal/powersync/db/crud_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package db_test - -import ( - "context" - "errors" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/sqlite" -) - -func TestCrudQueue_GetNextEntry(t *testing.T) { - t.Parallel() - - t.Run("returns nil when queue is empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - if entry != nil { - t.Errorf("GetNextEntry() = %v, want nil", entry) - } - }) - - t.Run("returns entry with parsed data", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{"content":"hello"}}`) - - queue := db.NewCrudQueue(database) - entry, err := queue.GetNextEntry(context.Background()) - if err != nil { - t.Fatalf("GetNextEntry() error = %v", err) - } - - if entry == nil { - t.Fatal("GetNextEntry() = nil, want entry") - } - if entry.ID != 1 { - t.Errorf("entry.ID = %d, want 1", entry.ID) - } - if entry.Op != db.OpPut { - t.Errorf("entry.Op = %q, want PUT", entry.Op) - } - if entry.Table != sqlite.TableMessages { - t.Errorf("entry.Table = %q, want messages", entry.Table) - } - if entry.RowID != "msg-1" { - t.Errorf("entry.RowID = %q, want msg-1", entry.RowID) - } - if entry.Data["content"] != "hello" { - t.Errorf("entry.Data[content] = %v, want hello", entry.Data["content"]) - } - }) - - t.Run("returns entries in order", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"first","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"second","data":{}}`) - - queue := db.NewCrudQueue(database) - - entry, _ := queue.GetNextEntry(context.Background()) - if entry.RowID != "first" { - t.Errorf("first entry.RowID = %q, want first", entry.RowID) - } - }) - - t.Run("returns error on malformed JSON", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `not valid json`) - - queue := db.NewCrudQueue(database) - - _, err := queue.GetNextEntry(context.Background()) - if err == nil { - t.Error("GetNextEntry() should return error for malformed JSON") - } - }) -} - -func TestCrudQueue_GetAllEntries(t *testing.T) { - t.Parallel() - - t.Run("returns empty slice when queue is empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - entries, err := queue.GetAllEntries(context.Background()) - if err != nil { - t.Fatalf("GetAllEntries() error = %v", err) - } - if len(entries) != 0 { - t.Errorf("GetAllEntries() = %d entries, want 0", len(entries)) - } - }) - - t.Run("returns all entries in order", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"first","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PATCH","type":"messages","id":"second","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"DELETE","type":"messages","id":"third","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetAllEntries(context.Background()) - if err != nil { - t.Fatalf("GetAllEntries() error = %v", err) - } - if len(entries) != 3 { - t.Fatalf("GetAllEntries() = %d entries, want 3", len(entries)) - } - - if entries[0].RowID != "first" { - t.Errorf("entries[0].RowID = %q, want first", entries[0].RowID) - } - if entries[1].RowID != "second" { - t.Errorf("entries[1].RowID = %q, want second", entries[1].RowID) - } - if entries[2].RowID != "third" { - t.Errorf("entries[2].RowID = %q, want third", entries[2].RowID) - } - }) -} - -func TestCrudQueue_HasPendingUploads(t *testing.T) { - t.Parallel() - - t.Run("returns false when empty", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - queue := db.NewCrudQueue(database) - - has, err := queue.HasPendingUploads(context.Background()) - if err != nil { - t.Fatalf("HasPendingUploads() error = %v", err) - } - if has { - t.Error("HasPendingUploads() = true, want false") - } - }) - - t.Run("returns true when entries exist", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - queue := db.NewCrudQueue(database) - - has, err := queue.HasPendingUploads(context.Background()) - if err != nil { - t.Fatalf("HasPendingUploads() error = %v", err) - } - if !has { - t.Error("HasPendingUploads() = false, want true") - } - }) -} - -func TestCrudQueue_CheckHealth(t *testing.T) { - t.Parallel() - - t.Run("healthy database passes", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - queue := db.NewCrudQueue(database) - err := queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) - - t.Run("missing ps_tx row returns corrupt error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Delete the required row - _, err := database.Exec(ctx, "DELETE FROM ps_tx") - if err != nil { - t.Fatalf("delete ps_tx: %v", err) - } - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err == nil { - t.Fatal("CheckHealth() should return error") - } - if !errors.Is(err, db.ErrDatabaseCorrupt) { - t.Errorf("CheckHealth() error = %v, want ErrDatabaseCorrupt", err) - } - }) - - t.Run("stuck local bucket returns corrupt error", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket in stuck state (target_op > last_op with empty ps_crud) - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 5)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err == nil { - t.Fatal("CheckHealth() should return error") - } - if !errors.Is(err, db.ErrDatabaseCorrupt) { - t.Errorf("CheckHealth() error = %v, want ErrDatabaseCorrupt", err) - } - }) - - t.Run("local bucket with pending data is healthy", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - // Set up $local bucket with target_op > last_op, but with actual pending data - _, err := database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 5)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - - queue := db.NewCrudQueue(database) - err = queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) - - t.Run("no local bucket is healthy", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - ctx := context.Background() - - queue := db.NewCrudQueue(database) - err := queue.CheckHealth(ctx) - if err != nil { - t.Fatalf("CheckHealth() error = %v", err) - } - }) -} - -func TestCrudQueue_GetNextTransaction(t *testing.T) { - t.Parallel() - - t.Run("returns single entry when no tx_id", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetNextTransaction(context.Background()) - if err != nil { - t.Fatalf("GetNextTransaction() error = %v", err) - } - if len(entries) != 1 { - t.Errorf("GetNextTransaction() returned %d entries, want 1", len(entries)) - } - }) - - t.Run("returns all entries with same tx_id", func(t *testing.T) { - t.Parallel() - - database := dbtest.OpenTestDB(t) - txID := int64(100) - dbtest.InsertCrudEntry(t, database, 1, &txID, `{"op":"PUT","type":"conversations","id":"conv-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 2, &txID, `{"op":"PUT","type":"messages","id":"msg-1","data":{}}`) - dbtest.InsertCrudEntry(t, database, 3, nil, `{"op":"PUT","type":"messages","id":"msg-2","data":{}}`) - - queue := db.NewCrudQueue(database) - - entries, err := queue.GetNextTransaction(context.Background()) - if err != nil { - t.Fatalf("GetNextTransaction() error = %v", err) - } - if len(entries) != 2 { - t.Errorf("GetNextTransaction() returned %d entries, want 2", len(entries)) - } - }) -} diff --git a/internal/powersync/db/dbtest/db.go b/internal/powersync/db/dbtest/db.go deleted file mode 100644 index 7aad353b..00000000 --- a/internal/powersync/db/dbtest/db.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package dbtest provides test utilities for the powersync/db package. -package dbtest - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" - "github.com/usetero/cli/internal/sqlite/sqlitetest" -) - -// OpenTestDB creates a temporary SQLite database with the PowerSync extension -// loaded and schema initialized. Ready for testing. -// The database is automatically closed when the test completes. -func OpenTestDB(t *testing.T) sqlite.DB { - t.Helper() - - // Extension is registered via extension.init() - ctx := context.Background() - db := sqlitetest.OpenBareDB(t) - - if err := extension.ApplySchema(ctx, db); err != nil { - t.Fatalf("ApplySchema() error = %v", err) - } - - return db -} - -// InsertCrudEntry inserts a test entry into the ps_crud table. -func InsertCrudEntry(t *testing.T, db sqlite.DB, id int64, txID *int64, data string) { - t.Helper() - - ctx := context.Background() - _, err := db.Exec(ctx, "INSERT INTO ps_crud (id, tx_id, data) VALUES (?, ?, ?)", id, txID, data) - if err != nil { - t.Fatalf("InsertCrudEntry() error = %v", err) - } -} diff --git a/internal/powersync/doc.go b/internal/powersync/doc.go deleted file mode 100644 index 9fdb5a26..00000000 --- a/internal/powersync/doc.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package powersync provides background sync using PowerSync. -// -// It takes a sqlite.DB, loads the PowerSync extension, and keeps the -// database in sync with the server via HTTP streaming. -// -// You don't query through powersync - just use sqlite directly. -// -// Internal architecture: -// - syncer.go: public API, lifecycle wiring, dependencies -// - syncer_run.go: retry/backoff loop and token refresh paths -// - syncer_stream.go: session and stream processing -// - syncer_instructions.go: extension instruction application -// - syncer_controlplane.go: serialized extension control-plane calls -// - stream_capture.go: optional raw NDJSON stream capture utilities -// -// Optional fixture capture: -// - Use `tero internal powersync capture` to record raw stream fixtures. -// - Use `task internal:powersync:capture` as a wrapper in dev/prd. -// -// Subpackages: -// - api: HTTP client for PowerSync service -// - extension: SQLite extension interface and wire types -// - db: Local database operations (CRUD queue, batch completion) -// -//go:generate go run ./extension/generate -package powersync diff --git a/internal/powersync/extension/controller.go b/internal/powersync/extension/controller.go deleted file mode 100644 index 34b52a65..00000000 --- a/internal/powersync/extension/controller.go +++ /dev/null @@ -1,175 +0,0 @@ -package extension - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/sqlite" -) - -// ControlOp represents an operation for powersync_control. -type ControlOp string - -const ( - // OpStart starts a sync stream. Payload: StartRequest (JSON). - OpStart ControlOp = "start" - // OpStop stops the current sync stream. Payload: none. - OpStop ControlOp = "stop" - // OpLineText forwards a JSON line from the sync service. Payload: string. - OpLineText ControlOp = "line_text" - // OpLineBinary forwards a BSON line from the sync service. Payload: []byte. - OpLineBinary ControlOp = "line_binary" - // OpRefreshedToken notifies that the auth token was refreshed. Payload: none. - OpRefreshedToken ControlOp = "refreshed_token" - // OpCompletedUpload notifies that CRUD upload completed. Payload: none. - OpCompletedUpload ControlOp = "completed_upload" - // OpUpdateSubscriptions updates stream subscriptions. Payload: JSON array. - OpUpdateSubscriptions ControlOp = "update_subscriptions" -) - -// ConnectionEvent represents a connection state change. -type ConnectionEvent string - -const ( - // ConnectionEstablished indicates the sync stream connection was established. - ConnectionEstablished ConnectionEvent = "established" - // ConnectionEnded indicates the sync stream connection ended. - ConnectionEnded ConnectionEvent = "end" -) - -// StartRequest is the payload for OpStart. -type StartRequest struct { - // Parameters are bucket parameters for the sync request. - Parameters map[string]any `json:"parameters,omitempty"` - // Schema defines the tables to sync. - Schema json.RawMessage `json:"schema,omitempty"` - // IncludeDefaults whether to request default streams. - IncludeDefaults bool `json:"include_defaults"` - // ActiveStreams are currently active stream subscriptions. - ActiveStreams []StreamKey `json:"active_streams,omitempty"` -} - -// StreamKey identifies a stream subscription. -type StreamKey struct { - Name string `json:"name"` - Parameters string `json:"parameters,omitempty"` -} - -// Controller wraps the powersync_control SQLite function with type safety. -// It holds a dedicated database connection to ensure all operations use the -// same connection, since the PowerSync extension maintains per-connection state. -type Controller struct { - db sqlite.DB - conn *sql.Conn // dedicated connection for state consistency -} - -// NewController creates a new PowerSync controller. -// Call Close() when done to release the dedicated connection. -func NewController(db sqlite.DB) *Controller { - return &Controller{db: db} -} - -// Close releases the dedicated connection. -func (c *Controller) Close() error { - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// Control sends a control command and returns the resulting instructions. -// The powersync_control function always expects 2 arguments (op, payload). -// All operations use a dedicated connection to ensure state consistency. -func (c *Controller) Control(ctx context.Context, op ControlOp, payload any) ([]Instruction, error) { - // Lazily acquire a dedicated connection on first use. - // This connection is held for the lifetime of the Controller to ensure - // all powersync_control calls see the same extension state. - if c.conn == nil { - conn, err := c.db.Raw().Conn(ctx) - if err != nil { - return nil, fmt.Errorf("acquire connection: %w", err) - } - c.conn = conn - } - - // Determine how to pass the payload to SQLite - var sqlPayload any - if payload == nil { - sqlPayload = nil // SQL NULL - } else { - switch p := payload.(type) { - case string: - sqlPayload = p - case []byte: - sqlPayload = p - default: - // Marshal structs/maps to JSON string - jsonBytes, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) - } - sqlPayload = string(jsonBytes) - } - } - - var result []byte - err := c.conn.QueryRowContext(ctx, "SELECT powersync_control(?, ?)", string(op), sqlPayload).Scan(&result) - if err != nil { - return nil, fmt.Errorf("powersync_control(%s): %w", op, err) - } - - if len(result) == 0 || string(result) == "null" { - return nil, nil - } - - var instructions []Instruction - if err := json.Unmarshal(result, &instructions); err != nil { - // Try single instruction - var single Instruction - if err := json.Unmarshal(result, &single); err != nil { - return nil, fmt.Errorf("unmarshal instructions: %w", err) - } - instructions = []Instruction{single} - } - - return instructions, nil -} - -// Start begins a sync stream with the given parameters. -func (c *Controller) Start(ctx context.Context, req StartRequest) ([]Instruction, error) { - return c.Control(ctx, OpStart, req) -} - -// Stop stops the current sync stream. -func (c *Controller) Stop(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpStop, nil) -} - -// SendTextLine forwards a JSON line from the sync service. -func (c *Controller) SendTextLine(ctx context.Context, line string) ([]Instruction, error) { - return c.Control(ctx, OpLineText, line) -} - -// SendBinaryLine forwards a BSON line from the sync service. -func (c *Controller) SendBinaryLine(ctx context.Context, data []byte) ([]Instruction, error) { - return c.Control(ctx, OpLineBinary, data) -} - -// NotifyConnection notifies of a connection state change. -func (c *Controller) NotifyConnection(ctx context.Context, event ConnectionEvent) ([]Instruction, error) { - return c.Control(ctx, "connection", string(event)) -} - -// NotifyTokenRefreshed notifies that the auth token was refreshed. -func (c *Controller) NotifyTokenRefreshed(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpRefreshedToken, nil) -} - -// NotifyUploadCompleted notifies that CRUD upload completed. -func (c *Controller) NotifyUploadCompleted(ctx context.Context) ([]Instruction, error) { - return c.Control(ctx, OpCompletedUpload, nil) -} diff --git a/internal/powersync/extension/controller_correctness_test.go b/internal/powersync/extension/controller_correctness_test.go deleted file mode 100644 index c2a98a40..00000000 --- a/internal/powersync/extension/controller_correctness_test.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build correctness - -package extension_test - -import ( - "bufio" - "context" - "fmt" - "os" - "strconv" - "strings" - "testing" - - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -const ( - envPowerSyncFixturePath = "TERO_POWERSYNC_FIXTURE_PATH" - envPowerSyncFixtureMaxLines = "TERO_POWERSYNC_FIXTURE_MAX_LINES" - defaultFixturePath = "testdata/dev-sanitized.ndjson" -) - -type replayDigest struct { - BucketCount int64 - SumLastOp int64 - SumTargetOp int64 - MaxLastOp int64 - MaxTargetOp int64 - OplogCount int64 - OplogMaxRow int64 -} - -func TestCorrectness_PowerSync_ControllerFixtureReplayDeterministic(t *testing.T) { - fixturePath := os.Getenv(envPowerSyncFixturePath) - if fixturePath == "" { - fixturePath = defaultFixturePath - } - - maxLines, err := fixtureMaxLinesFromEnv() - if err != nil { - t.Fatalf("invalid %s: %v", envPowerSyncFixtureMaxLines, err) - } - - var baseline replayDigest - var baselineLines int - for i := range 2 { - database := dbtest.OpenTestDB(t) - lines, err := replayFixture(context.Background(), database, fixturePath, maxLines) - if err != nil { - t.Fatalf("replay run %d failed: %v", i+1, err) - } - if lines == 0 { - t.Fatalf("replay run %d applied zero lines", i+1) - } - - digest, err := snapshotDigest(context.Background(), database) - if err != nil { - t.Fatalf("snapshot digest run %d: %v", i+1, err) - } - - if i == 0 { - baseline = digest - baselineLines = lines - continue - } - - if lines != baselineLines { - t.Fatalf("line count mismatch: run1=%d run2=%d", baselineLines, lines) - } - if digest != baseline { - t.Fatalf("digest mismatch:\nrun1=%+v\nrun2=%+v", baseline, digest) - } - } -} - -func replayFixture(ctx context.Context, database sqlite.DB, fixturePath string, maxLines int) (int, error) { - controller := extension.NewController(database) - defer controller.Close() - - if _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}); err != nil { - return 0, fmt.Errorf("start: %w", err) - } - if _, err := controller.NotifyConnection(ctx, extension.ConnectionEstablished); err != nil { - return 0, fmt.Errorf("notify connection established: %w", err) - } - - f, err := os.Open(fixturePath) - if err != nil { - return 0, fmt.Errorf("open fixture: %w", err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 64*1024), 64*1024*1024) - - lineNo := 0 - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - lineNo++ - if _, err := controller.SendTextLine(ctx, line); err != nil { - return lineNo, fmt.Errorf("line %d: %w", lineNo, err) - } - if maxLines > 0 && lineNo >= maxLines { - break - } - } - if err := scanner.Err(); err != nil { - return lineNo, fmt.Errorf("read fixture: %w", err) - } - - if _, err := controller.NotifyConnection(ctx, extension.ConnectionEnded); err != nil && !isNoActiveIterationErr(err) { - return lineNo, fmt.Errorf("notify connection ended: %w", err) - } - return lineNo, nil -} - -func snapshotDigest(ctx context.Context, database sqlite.DB) (replayDigest, error) { - var d replayDigest - if err := database.QueryRow(ctx, ` - SELECT - COUNT(*), - COALESCE(SUM(last_op), 0), - COALESCE(SUM(target_op), 0), - COALESCE(MAX(last_op), 0), - COALESCE(MAX(target_op), 0) - FROM ps_buckets - `).Scan(&d.BucketCount, &d.SumLastOp, &d.SumTargetOp, &d.MaxLastOp, &d.MaxTargetOp); err != nil { - return replayDigest{}, err - } - if err := database.QueryRow(ctx, ` - SELECT - COUNT(*), - COALESCE(MAX(rowid), 0) - FROM ps_oplog - `).Scan(&d.OplogCount, &d.OplogMaxRow); err != nil { - return replayDigest{}, err - } - return d, nil -} - -func fixtureMaxLinesFromEnv() (int, error) { - raw := os.Getenv(envPowerSyncFixtureMaxLines) - if raw == "" { - return 0, nil - } - n, err := strconv.Atoi(raw) - if err != nil { - return 0, err - } - if n <= 0 { - return 0, fmt.Errorf("must be > 0") - } - return n, nil -} - -func isNoActiveIterationErr(err error) bool { - return strings.Contains(err.Error(), "No iteration is active") -} diff --git a/internal/powersync/extension/controller_test.go b/internal/powersync/extension/controller_test.go deleted file mode 100644 index c76c485e..00000000 --- a/internal/powersync/extension/controller_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package extension_test - -import ( - "bufio" - "context" - "os" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestController_Start(t *testing.T) { - t.Parallel() - - t.Run("returns EstablishSyncStream instruction", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - instructions, err := controller.Start(ctx, extension.StartRequest{ - IncludeDefaults: true, - }) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - if len(instructions) == 0 { - t.Fatal("expected at least one instruction") - } - - var found bool - for _, inst := range instructions { - if inst.Type == extension.InstructionEstablishSyncStream { - found = true - if inst.Request == nil { - t.Error("EstablishSyncStream should have a Request") - } - } - } - if !found { - t.Errorf("expected EstablishSyncStream instruction, got: %v", instructionTypes(instructions)) - } - }) -} - -func TestController_Stop(t *testing.T) { - t.Parallel() - - t.Run("stops without error", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - _, err = controller.Stop(ctx) - if err != nil { - t.Errorf("Stop() error = %v", err) - } - }) -} - -func TestController_NotifyConnection(t *testing.T) { - t.Parallel() - - t.Run("accepts established event", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Errorf("NotifyConnection(established) error = %v", err) - } - }) - - t.Run("accepts end event", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyConnection(ctx, extension.ConnectionEnded) - if err != nil { - t.Errorf("NotifyConnection(end) error = %v", err) - } - }) -} - -func TestController_SendTextLine(t *testing.T) { - t.Parallel() - - t.Run("processes checkpoint line", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - _, _ = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - - line := `{"checkpoint":{"last_op_id":"0","buckets":[]}}` - _, err := controller.SendTextLine(ctx, line) - if err != nil { - t.Errorf("SendTextLine() error = %v", err) - } - }) -} - -func TestController_NotifyTokenRefreshed(t *testing.T) { - t.Parallel() - - t.Run("notifies without error", func(t *testing.T) { - t.Parallel() - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - - _, _ = controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - - _, err := controller.NotifyTokenRefreshed(ctx) - if err != nil { - t.Errorf("NotifyTokenRefreshed() error = %v", err) - } - }) -} - -func TestController_NotifyUploadCompleted_AcceptsCompletedBatchProtocol(t *testing.T) { - t.Parallel() - - ctx := context.Background() - database := dbtest.OpenTestDB(t) - controller := extension.NewController(database) - defer controller.Close() - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - // Simulate a completed upload batch that is waiting on server checkpoint 42. - _, err = database.Exec(ctx, "INSERT INTO ps_buckets (name, last_op, target_op) VALUES ('$local', 0, 0)") - if err != nil { - t.Fatalf("setup bucket: %v", err) - } - dbtest.InsertCrudEntry(t, database, 1, nil, `{"op":"PUT","type":"messages","id":"m1","data":{}}`) - if err := db.CompleteBatch(ctx, database, 1, "42"); err != nil { - t.Fatalf("CompleteBatch() error = %v", err) - } - - var beforeLast, beforeTarget int64 - if err := database.QueryRow(ctx, "SELECT last_op, target_op FROM ps_buckets WHERE name = '$local'").Scan(&beforeLast, &beforeTarget); err != nil { - t.Fatalf("read before checkpoint: %v", err) - } - - _, err = controller.NotifyUploadCompleted(ctx) - if err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - - // Feed server checkpoint line acknowledging op 42. - _, err = controller.SendTextLine(ctx, `{"checkpoint":{"last_op_id":"42","buckets":[]}}`) - if err != nil { - t.Fatalf("SendTextLine(checkpoint) error = %v", err) - } - - var afterLast, afterTarget int64 - if err := database.QueryRow(ctx, "SELECT last_op, target_op FROM ps_buckets WHERE name = '$local'").Scan(&afterLast, &afterTarget); err != nil { - t.Fatalf("read after checkpoint: %v", err) - } - - t.Logf("before: last_op=%d target_op=%d after: last_op=%d target_op=%d", beforeLast, beforeTarget, afterLast, afterTarget) - - if afterLast < beforeLast { - t.Fatalf("last_op regressed: before=%d after=%d", beforeLast, afterLast) - } - if afterTarget < beforeTarget { - t.Fatalf("target_op regressed: before=%d after=%d", beforeTarget, afterTarget) - } - // The synthetic checkpoint line above is only validated for protocol acceptance. - // The native extension controls checkpoint application details internally. -} - -func TestController_ReplaysCheckpointFixtureLines(t *testing.T) { - t.Parallel() - - ctx := context.Background() - database := dbtest.OpenTestDB(t) - controller := extension.NewController(database) - defer controller.Close() - - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - path := filepath.Join("testdata", "checkpoint_lines.ndjson") - f, err := os.Open(path) - if err != nil { - t.Fatalf("open fixture %s: %v", path, err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - lineNo := 0 - for scanner.Scan() { - lineNo++ - line := scanner.Text() - if line == "" { - continue - } - if _, err := controller.SendTextLine(ctx, line); err != nil { - t.Fatalf("SendTextLine(line %d) error = %v", lineNo, err) - } - } - if err := scanner.Err(); err != nil { - t.Fatalf("read fixture: %v", err) - } -} - -func instructionTypes(instructions []extension.Instruction) []extension.InstructionType { - types := make([]extension.InstructionType, len(instructions)) - for i, inst := range instructions { - types[i] = inst.Type - } - return types -} - -func TestController_ConnectionStateConsistency(t *testing.T) { - t.Parallel() - - t.Run("maintains state across multiple operations", func(t *testing.T) { - t.Parallel() - - // This test verifies the fix for the "No iteration is active" bug. - // The PowerSync extension maintains per-connection state: - // - Start() begins an iteration on a connection - // - NotifyConnection/SendTextLine must use the SAME connection - // If connection pooling gives us different connections, we get the error. - - ctx := context.Background() - db := dbtest.OpenTestDB(t) - controller := extension.NewController(db) - defer controller.Close() - - // Start an iteration - _, err := controller.Start(ctx, extension.StartRequest{IncludeDefaults: true}) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - // These must use the same connection as Start(), or we get - // "No iteration is active" error - _, err = controller.NotifyConnection(ctx, extension.ConnectionEstablished) - if err != nil { - t.Fatalf("NotifyConnection(established) error = %v", err) - } - - // Send a line - requires iteration to be active - line := `{"token_expires_in":3600}` - _, err = controller.SendTextLine(ctx, line) - if err != nil { - t.Fatalf("SendTextLine() error = %v", err) - } - - // End the connection - _, err = controller.NotifyConnection(ctx, extension.ConnectionEnded) - if err != nil { - t.Fatalf("NotifyConnection(end) error = %v", err) - } - }) -} diff --git a/internal/powersync/extension/extension.go b/internal/powersync/extension/extension.go deleted file mode 100644 index 29e31231..00000000 --- a/internal/powersync/extension/extension.go +++ /dev/null @@ -1,112 +0,0 @@ -package extension - -import ( - "context" - "embed" - "fmt" - "os" - "path/filepath" - "runtime" - - "github.com/usetero/cli/internal/sqlite" -) - -//go:embed extensions/*.dylib extensions/*.so -var embeddedExtensions embed.FS - -//go:embed schema.json -var schemaJSON string - -func init() { - if err := Register(); err != nil { - panic(fmt.Sprintf("powersync extension: %v", err)) - } -} - -// Register configures the SQLite driver to load the PowerSync -// extension on every new connection. This is called automatically via init(). -func Register() error { - extPath, err := Path() - if err != nil { - return fmt.Errorf("get extension path: %w", err) - } - sqlite.SetExtensionPath(extPath) - return nil -} - -// SchemaJSON returns the embedded PowerSync schema. -func SchemaJSON() string { - return schemaJSON -} - -// ApplySchema applies the PowerSync schema to the database. -// This should be called once after opening the database. -func ApplySchema(ctx context.Context, db sqlite.DB) error { - if _, err := db.Exec(ctx, "SELECT powersync_replace_schema(?)", SchemaJSON()); err != nil { - return fmt.Errorf("apply schema: %w", err) - } - return nil -} - -// Path returns the path to the PowerSync extension for the current platform. -// The extension is extracted from the embedded binary to a temporary location. -func Path() (string, error) { - filename, err := extensionFilename() - if err != nil { - return "", err - } - - // Read embedded extension - data, err := embeddedExtensions.ReadFile("extensions/" + filename) - if err != nil { - return "", fmt.Errorf("read embedded extension: %w", err) - } - - // Write to temp directory - // We use a consistent path so we don't create new files on every run - tmpDir := filepath.Join(os.TempDir(), "tero-powersync") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return "", fmt.Errorf("create temp directory: %w", err) - } - - path := filepath.Join(tmpDir, filename) - - // Check if already extracted with correct size - if info, err := os.Stat(path); err == nil && info.Size() == int64(len(data)) { - return path, nil - } - - // Write to temp file, then rename for atomicity - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0o755); err != nil { - return "", fmt.Errorf("write extension: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("rename extension: %w", err) - } - - return path, nil -} - -// extensionFilename returns the platform-specific extension filename. -func extensionFilename() (string, error) { - switch runtime.GOOS { - case "darwin": - switch runtime.GOARCH { - case "arm64": - return "libpowersync_aarch64.macos.dylib", nil - case "amd64": - return "libpowersync_x64.macos.dylib", nil - } - case "linux": - switch runtime.GOARCH { - case "arm64": - return "libpowersync_aarch64.linux.so", nil - case "amd64": - return "libpowersync_x64.linux.so", nil - } - } - return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) -} diff --git a/internal/powersync/extension/extensions/libpowersync_aarch64.linux.so b/internal/powersync/extension/extensions/libpowersync_aarch64.linux.so deleted file mode 100644 index 5efd28f09202e0b13682b27836b54c7f778c246d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 460688 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�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 AjQ{`u diff --git a/internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib b/internal/powersync/extension/extensions/libpowersync_aarch64.macos.dylib deleted file mode 100644 index ce216445f8ee4b1c2ffa20305155a1eca88b6b8e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 367024 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%0DGv}n7QB4GPxP?h+2XdY2Ki?q;5iBW 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

15x{FxbF=wBeBkFy-pB;D#7@*pO5p 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 z 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 z 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 z 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 diff --git a/internal/powersync/extension/extensions/libpowersync_x64.macos.dylib b/internal/powersync/extension/extensions/libpowersync_x64.macos.dylib deleted file mode 100644 index fa830b8ba2fa88faf470b7fad6b7c071f5b2e8cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 399556 zcmb@v3w#vixj(*}Y?2KGCJ1U;Q4@{I7B7jSWGhMrc3}o)fJ#ujK)ew(QK88$!D?W` zCX#U+<#?dwoKr3RsXggwPYqrmAeY^M$$|>uA{Vg`P+`I#1kfa0%>KX6JG03qsHg4c zk8XD6o%iy*@B7@J=bhbl?whU@o6VkRv)PjI8Hi6?lFe3aOUISX_A`9;+HJPN!UFH~ z0#?-f4^@u;XWdh2>_d+W3w^WZ`Fbk$?Ju7O$G?Ib&Hm^K33){ed^gKtVezOt2=J?cZ2nu?&H@l{+g=v z?5l?_;H^gV72D5DtE|sG;R$`r(P*8XnB6l=y8q$+oK|<#KednjqxA>=b^pUW`>b2< z{+t#(=|jyXbRXYecSqJmVd2w{6qY^r%%iiPE}Y{ly$HUHG!p~zton2x-ygx&4Lhz2 z3m<>P_lQ-Q-S>uXiq&6+RfX>3`|D|GUKJKTTGoTb7q8!wR)6s@#P&NDRyM1&?3Pmhv(L^dy`_A1 zX~`{fW|yPrxo01{sqEKJKjoWs(_^zsXZ=X2?~&3cX8CTJmv=|u9V2dfWX_zYXWjJp zBcjRW zP0C76wh3y0dK0QczJzGYG(q`VAJ{Jm#Yu_g(`GDg{L{8n_Vnu{);7;-n)WM z!DgeL`1|;K`DuK?R3UggLr`{#N{6U)N!l}^V*CA)auRI>Ws7crEM#?-lMhzd?7kZ% z^_gZol$0((`CR{Sc#=(4u9uZ(ouV=`T?lTfE4 zL@0LfGsWNmo1{L5u@6d0yZ#M&6V$PQZDKlVu^y5=HKUxyT#s@taCmfJOCZ+SC4Bf~ zG9TC$qUSXQPP=iBN9hWmNX`!&8J!>48t^F5@X0i5u(HAVfo(1MsG48nbJ|fq!MG+r za1=GS1<=za=&6l*%CC9BX{R~~sFNt$k4Q>cdTxl;l&xs@zOh})WV_ZbDp5(B6~Rhl z%k*N1J5hZh+leXB7G?Y*$tEffWv7d(BU@CZZ0uBuh$R-4LfWv*i#lfrAyVt}Y&)tq z`)(4kHQE$sZo80ps}O8R7owZ}--^Nc88)9w2zGJhLxkWwr_Dbgx82CdZ8x8A*zoFh zft%bY*hAQDNvmjPxD_SDdJU*6stbTvqwPMgpn01GRlr{435wtpf)A(L1f_}g<-6~r z(W;PtxS)Dl1Z6DHgsRGW1!W@eB+*2=7{ayadQy_D_})VOR5BB$g3?SlWN#=-%ndP= zB1~a(l$EfkMA$S(O4^7!B=>2F8-F@CBnHpgxg{Y%b5xV^Ai5hRZd{w7-shw#NLX*> zl)ePxi0XJJ9~f=(0dc3b7=r*F*8Mi2-s0_nIN;C`ZQs->f^tSsI4^GJK|3OaVbr--RB%8Z3)#;-#~$T=d7Si>%?NV z8Q3syXD<59!-8SBkPj<2txX^T0pSr>w8pC5;Y*&;`rqfH(O#q^5(79dXi`6P(kyiB z6|y2i#}QH4#4Qd9mV#vqNUl?&`xEYWVP`mS*6!~o1RrzSitW>lo8;=Rd{=_4Nc8^^ zy{ik;02o=4@;o+fY`T%k>Y6x?o$SU$lwcVjOE>D-l6B7=7_ntW>i{fAJRuP4A|eo6 zSl0vk^_ZBfJ^_4R;FRn~EixE%Ly}Fhe@RH+Jxq+hs40S4o-VlB1b0I@Ep2SAK{$~a ztxsD^Y;hQ{%S^0`q>h%*N_KbnMA_Zt$BYi@JWUZSWK5$Y$0kM_X==Dn7qklC8F9Eo za2Hf%98RAFPQtcFtVpq)pAMP^{E~7OsH$I^NC*eY<^!X$ZN95}uuR6D zx|_HvA7iHez4})P5Cd3cNx8GNFt%LxP6dA#)XN1Ggzx@*?v0{4-&t&*DOB(G4JKBx z@vON)SBuIxr!hc32*MMa#yJV|L}J>?=MVBTXKXCC7t%s6z#wcN7&Wmq(foTEfdj?1 z=qso>hTwyD+kHO+66%6-Cs7D?9mLI84|;=?(DmD5eUVUN%q>RGjctBDhp^LL{eRZM!zjhO6 zhzp(;NQr0$OW>Al^j$&>EgA{DaCg-0GrAnfMt=c}DXTF#7x<==T~eGg6P5IPu1&JN zo1^Tt*yX#q@7ax0g0fw3oh|Jr*wH7L+!jlF*sa^FAwjq4c%)uloH|p`228jv$;Ojx z=-|V;L)jfX!sWj-VjzU?UG0Ka9u1!3JW50PK_S=#rO0zvXK^Zjzvw!JH9d-^pqt_Z zFz6=z0CZ1)l0IW2qlWxIGO6IRD; z4yCgy#4T;euZre#OPcbXdwiF2D$nKR+@8n6!9HrxK$yZTjk)3 z#wecT3qZl1yDl*px?p@1JexZAnj(J9<2GuHHmA8&TTm-aL>noYYISj|A`m|gAVzxd z5XZmd5$$7YJjqcp8WGmS;F4SolD)AT;HlU#{zC>TgY?pdeS}DzqOx7A>}24M0P5uK z5IfF@N{9j64euC&cVG-&^A3=NFBlueU}x&wY&LxlH8*Fu0}v0n2dxSQQoSw@CQc$V zNrpfg#0Oirv9!v7dLW?QEV(vIY;l1|mPBT9n_D?r(EQ)&gTdzkC?SFU0Wf|r93`ah zf@NeOWe;Bj&Ykh7BRxt-$3dPGy3qcJKEZ6yckJfFC$2X8@F?wOH=5ZE-6hdiLa6$6W4>hA9@vp*<$R)3bpe@3OroY2=1 z>%3?(DQ2sT*z`>+0T585uV}P8Vx#q1gl#}REJCauNlQ^VEvYHRLuT@;yHQin?%KWw zB3TIw7INkagkWL^NI4!oft}-v6wmECkTZ6DOb9k!Fd7BIO>nT)-MD!QxCzvpT%<_1 zQzgPo+TkQ(S_6TbFL@?|lx{bK=+@#PjGjT;w;wk*Qd0KhZq^5y6UdCyg_xC8d0dAr z$Sa>Iz{jYUTlKZ1ebCksg<$E>m)uNB$0Vl=yGB-LW{BK|xtaV+lddUFoE|uv;D?;$ zr{_iunOnJz+JLfupOs{*+V9Jew4~Q?32c-&Zv%Lp_Ny*5;Z|?7F1-a0V-cy^RK;XYSoLeM@1xJ!112llw@li0wGY;#L6L8yw%@uRhOa&k2wOu zY{4|}n7|-;At?`M7ALfR4F+FS-Cq3d^x`Cbro;(t68BEKSlPAVZkvr;bQbI5QS^?z z)bAQw0A*scT)mmA+KML|rM*+aw^H2jq)w@ejs-MKQoOPV58b)iUOk2*aswOy;YEr?V z2#&wSW>a=^Dg822=gwg{sOTTbEy{HXOS7*9ZovsYb& z6eQ%0FN*uYN1*5K5IRorkmRadEABiDVs{GKx;$DblQv*+L~=J9w?JB>im?X{ucB zQJ>kQR_to6h4Ch-uhZ7@(U1Aym!qK@#(IF-xm>{NEBl0<$DnEWAC~T{h;YFfn7CN7 ztDmUslCCv3y(HAng#K8d0Smq4F4}4=CDz)%Hg3+~b8*wQrZR=Uhc9T&r6s9`KKpmT zBviTyecmMyER{#13kWRs$bh2M*0Gmc z)fOB2hT-(^bz(kP5Nf`<7NbBYoq0=Ej*7u(zfxEn2iM!S{O6ter*Mjj!KM!5DAdU- z;87Z*j;Oa|V6y{I(_c^n9nrMVN*_3o-BQ1F`|lX73Ot^VHX57-LUF&D`U*g-PTN() zyLWRhzk&II;2J&3Dc5$3-XwPmX1Gt}#(n`Ch^zVy>VP!bUqDRr;`$kgM5f=h*v=ror(EgVeSyE2u8q`p%LOSzK z;1)~9f8aC{y}4+H8b|aJvj!Ktr5b@x9mGOu#%85)y(^7ll|D+Kr`p@J;?z@?S!tlv zCW|U{ePk^Ph?Rg8?({~usxH9K2R_R2)ciHcWYkgGn#*Vn(WP2ZOO(+1Ehgd3{VI3A zweH19Sj(t>l5Mm#*)Dw)w7>Th4?v)_QCHI$Q^J|t|Ne~IqjYcVy?I&}Dz9Dc1F3fKBL{6XJ>>W}DU zxL!O1Q=bdBgCF1T;=|P4ZpSoKE^uSs^zQVqgBz{`WGcKHDy(j`ECT}bvCNmIGF|Ij zf-J-6^RDpV>Wl4}pQP{9oZu&Gf6*bVd@#UFq+vJ6_&9O4tE zq5`f#LI5h&r>08v%wlop*Iwl)v?z~q1io#Z;w6FtR}6Yr-X?)*Jvd72Nb!ccwJnp~ z9M#*iu`$rcvx^k(E>ZPmJE0N#pj~;H0WNmHbHANPGJJBhq&Y6!CB=iLB13yfTKx#t5eiL68a{1#oGJkqEz}^H%;9~&zI%-l{4E5G zgSe{yMpt6VCTJ*|qzP1RxKr+tM$JyLuf~f;hZR5FdZsNSO~U3 zMcF5~!opMGpHfj68}JhaY3qmy;l4(nW!^L4uKEM|G2A;U*!O_oBfdeXHcJnpMEc%UaZ~FBO}MsZuZ9YJ?@$wpwah8^VU>I z4(P+!ZA|?kcq~l->JRvBOm7(vT(iapj_Ye+y8Y6-tRfXsOH%w1BbWYKgBJID5LFKL zObK_}duD{YjqI*kAW;t8o0iMexhRIxt-L%-JVdMR1k3IHIDu&=lZlaNZ!bS*J!ZNM zwN7NZO%5J2868tUpbK1z$#&}hQ`( zY|faV#l@IfhdElCqJIEfwt^Km(sHWuGCuFq7xMEWFN{KG;2IBsFY43TA~KYvThY9w zZKc~^(rt93wT!Kwfy+zU^Y*+*#VmAIaUIr!mJiw_IS$Ak!0@I9)}c2+=wdbRw1^jj z>YGRoo4QTIG16_|uMVE)xJB&`{`1j!0wq|HxHLZi??@1qfHp{2)+jZLrC_6 z^{aE!xiv?ImJSY|xHNbKworNsrazSVRm97L}K&Fj5bN4n|`4fNJ`5Qvu*>|#XH z(tayyW5^_Eo}i^IMH3KSZb))(<*I_X5g`<#8$IgXaB?|hI5M$vUdZldXwisrkkaoy zW!Hmb?EP8`{xhRBYWr3#N!T#c5;2?YS*Fn@Ysi{*C-}I8ll5LAV z8bD)xprIq=89tK0Lc_i*)q>m$LbxSUNQFaM=;%;9xQBBOCa!V7@}(AEQK6~ zrZpl9&ylBY3Mot!>H5QR8dQCeeYy7v+;~KJ4(RW*-E1NQpRybd=v&FGaUYhrF^6+E z!v*R7#CN$mqJ~?!gIjah#t$0tD$FlC_gaJTReOWoHwYAVLwPpe$~r!-%&@%6n|yR-7abrk@w;*INJyCItw}Iq1tK`V(g$^r!gr z<#47C^W8c|dB2*91$Y<4XvhGDEo6Ob%)4OW`5xf8iQNeI3}bY^_-N0(;XkkJqWm^4 zcoJ(RDs5s|PYNEnT2Oyyb~YwDNlRM<6)Wn?2KKqQ;6AFTrD5mK4&#C$+^K1ccqOe; zh(?Uvh|{}6zSLm$BJ4rP0D>-}jwIw70CvBi2~jk>fm^f)r5;sUbU``QvCpGy*?A1O zj@Z%wjCDhKxiVytq)o9GXFlLMEo;;5qI+Z6ErK?^3;R5*>@ny?I}dqO-=a>h(t()8 z&Xe9$=*=%Ix`3VcDsWftH9k>JjoF#Khyse>3Fdsjd>AsY%!J}A3-uL8z#WN7w&~@u z%O?G4x{Ma$f&K_CV+>*H6u3F#uo!510V)P#%RIjA4~+8h~*VWx7b$hCdm<-|O9wt&qEJ5Rt7 zPp|>J&g~{12 z{T>t$U(iM^rilq1pUax(_uvU%;(EU~ceA8ymE0fuvS_;3d(?uF7bI=c1+RNZj_)hv zCXBqm{jMSKxedWId&j$Am}iXye8I=MG}OLVaG!w>mRs4tdBQ)%bHNNgf#DQ_0TrP| zSGGw?Bc?VH1WtC5G7X6ja7NC{!tQs*G1pEXBik@Q%xem@Gm zxjQ5ZPn*094o1PX4{mh;wE?}`*3cga1OL^=2+3`T+_=AE-;1Nuft64VFdntWg62kZ zcX(fNjFPy&Gzifq+Fr07W|oV}Tmg`T@I6IVil)oTq=&_(WDE}Tin6>WrHjh^3{klk zlA~xC=y-09tlXQ2l!1I;NPPMisR1X9MCusX0MC8dgWNz8GO010+>ne#Q#R-Bm$eaB zi0<~XhhZ%yite_u$Hu)hV&u4&9Ef<4Z^)b1$aAkX@!an=RqgO{-V5HUsQ)UI`me_% z1^36IvRCFj4I=0LT(s|(l#Rw;Cut8|VV9UGV!SRP5CC7#YZTH0H)QB9!JlUwjsuJ_ zXTbJ=7kaL;6TvnaL7~uO%`p-%al!v$>u^@=*bW9JyV_)Xgq6R>%1=?b{gmhmi|$ig zuo{IlfpIUTaWsS6Z@hUCOkxuz@ln-gOyX;A)mOgs$(rYGfVoj{ZNz}a&8X!citG{5 zeE?P&CiSt*dC$lg(B3I4Ta5uSyoDxol7bR!;7+cZtoA@p(V@gF!TwJ}0>w%Y6uw0kvfJ9^YkS@nj)>Z??L4=BQ1y;^NuqMfxJ+^%@MZJd%3s^b zl@L6Ks2`x*XT;~S2@H~5C&??e94MNNqhJ!CvaU!}%F&_SGB@Cf#b3T`kaRvyMq+bYkI z>z-TqsrBF*<5qf}V+`jZXJN>2{~oGp@Z}=#tUF+Xz^b^tbqwg3YCUTt(U%XIIEVr^ zjZL#9U>(eeGhoQ%7G|I(t`?Neq3$qJ*)XBWk)4fV?BLS=*gATV00%GtxvKxi?r$TA0+p># zVV*F)uWJDg-PQ7^1RHodJVcd;x=>Jg_Nsa+X~=A-Jo~q;>>;ncl0M}7Xz=VQ=s}s` z%9>SB1IinM7qQxE@=u)?|38WBAGvYN3GcL9iZzL>-_0XG|%B zt3uMLja$<)v~&;<7FkdS^&7D=t*@gPZnSZiQ5H-UF$~d;Yy$FmL6dNI`JUre-i@q~ zdC2CQZ#V8kc1cU=c>HZE9S`am2gg~R=a0)L&yD`T%X*)hsI80NmUm z0@yL{g2B@PnMgj%yI40@^6)^ZH_Pf=r>RMkR6oZ@@67UdnQ`gqn%iLvIqVPHOcal?cYOqi5u)d zfBFyNOk5YWng8j2s+H(%ULC@&$>dGkjSJ++A@^)Om5JP#cBP~8=mlIur~$lvxron( zeL3JxV(?t1FKec67fDcjz8b$6jVl@2&74 z4H9G;)~^hr)2_*zTwvssu{@$nN+`nZ?(TPR&PmudpMoRDA&Sg7k(Eym@tkG6N|OEm$O( zOiwT56tX)FNqMH0F@X7~D{7-sXv+{idm6WpI72wCv@$=?^~X{!cutkkNvy zMr>I{vuNK6L3ktQt@>tOicHRQqg1rRLeYaQSEKcPcF;c``2OHr9(R)y!iST=DpI$j zsI7Dqn$Au{^wkk;NeQ=H0*(QC7|o}C%BOzBSAI{6@4ps~u;y0c)u@;dZb?pU#sV2> zxJv|^0f$jOg*~v$qnubnbfQP$(Rs=H_Ak6|jk5bniJW2dBj!V~qHnHD#GX^;$5Apa z)ES}ZRPYO~x{2Xz1%ekxC>gaB?ocYOLopPxhX70fm!(6I#!okt(_hLl5Lq_=Lt^j? zn{59UJ=PMu2=&LB{Jc1Uu33wL-7TgI!MzkSmt0_ck=7R=f^gRZDZtkA0W3PKr&0pe z8;VW=uq*8T{^l-6r_~`_iReR!Gy7NA4TBJcTgic_gy88Uq&xlF`y>LBK6w{k)td#* zix?l0T9Jyq0nV@Xaf36|6yMLO8PF;{IO~G#E}C%JWi{RlILA;bz}Z8mBUCX{X^b9H z!hu7h1DgYPvBM1<$T=;9nlkIgo6lR*bw2Dn8y$*F4Ro>zDDjU zPBez*Tc;4h`Xy;?XrEtG@qNv-t{OR7?05hf=BZClhsE(wBc;X9(+Db1oYzQR`i5#Y2Q@X4i z2b)@7XE$=-m|HH~IgQZ0u8aX3>qJmT;FwLpASZfK!gcnl&3^K@1vgEP!%~K!s}hhN zHw0lV|D_DpaEk+3QYlWu$*mfb08IeNyM4Dd4_R9<5I;j292DDYc;u7w$`^d)S2kNF z<(CYy6%T)a^EC6}sB}E28$aSSjFo}%IM9nt<9P%WO z=>6ruB*eW%`zC}F%ZI?#$^}*I3z}tc3pe6QnHKT7jxcMS)0k8r>l*BX;yY#)NjD7z!yf#Sg>C$CBms zELrOxF_^55q@GRxMXX!W!rx=;p7IWGEF=TZ$&RMZdv_9T!DAr1xAr;^$R7seVAXh> z4FJTVS`KWXcsU)Mg0&8S=@cXN?Ehu2X+MXBGPSTwWg}J&Sco~2w}1^v$Fv|qNf8_T zo}Kvs!+P&umQ(>7hA^I)$+fpDef!I`b z6HaDY=yf$TAYX>4da^NdisP`u0;ZWAZTX=iOf0D~cs!3Kf}e+SP(FkraJ~`1L+}9b z4Rs7H6Xt{P#sn-3VU4$rPHgeqny)fSvjH#_H5*R|>Yd00z{!GM+eMBTtO%MAUqFqq z*tT;PXZUX{tkC4GLmgPIv+8ZRn~S`zF5oZj(W!?UbIPN%^lXwc7QACtEtVEJrQi6j zM6V%8a!eOzfw1ZJO`y*VG5V?hIHlcuB_+;khWZRMep4B5ZXVC zU@QX808mm(VB=1!Be707J!1h1c5pa|9Sp^sAs(|J12v^HGQFp~5y*>!Xuvu`ApH~u zGWK=vrM2Yn-dOet*e&Bemx?M5QglEPo7>4%@5InJL6a_)jL*nm3Z^$w#INmVW63d9 zM2V8NAcI?#0{$_X_{X!@!r2Lp-d(oftY({DNr5eL_#;iXGr9DN)+K1dV(rMt{x+~B zr>5ZcZAim~CIMH0|7)Tb&EE`pX-pu6Pz-*bKlcV!di%Rk_(<~~V2XnOmtydvkkNu8 zJ5C57Y)OX!XLl4^!c`T52b)b6pb1O~G{G}s&mKHtCP^O4LD{PBX*SJv z_(0(NgsLV&-{?tdf1X?A?J82nW5v9n%#@nR zX^rCt^fOs|k-*KzK5Z4aHSMFZM9htd6YC2E_io=%dXTl-*T2qzTb%C;;nY_6P#W^3 zMkHFwKop0v-b*$uH0mrw`yM_E?F9bV1|fWG1Y8y=KnHDvO{|BWg@0Gu#H==9J-d!_ zHX*C6dWWESa_l1K2~{?Hiug6)EK=@s)=UPRJ=P!jmp=gln+FuvK&`Rq2e3)dpZ!0_ zX)Nw{A(9$&9L=93yN}Mz)^FVgT!0Dd42(+LuwcKD-~;-@9oh~f7a_Y{TOH&z#f+%= zzf;4MF@aHchF(ZSvZ7fNY(t-CjnWT&`7hC{(TsZi73WUNrBn@^ zZ7Y3|TUn4u+Bdh-mzaPI7jC5|F)?^3C4A_T@Zq%Zk>u3fFyB+#QX{7Ps{R$$!+6{G zhB+v?0HB19z(7R8losPYU53QTW+8aYUW)0YY?yzlM@Ey&Ej6!TBu&LE?OC=M>7V8X zc|m07B@GH0JTC&aLsqYcw0+j;eQ9n#N(%}erKqd7SwDcNlbM0;qq)a2(D_vfI2Oei zC7pVvaMUJl2~HbLu!}pt>e#{CKjQ-(c4H8~%3-9@C0yA?BEKq;51mNy)Z7NXg_O0i z>5TO%6EliDN=c@t=J{*D%;L$jXz@}k$S3#^qHM(HKk;E}0gLuZ_yFLjz!?vm^9pgV zH&BjHGz@P$7%)>@*z`>nl}jw@R&LdJ(sy#Sn)YatOw;ttR8b09#VqUfe`- zo5Kjdg^bGtob5Ey@i!fwlGuSx+l-A!A}t9`NvfM_x2>K8Bji6i&I-$dom0xMLm(60 zlmWtd%6c0D1f{ z$gKv;#@M2JJ~mgVGD-(W$_59ckC)w;8$$jtyqj7<$Zix>M)SvTwz3;nFMrW)b8myi z&dxt^W4Dh;GKAGSJfUXk<3M35O*C%B5pXP2*=4H(zg=cMrB3K+iZNt0WhI+!s2Y`~ z_ECbTAk@R6%om`0XM(9hVQCaxTj#nlk+~zh%2qG@!KybM#qgZND?RrT@({b|f3U$+ zcx1}5L{|i!GbF;fLvybcgW1{8LwfmH;MbpfFUzvjf4K!nMNI1i1*7zLK(^?R5?=o| z5~ZY&>-cCB&K7iVZ-k)oAVzeCu`)0X|3DsTA{3{FvyaYByEm2(l9d~?=~%e4$g8}N zoo-aKVZd?p=FJvXcBf&|`vE8`v4NO?HuN$f`Y{e8&<(<6io6S*W+I0zLsI?e@NVWz z(%^J=!57zv3?SLHNmf3UT_4Hr&k*}SZdzMew(R<>^yD8-jbK@6lVgwo-@DH|xM zo@A%g-;pd=@Ab7J;(7j)nTaj`pu%JXgLz*kXu{#$%=)uhexfv#7%S1D4^`X~J9ZnOq#`Nymf5a`s-yIv?vvaS$Nzb0gIlNW}I4>VK(tXGX?gbBMw8NQQsbRXV_U}RH} zs2|RKCcg(8^}R%#&2@Te#;4a54(egmKnyS=dJ%;wJrR$O*y#*~64|c1!f_Q5=iYge zl-%ul9HEn@!44j4Gnt1O-u0t6W9}QmywqLfq6s~r%g3Y4qteARM%nHd_h$)8r$;j(UH#Wm3 zExJs{co&^tzT6zrDcC=!L-vq!-O)^%COn7TvI-{%eEswDMhzsy3>$4&Gpy7WI&uv^ z5}nJM0d%5P^g;wR8HpLBOaVB?&? z`5Z3zGTlw^YGuI5uC&sAJdlzntehJ-AFB8@<^iMxoj;VG5QDYiLA=-k2^`I>NetCD za%hlH?xhC#KhR*qf?uE%$jZ7&w7S`_0B^!*a8Nhv31`87U_Cb@v&AO4!pN=@lu1s} z1r-cI2mB=u+ISc-dq4hPq9bY)qX`$K4$>88i_LIe7liSuo&L4%%#26WCE-W=;K7^F?&c<>*RY&fe7 zSH%#VY#vaJTj8-0E7-|m(t$%BVE9SD*+6e$!L?aVOo_sN>`W4z^PL>H zavTPpo-08wjOOS42J&i)|3oVte=p*mZ7SkUZZ6{TY~eH9>jAQsdgYR$g`3{43A!X)$e18HUa6~rL2Dbvt7Y#=u+zq8a zXK)g1X73EvBdt?`E6~d4UV+C0@g$56BDo2` zcM0yAaRiao4YPM!;7NEj<;!6Of~Q)7;VkwJj@DDaC$_8Id(plwwimF?%5oSbLu%t_ zbu+!qf;QtS2sYZI0f?aGZqCoUn_Cj5fDVqM90exy+6jT;Zimn=oDNn1EGoMM2{>rO zqY*M4s8K5|JGU^Owp<$%T0d&I7KT17W`$ao#o0$F&^>Gld(m@{w zy;*y_N*6kGdhK1_tOL|TW={_X`Ryn9tPYP0Mm6egi$OuRJw13>#(~Fv9#Ba3Gm;%^ zHCxPK-FUD=UN~*oY`m^ETw^w{?GwCN5wD%@X*ymzig$aN)elhGyjgoOA1rTv>we-5 z+L*TWv~pOj@@c>ybEVMvX0Nh^-+qj*JWR{$b#15ntkZnBHG$7M<+Zc>@G%Uq1$AiI zy+}+$;Eo<4BarqAn`r!rc5!>zpxpNSJU7eNu;bK1*f@q9IZHGO>Whq^U=_KEyK}2v zA+wCmOS>iYKNs zV)G4`)S67{t|^7?<9k3G)&qLVK~H&1Nb8F>^yp-_ z0_+xkR8jM!AlTfQ#s%L(4Jj6xn6_Bd?t=>g?pzQp9oQD_)Ey? z&-KSJ22QW+H#N_(K$bz;c%*x2=%0_r-D8kWNg?*y7_5`gx^-6CG zp&kdJN0@1FiNkrh7=13&8_{7AAANp%dXoBbm~KS8xPp+Ly{88lAB~WNHj$pt zuiQl0i}8MSDG`7VA>qmZuDbDlG86OYM+iPaC-h>tKOP;8w{Txw+ZXpMk4Mm(piUkJ zC|6L|IMscJiRo7k5_MAvuR!hN`gOXV+A1_Z7vQYzV{w&F>K2gvR# z*@TmhRd(~W-2^w*!KbOeiV(dS4P7GDg3MGFnmMHp ze8yqp!(S&&P(8x|g?AZU5ScEXpgM*lY~DOUm4;&xlF92Wr9LYR75awk&cT1rU{r?Wol*S=_%Ej ztWV@96Rb;3*p_Cn&a`w^$j)M6gnaXk4Xk{SXWp5^evo6{JxpK92gBGMoogq~b#EJj zq!H+wIwFr)pK9u8g5tRi@UipsbPW)z&1M?)H9?VXb52kkw~4{$G7$jab9`kiGeH3& zg81vi=q7%{JRsI2q%naL)KAdX8gx(5Bp0$r4kO{SU9a8W&ALM19}#djxGsP@6qPuS zchtd`L8%Xikupv=9v5Tdp2Aad=3ew@WASY`YjOk5nt(4nh7j-5%7nL&8WKK#d(vc6 z)Ymb6M-g0-&Y2XG+w)f@djRw@cz~Yl!LW@ae6m;dMIV#dV$(_2_-}#)%=UVd+3sgk zDIXTY5Yg4fEo?v`@S`61v52a}lV+li_AHPcd0N;PRZ0c}k(bj2kV#%9s%goHk|qQ3 zyx`g7P4UoAq!qM%OV;hM=I(!_B5+Awlv}@FkX<23~qYvQ~8mArr3a zM0E}r+&u8NeBfdq_D1Yd>BZuRO_}_SFrU2FG5%(Su7nP=XatZr!z?(kv`qK3;& z5id6UM4E#=lEV=tdyKa^%hzuvrsSCs2Kf-(U~@68?f4nd3MRW}u$H2ny#aZ0qDkM*GUN~%u`gJ%D7HjRPf**Ky? zHo)UVm!?|u9zBtj?_?Lw&rcy5g<8aFlUhUu0Np_A&n7_tB?j?(%>&+tWFbb1N(B%d zZgx4Mf3}OEMu=a_v`M5H!PlLMX3NErZF#7&7~B}bZmp9&FPRp})BlJzq9#MS$n~OY zE93my3yEk&2kk3r#V8J%v=d)Nu8F=5yCj!&1rHno(#7;-A)EUbJwLg*|pb>S!6)(B*#=z`c7ouaoYb5|WLOKQyHcCAtLN zKvpMq%IZ`wi3g#hBNItd9&*DOg|x4YEm6g* z#}qk$Z(UTUfD$b_whojowGja`0CAj#V3Y$e3u>TNcY-Dvm8+JPQ;yb?b<}{(wl-}q zAv!h0C_?c0Ozh{_m?om(859p_A|LVV1K7Ta+RzaYXtgkSF3>$?5WCKiCIT|jui8m? zP1YO)GSfuJ{DCn<%lal>7J?ZO)f@w95Q6o{iPnM0>L9AQ>Y_&*i?6`R*EntDv$~7X zwT1y|iW5RJ7@mZ#)fWEM6Q)@T$$r}Hc(b(t2NNjM2^tA&3XPb(wiAjlaI=;~4(q05 zBM|llL>Uz93B-t?S*&&vxE^OF=-mr+9!QpLCd^p)F@%zx8G6bNQbfoKu+-&&cqKE~ zqC~v0fOsYy^Z_FkwF)P+kO(dcQh-8~xykHI?h!0($+YG?=n!@MIzsx1n&W18HH-7; z9PMO4?9Kd(w^`8i}o7h{nZKZduMQ3}JC@^_j&$4)h6I_byZ+HG`H8`6Z^U zTTiww;fq(d``g7B!i-d6XiU3QkYEKuot*(Bd7bU9=D5maY4>6rS)D`r#~buSc74e$bQ7O5 z`540~=pXDMwiWA)cc?)%B|%j5ENr&?jF{+bfmq&O?&9Xy3y4 z2RI&4HL((y@r;d##6}jwngbj86&L}%$%M4MrRgTnsEJc+XjJks1iK$BqF_KKWIwnF zaTKzzO4}xCBOp!v%SayptAr}GKTo#n%p-T zST9OsOkO(Vy{GOblL_OX(>3Bnn+6q*Xh5Q*W_wO zrfpRRfrqzC!@a_E1GrbdBKJx!6@u2OTZLdZ|AGoZuBm^)mw2ZIaQDG*IJ&V>A-&Gk$_2q3#Ox|1Lqerl2NCE9?Bp?p9cvsef$OTSTKJC zi4RiVp)#vbZRmY1$^=v!ql>|cOzJOTjMXh-dnF*Z{+SR6^7RVXXVZJo_9Edr@6UZ%CvBv9A347MAKL=U zCye`Ax}T_dZnPxpqihBvS+*dlYEbpC3;qyER;o&_F3Zq|pKKu&nOYzi9aa^~2BFGy zJ4>3HyorfZU`Cuo{Qwyn*wlKGs#r6$B;GWw22#{PGSvZ@3MOY7(hzVOUi1tpNOIKy zmdR|45AUZw;7L89{|4MW_EtD7dsil#*ZW^Ggnce7y;z=IJ&BB=oFdiit5 zU?=$+vniA{zKryksO!#5vGk%$-~Hx+UZ2=vJ>CgGIdq?51&pxrN2JfD{XXodBxy`8X@_9nM$FWs97CuD$lt8r-o&^NA|iDZ5CM@#u#tFu z%Gwt5zM>W$W_Tx$>HSbu)E3x9@?BSiSa;a5S=i~Pvw{4UMb@g%I2y%EDc(9CLWdaG zd=jDUXa`yFXbAT-PIJzPz&(AZiEl8hmW%3bqWU}(XuN5*#W4v!pGj&#GC5Y0;b8@5 zQL9*@IU+jj8`#qstQ6dg3UaNW@SP$VKOz}Fvl(}OkpMB>1s~ET$nZjf z*$`tsLid=RVQ`z_sW}p7ms3$IqxSJ z^T9VSCV6w$KL#uyc}bm2+U&ov&C?ussSFwD?-HOY^QOH)Ph@&&!cttYt>(?WCT*5I z)G7dGC%kD6Q5|af&L(OvpD_0n0Wehr!H&c}LdQqGdThzFV@p01OHTf_?j?VjExEEi ze8?dy*{QNpkm`glh&lvIsFk(1w$px#iYL1hZ^i*fiLj`jj2PV(W>p{&=d8{nH^LO! ze0aK;p1LyQ>S??7gj25Bj z-+t7KcN6vA^mTYjn@+5u*QUp5iYNXEczcEHV!^l-8%!Wo%dupIv%j9=g-0l}#KXCWh#-Xuy?jY}JDbMlx&CEHCyU@s0#LGfWJ(t)rWsnY1p=rPc zTgXaIB`ymqnR!tp#VG_Ib^=$YSlPrFY#IimCEuU}bM&|0#S6dT$U}0;a*BZB_`&ic z^gUyvetWDYITiKi<7z&PL8vCJHr4EJ)zqPYBFWp#jN3p+rhBYr@VI4C6CzY|fH^rysfsZFLlnOxL;-*rGKKJ#oE%tI*p~HgnCpRaE_h9d zU*c9Rq6J{+H5NIYMan96U(yDg8j}vAc_c1y+zH2Wl!B4009P+Jk@kl?Q9*G-6cRS{ zjPZaq#s;!v_5Z2+@vZ-*Rg(;&*lz%UAaRQEdT=|u(Qy$W3H|sB-#9KE2-cy$43_|i zE<;qS_7G}Gv5@-WuW)C4L7W*`5iG;DJJoW-Jc16FC>(P!kLB$|3*TYJ5FxTWqcBO@g>!rW_FmYY=e?q}1Bpx^B zm{71?A;L9xg$UqBc7-H|I*{A}IK)s&nHK@(IY9Q1nODRi&3rX=wT567IL~%rJ?Xjh z51zvrtuoGo5SwE?y+SM5t2d^M_U?_AXAR-w5A+sY64zUAxcVz`J_C|4wI zmPAZzh+=;Lt|xl+MmW}MmV|A6dLwjPLpc8fz5NWmLHnkJU%Wb+qK%8Gx}aRna=P#! z`2QGnkd>!2wf^B%b5$r$zUx2YaB~WKV;$bsQ0~Yz)>8E5VY%SP)xsm zq)bJxbd-Vd1pR>@>t-vxtyD2Jow#oP&Q5<_)XiY48}>t0F@eOqJ3WU~ky?F0(0+qA zfOXBi9(o7)RG2b$gscYkdtjYHM>9Fypw#KM4`B)5!DMB`sENu$qX6>7KeZP-0Q*n& z{nncQtM92;Qk)|}zxY|034nPk&ViIP!CTrUl$^yE{3vIIj_s6=3nwbH9DOo%iYj*lpu6w*>YiGORI8V&=dk)}jH~tc z-}SA9$TAyod0eekR4dq(*87#5{pppQkFpMsnjcLovkXour>Tzr@m4qf_@hBT{;2)8 zKMKbFAAc0WqnI`uOEiH-d!7tTNxef-pG_xc5{V0PCQ*zJDqHfCM7D4q;THoJtXor z#CMJu{t6i#hxI$qmh9aqj_I0t2)Ki>kng1M3Ue-!`@0NSG&WySN(A5xwBGKw^Yon} z9F%Z%D-j%vF@JnbQg4>@VS!`I37i|3l*iiYw|3BEk2v31W$Xb0Y$~&a5 z$l5IUftzUE$pBtBmi#{J%~hcivg2n1R3tOvsPLxm%5lzITHZw(y%=$<15(-f(1f>~7X)juM-U&eWGXD>Yee2f&(88ROeyiFtI}}=!_mg)A}A>Lf(sM0`-THYn|SK!FVSDXmWEeY3ni3vxf(MOwHe*Jg+sk_e-YzcKfYV(Y*yxO5tYYt zFh=2)Egw@0TKnN;ld;_*b#@v~I4Ey2uRGrBqr4iuv6vTaskWU?-3G6|#fQLeU zUf{%NlsR#^97+sx!~kz<&GR5KVhk$lh3RiEq`-^HB7ZfcxUn}=k$;bJ7PWcEN^ASKUS3Kpi=v&xIde)EE(vhyNte7971Nhv6GJ-H=D8^}E@qu<-D9IRuaO0<=N= z?^Q;B!HVSxrFZqjndrO-JIq1J`8do`K|!YTNTsFZ78>!jtz^I*q?8Br?L=Q@?5|m` zsiY@+F~(vL^pkOTP3g3sseP5?%HuVRYFKdRO(r4X@`s|#E)i7|HOXOZ7ri2{?{*;) z7Td06v|ZE;+qK-dxLP)~)qC4NZE-n*_jx*I-NP-v|LlTM*>vQlrjvfp#{?0H2 z0O?OG=s}RmO|{5aQ*ALdVsLCfz=&;h~_ z%L1!G_6BxLhU%qK5p6ENo@6!7U5!M_K3OcVzf#ElA}b1Afpogl_G^hjfYL*<^0nCU znQZ@v;>r5%|6x0Yt*6K^C=G7eMCuhWyu>-IE2Q+JjGRqUA`r0zNppg>gjE7>MT~Em z6*k`GrX6Wv8RAH7XS~FM1lYrr2LWgR zL1J8{-a=XmUg5OO=Liagtl|HN;bk)4fH6l&+W^KKHM8}fse%0|xmp;e#M1TmBmaUS z5T!}#vq!RA{Z~!(q#|JE>MtYQAq58!E5e<{eHaXRT=WDC+=DyUqa&6)cT^ue_{-uraj?w7p5>M}Ft>>g z2eNGf|7P3MY;6~iek^vLBaOmFonre%)^1^hD0c(7c`6w zW*p>1gaUb^j`HhF(>U&M;7Qt}5Qr*9)#ES*lOg6w67(KB9QX{F5@ZX@ceTpMOs5er zR5YScJN_+ckY>1Q~uMgioBc?uM- zG(j-1L;nOn5;H-2=uW#aKysfizlzH0Q6?(Mcx62dUi_?0kFRXQD_BtdXaDM8CGd9k z9qc}o{nTK8t@`nq5kM5~ zBxCI-2HO!dXU5)5H7FKHRo`zU1Hx#H6(CX?e8LGfFy4BXXpc%p1fQR8nMAf1^S$#Giubvk0gY#?Ic`paIXg}H`pAHs7tTjnJFs7;wa)ohTC?|+p3;}X0S7FA68jA74&;5R?`NA)EF^*d5*>@$ zG&r4Je;8na*R5v;3DP9EMOV_O=341O2)xreNJ<$K6L6S0v=4$^FJ=E<37SQkD&BBh)sBKL} zCq1bfHRwM+m17}l17PS*M8?w~|H#}2bW`w|gDCpL01^11%ozqcAzY24lbCheheTlb zgGA0-6ao$5uMCyMl18}qU!dcW%si~?K4w80ArAV1Y%mah=@P%0rBe#9{(Iz6@S69B zC}4s~0kqvc6tH?nZwg?jZylGh{2at1ViZ7+<0(K=^Z!o@xQ4LFq<~?BIQUhU?p?iz z0`PlqL;-luo<#xI|0}I63b-Cpgp^BA01R>{;_8TOmM1tGof7uu9<3zrodK;r*+vAK?MR`?XX9Xhe9=sFJPF zI&+173hxouWqAKRe4)Vm5b!?II=u()|KT3+TQ0gtJ{5-g=%*smn%;x@7WBWxFvcYo zIapz3%@V<`JW=@qZy*?K z*}E)|L`TH5_Xi7v36_2QFj~-b5YtarklH)dgvwxh(bFZJ7(4+!mTz?p`ckmhS(!YBy|oWB$_u9B}_&7|5$qy_$aIE|38xih#*cB(V*5w zMWwYWs3^!{B19&dC@v_VEQ+887le$WG&q<{n_)UFt=gh3EwF258&w@6XFP&-2{v+;h)4_uO;OJvW$2(4^ZMkJglb)&Iaq zmx+W-{gH;Y7o?P zHH??dMJJ#z%Sg4Fj`jciiA)*OnKHx_TTK~RHP^Is)LbooP0h6r+o-wnd|}HCg|a+| zD{@Ax?VGLtJ>G8}wHIk%f-LXmKOMyG7DmB~XDS3e8> z6iNN)8QWwa&PZ~HNV6B3yF+BrDue7pRcD%a^Lgc}BJ)c1&NQvKQOtM5MBqXXX`VL{ z=1@_o6C1JQPVtvuhS6lH1=$zgE+V~Vyyx*|)rxA0{~f;AWV%g7!!9qV=;k%9B8ya2 z(Bgp@Px4y4F31?^4mTE0d3cIH`&!q8fe?{_a9)}B@}(l81?cJo!FQlsb$4+dsQU}l z)PhQHv`K^opHDy!$Uh_&pRJO}}Ecbrlx;-e@?U#aX zJD8M5A=^PsULMhVt{y@R2v%<=<;$XP*B}p0ijhrgFSY(|VI8u4l>T;Z0j643e;rCE z&MU}x#n)zRx~w4MZ#REkjUP1@WIQv-D33zA{Dn?{DcJ6AM(%4A0FYjekweg+God?l z&3=Rzh80pxYY9g@u55L$k~F7l+!vAj>R#-|_Ak1SV05YIChw?gWj3OQH6`#^QTiT3 zWnpuTduwTTxYaU)drDU|4p99;ZRwLN;TB5AqQeqna2mx+$pN$tEHwSX`9aeUUgMfp zs72jz5>$we-v7=_o(X`Ler0Qz;K|*DhJl403#IqnroruX{+dkfM+Xo@+SPY;Kn_+8 z^sU>Zy~b|~55pqK%gQ1%R&#L^r>l&_z`eg`zY^GslufHu5tqiMMtxDnU0i*qdgbcH zKHI=XM>}T*$oSLG95RA2&B4G>R7UVDQ~Q0?KIa3^C`;p`%bp1rs0|?wncW^q^$4_7 z)B4P~Q~h^lS@qrCc&q*)p_)ziruFiuUQJ-HRR5ltEbo&g{c5J$$R49MP(6sI!|~hb zm7$xXYfFvD8~NT537_zB8Z#+FVS@u?e$8M8$GR;BpxZ-#bFMJc zf5q$`Mc#&=W(mZ^6g17 zJ=N-5^%k^{E+%&=9o>hr9*Z3litdV%s2SgR`S#q+*Io3nmCx`AV!D!-@V)V8d28Oh zFBb@)k2G&SnColshi~3|X1FC~UuK0{ZiF?PH(wlXdCwji!!7sP!-8=9c=A+lsT&vx zw_HMnwcTR}4no)LFj!_{F|J%)RTkp52n?fKaeznMdB4L$H{~LhB-rN&Vndyb06n{$ zD?tB=Iy2EI;2zcD^*^Q*p!FpL06o-$N%j(-$P3X_lq=GG?4U^VIpx&M-889{Mda|F zJC&hK#`R2`o-%%99}Bnq)s-vf1760{g7(-G^b!c6@V(2_Sa6AW^B22;2o*?KjF{xH zA19ChSEBwlc2=_&cer3ziIkMgaLX#;?~C2TEvHi~eavc$h(+{rQ|IaL`2wV%wwe`G zxZT?B>f5?Z@T2f0$&$J*#7pA%k_0XRu1CpCq%AehiN4)A$rs)Vg?sfD*3>IHa|hso z6|(@QblQ{KNP@*gf(uCkyGt^q#Rv7l!NsoiL_DI#Qzs~4t7hH46^Ggz2BT*hDWY!9 zCnvh*h&u-JK)|b%Svb8jHfVyi5M7Z(e7vhpKgXxOUrCJP4M}S$X)PtjeVjPyUsz!_ zTh;f7$~ocR{b*g{jw;G_j=Mg%uL!*82@Q;dvw0hSX1M8t9d zuVIK&8{E+*DV@4Ugy!`fEOyg(*zLPC8e4gVy~Gt1oESsOMSgD%w!X28Z&uFg*q0F2 zS4=KYsB$NZlhA1Da2^wJ4L-}s825;v1&Ki88blloB%JC-SY(W7zkp`FIUjNpVe$@< z-?-LuM-?Tlf}~Y8CNXwYXiTDRR4LBPqfq2sO-ZZi1QN?nAdx$2u&kMdoa17ZK{x%~ zsnPJ2KO*mPAvRko z++D$1^3=5ZyCV=)qAqD6t5`8=As$<8jKP%$#(iG;C&gTLCaiGHe@vk2lxUuC%%&gO@~HgTb3y_th-oalMtV zNpSyf4%F%`V+TgW3crBF{Z0cf7`N0gRgs8Kv!B#?uEHw!@e+PqyZwW9C%S~7T@4}$ zYS*M*!nHfIEj=0j>B~-ahmIeDIO8L*TF!;bW&SMy83^EXzu*BJ7EztIDpd@9h}4_% z&?_SIj{mXHo9)aK;#VTew7lBLIuu(PyC5{HyGR=&@=my%4C&E&-Hm)M&2g)zotE@| z0Jek_tV9GAz^+0+MDLl(_|zV2YTvDKR;CC1b|MiFqt03jjj$=#lO?vM>bxa(ARQxX zxGPwU{hqf!n_2$Jo2lIiPLdGYmeiXh0T{78?svVr*)8^EY0PD<5WMY$@2d4_cC9cH znUGCXm#q%%=4;SQ#nd`Ce6Q5nT`y--sP84z5y@PFf zbNh@_{1Gz*#)2|$%?wHPg0y$u=abUT-ZrhPJybMn$qm5rzx>@5qNvVE((4K6z3(Kcumz)ZbAwTy`-XDjWWN*YEF{aGvl`;unL|B)x4*X!BlFaHq zSMIW)9OT3DO@?>|emu{!o?jszyL9>X7uWk0_?G*1Q zEDm9QUGc@wZm0O7!s4#`Lv*7R6W

~@@?|=*(2mywE=VkiImqd1lwE40KjN&1%Mqe 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 z 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¨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 HXxIM*_J6@} diff --git a/internal/powersync/extension/generate/main.go b/internal/powersync/extension/generate/main.go deleted file mode 100644 index 0cbf35ee..00000000 --- a/internal/powersync/extension/generate/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package main generates the PowerSync schema from the PowerSync service. -// -// It fetches the schema from the admin API and writes schema.json for embedding. -// -// Usage: -// -// doppler run -- go generate ./internal/powersync -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/config" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - cfg := config.LoadCLIConfig() - endpoint := cfg.PowerSyncEndpoint - token := os.Getenv("POWERSYNC_API_TOKEN") - - if token == "" { - return fmt.Errorf("POWERSYNC_API_TOKEN is required\nUse: doppler run -- go generate ./internal/powersync") - } - - ctx := context.Background() - - fmt.Println("Fetching schema from PowerSync service...") - - schemaJSON, err := psapi.FetchSchemaJSON(ctx, endpoint, token) - if err != nil { - return fmt.Errorf("fetch schema: %w", err) - } - - // Write schema.json to the extension directory (where it's embedded from) - outputDir := mustGetwd() - schemaPath := filepath.Join(outputDir, "extension", "schema.json") - if err := os.WriteFile(schemaPath, []byte(schemaJSON), 0o644); err != nil { - return fmt.Errorf("write schema.json: %w", err) - } - - fmt.Printf("Wrote %s\n", schemaPath) - return nil -} - -func mustGetwd() string { - dir, err := os.Getwd() - if err != nil { - panic(err) - } - return dir -} diff --git a/internal/powersync/extension/protocol.go b/internal/powersync/extension/protocol.go deleted file mode 100644 index 9700fa80..00000000 --- a/internal/powersync/extension/protocol.go +++ /dev/null @@ -1,179 +0,0 @@ -// Package extension provides the interface to the PowerSync SQLite extension. -package extension - -import ( - "bytes" - "encoding/json" - - psapi "github.com/usetero/cli/internal/boundary/powersync" -) - -// InstructionType represents a PowerSync instruction type. -type InstructionType string - -// InstructionType constants for Instruction.Type. -const ( - InstructionEstablishSyncStream InstructionType = "EstablishSyncStream" - InstructionFetchCredentials InstructionType = "FetchCredentials" - InstructionCloseSyncStream InstructionType = "CloseSyncStream" - InstructionFlushFileSystem InstructionType = "FlushFileSystem" - InstructionDidCompleteSync InstructionType = "DidCompleteSync" - InstructionUpdateSyncStatus InstructionType = "UpdateSyncStatus" - InstructionLogLine InstructionType = "LogLine" -) - -// Instruction is a command returned by powersync_control. -// The extension returns instructions as tagged enums: {"InstructionType": {fields...}} -type Instruction struct { - Type InstructionType - // Fields vary by type - Request *psapi.SyncStreamRequest - DidExpire *bool - HideDisconnect *bool - SyncStatus *SyncStatus - Severity string - Line string -} - -// unmarshalStrict unmarshals JSON with DisallowUnknownFields to catch payload mismatches. -func unmarshalStrict(data []byte, v any) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - return dec.Decode(v) -} - -// UnmarshalJSON handles the serde-style tagged enum format from the extension. -// Example: {"EstablishSyncStream": {"request": {...}}} -func (i *Instruction) UnmarshalJSON(data []byte) error { - // Parse as map to get the variant name (first key) - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - // Should have exactly one key - the instruction type - for typ, payload := range raw { - i.Type = InstructionType(typ) - - // Parse the payload based on type using strict unmarshaling - switch i.Type { - case InstructionEstablishSyncStream: - var p struct { - Request *psapi.SyncStreamRequest `json:"request"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.Request = p.Request - - case InstructionFetchCredentials: - var p struct { - DidExpire *bool `json:"did_expire"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.DidExpire = p.DidExpire - - case InstructionCloseSyncStream: - var p struct { - HideDisconnect *bool `json:"hide_disconnect"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.HideDisconnect = p.HideDisconnect - - case InstructionUpdateSyncStatus: - // Payload is wrapped: {"status": {...}} - var wrapper struct { - Status SyncStatus `json:"status"` - } - if err := unmarshalStrict(payload, &wrapper); err != nil { - return err - } - i.SyncStatus = &wrapper.Status - - case InstructionLogLine: - var p struct { - Severity string `json:"severity"` - Line string `json:"line"` - } - if err := unmarshalStrict(payload, &p); err != nil { - return err - } - i.Severity = p.Severity - i.Line = p.Line - - default: - // Unknown type - store raw for debugging but don't fail - } - - // Only process the first key - break - } - - return nil -} - -// SyncStatus represents the detailed sync state from UpdateSyncStatus instructions. -type SyncStatus struct { - Connected bool `json:"connected"` - Connecting bool `json:"connecting"` - PriorityStatus []PriorityStatus `json:"priority_status"` - Downloading *DownloadProgress `json:"downloading"` - Streams []StreamStatus `json:"streams"` -} - -// PriorityStatus represents sync status for a specific priority level. -type PriorityStatus struct { - Priority int `json:"priority"` - LastSyncedAt *int `json:"last_synced_at"` - HasSynced *bool `json:"has_synced"` -} - -// StreamStatus represents the status of a sync stream subscription. -type StreamStatus struct { - Name string `json:"name"` - Parameters *string `json:"parameters"` - Priority int `json:"priority"` - Active bool `json:"active"` - IsDefault bool `json:"is_default"` - HasExplicitSubscription bool `json:"has_explicit_subscription"` - ExpiresAt *int `json:"expires_at"` - LastSyncedAt *int `json:"last_synced_at"` - Progress *StreamProgress `json:"progress"` -} - -// StreamProgress represents download progress for a stream. -type StreamProgress struct { - Total int `json:"total"` - Downloaded int `json:"downloaded"` -} - -// DownloadProgress represents the current download progress. -type DownloadProgress struct { - Buckets map[string]BucketProgress `json:"buckets"` -} - -// BucketProgress represents progress for a single bucket. -type BucketProgress struct { - Priority int `json:"priority"` - AtLast int `json:"at_last"` - SinceLast int `json:"since_last"` - TargetCount int `json:"target_count"` -} - -// TotalProgress returns the total progress across all buckets. -// Returns (downloaded, total) counts. -func (d *DownloadProgress) TotalProgress() (int, int) { - if d == nil { - return 0, 0 - } - var downloaded, total int - for _, b := range d.Buckets { - downloaded += b.SinceLast - total += b.TargetCount - } - return downloaded, total -} diff --git a/internal/powersync/extension/protocol_test.go b/internal/powersync/extension/protocol_test.go deleted file mode 100644 index eadd2bee..00000000 --- a/internal/powersync/extension/protocol_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package extension_test - -import ( - "encoding/json" - "testing" - - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestInstruction_UnmarshalJSON(t *testing.T) { - t.Parallel() - - t.Run("parses EstablishSyncStream", func(t *testing.T) { - t.Parallel() - - data := `{"EstablishSyncStream": {"request": {"buckets": [], "client_id": "abc123"}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionEstablishSyncStream { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionEstablishSyncStream) - } - if inst.Request == nil { - t.Fatal("Request is nil") - } - if inst.Request.ClientID != "abc123" { - t.Errorf("Request.ClientID = %q, want %q", inst.Request.ClientID, "abc123") - } - }) - - t.Run("parses FetchCredentials", func(t *testing.T) { - t.Parallel() - - data := `{"FetchCredentials": {"did_expire": true}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionFetchCredentials { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionFetchCredentials) - } - if inst.DidExpire == nil || !*inst.DidExpire { - t.Error("DidExpire should be true") - } - }) - - t.Run("parses CloseSyncStream", func(t *testing.T) { - t.Parallel() - - data := `{"CloseSyncStream": {"hide_disconnect": false}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionCloseSyncStream { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionCloseSyncStream) - } - if inst.HideDisconnect == nil || *inst.HideDisconnect { - t.Error("HideDisconnect should be false") - } - }) - - t.Run("parses UpdateSyncStatus with realistic payload", func(t *testing.T) { - t.Parallel() - - // Realistic payload from the actual PowerSync extension - data := `{"UpdateSyncStatus": {"status": {"connected": true, "connecting": false, "priority_status": [], "downloading": {"buckets": {"prio_3": {"priority": 3, "at_last": 0, "since_last": 1000, "target_count": 32920}}}, "streams": [{"name": "account_data", "parameters": null, "priority": 3, "active": true, "is_default": true, "has_explicit_subscription": false, "expires_at": null, "last_synced_at": null, "progress": {"total": 32920, "downloaded": 1000}}]}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionUpdateSyncStatus { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionUpdateSyncStatus) - } - if inst.SyncStatus == nil { - t.Fatal("SyncStatus is nil") - } - if !inst.SyncStatus.Connected { - t.Error("SyncStatus.Connected should be true") - } - if inst.SyncStatus.Downloading == nil { - t.Fatal("SyncStatus.Downloading is nil") - } - downloaded, total := inst.SyncStatus.Downloading.TotalProgress() - if downloaded != 1000 || total != 32920 { - t.Errorf("TotalProgress() = (%d, %d), want (1000, 32920)", downloaded, total) - } - }) - - t.Run("parses UpdateSyncStatus after sync complete", func(t *testing.T) { - t.Parallel() - - // Status after sync completes - no downloading field - data := `{"UpdateSyncStatus": {"status": {"connected": true, "connecting": false, "priority_status": [{"priority": 2147483647, "last_synced_at": 1738443993, "has_synced": true}], "downloading": null, "streams": []}}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.SyncStatus == nil { - t.Fatal("SyncStatus is nil") - } - if inst.SyncStatus.Downloading != nil { - t.Error("SyncStatus.Downloading should be nil after sync complete") - } - if len(inst.SyncStatus.PriorityStatus) != 1 { - t.Errorf("PriorityStatus length = %d, want 1", len(inst.SyncStatus.PriorityStatus)) - } - }) - - t.Run("parses LogLine", func(t *testing.T) { - t.Parallel() - - data := `{"LogLine": {"severity": "info", "line": "test message"}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionLogLine { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionLogLine) - } - if inst.Severity != "info" { - t.Errorf("Severity = %q, want %q", inst.Severity, "info") - } - if inst.Line != "test message" { - t.Errorf("Line = %q, want %q", inst.Line, "test message") - } - }) - - t.Run("parses DidCompleteSync", func(t *testing.T) { - t.Parallel() - - data := `{"DidCompleteSync": {}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != extension.InstructionDidCompleteSync { - t.Errorf("Type = %q, want %q", inst.Type, extension.InstructionDidCompleteSync) - } - }) - - t.Run("parses unknown instruction type", func(t *testing.T) { - t.Parallel() - - data := `{"SomeNewInstruction": {"foo": "bar"}}` - - var inst extension.Instruction - if err := json.Unmarshal([]byte(data), &inst); err != nil { - t.Fatalf("Unmarshal error = %v", err) - } - - if inst.Type != "SomeNewInstruction" { - t.Errorf("Type = %q, want %q", inst.Type, "SomeNewInstruction") - } - }) - - t.Run("rejects unknown fields in known instruction types", func(t *testing.T) { - t.Parallel() - - // This should fail because "unknown_field" is not expected - data := `{"FetchCredentials": {"did_expire": true, "unknown_field": "value"}}` - - var inst extension.Instruction - err := json.Unmarshal([]byte(data), &inst) - if err == nil { - t.Fatal("Expected error for unknown field, got nil") - } - }) -} diff --git a/internal/powersync/extension/schema.json b/internal/powersync/extension/schema.json deleted file mode 100644 index cabe3801..00000000 --- a/internal/powersync/extension/schema.json +++ /dev/null @@ -1 +0,0 @@ -{"tables":[{"name":"conversation_contexts","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"added_by","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"entity_id","type":"text"},{"name":"entity_type","type":"text"}],"indexes":[]},{"name":"conversations","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"title","type":"text"},{"name":"user_id","type":"text"},{"name":"view_id","type":"text"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"account_id","columns":[{"name":"account_id","ascending":true,"type":"text"}]}]},{"name":"datadog_account_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"disabled_services","type":"integer"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"health","type":"text"},{"name":"inactive_services","type":"integer"},{"name":"log_active_services","type":"integer"},{"name":"log_event_analyzed_count","type":"integer"},{"name":"log_event_bytes_per_hour","type":"real"},{"name":"log_event_cost_per_hour_bytes_usd","type":"real"},{"name":"log_event_cost_per_hour_usd","type":"real"},{"name":"log_event_cost_per_hour_volume_usd","type":"real"},{"name":"log_event_count","type":"integer"},{"name":"log_event_volume_per_hour","type":"real"},{"name":"log_service_count","type":"integer"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"ok_services","type":"integer"},{"name":"policy_approved_count","type":"integer"},{"name":"policy_dismissed_count","type":"integer"},{"name":"policy_pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"ready_for_use","type":"integer"},{"name":"service_cost_per_hour_volume_usd","type":"real"},{"name":"service_volume_per_hour","type":"real"}],"indexes":[{"name":"datadog_account_id","columns":[{"name":"datadog_account_id","ascending":true,"type":"text"}]}]},{"name":"datadog_accounts","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"cost_per_gb_ingested","type":"real"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"site","type":"text"}],"indexes":[]},{"name":"datadog_log_indexes","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"cost_per_million_events_indexed","type":"real"},{"name":"created_at","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"name","type":"text"}],"indexes":[]},{"name":"log_event_fields","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"baseline_avg_bytes","type":"real"},{"name":"created_at","type":"text"},{"name":"field_path","type":"text"},{"name":"log_event_id","type":"text"},{"name":"value_distribution","type":"text"}],"indexes":[]},{"name":"log_event_policies","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"analysis","type":"text"},{"name":"approved_at","type":"text"},{"name":"approved_baseline_avg_bytes","type":"real"},{"name":"approved_baseline_volume_per_hour","type":"real"},{"name":"approved_by","type":"text"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"created_at","type":"text"},{"name":"dismissed_at","type":"text"},{"name":"dismissed_by","type":"text"},{"name":"log_event_id","type":"text"},{"name":"severity","type":"text"},{"name":"subjective","type":"integer"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]}]},{"name":"log_event_policy_category_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"approved_count","type":"integer"},{"name":"boundary","type":"text"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"dismissed_count","type":"integer"},{"name":"display_name","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"events_with_volumes","type":"integer"},{"name":"pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"principle","type":"text"},{"name":"subjective","type":"integer"},{"name":"total_event_count","type":"integer"}],"indexes":[]},{"name":"log_event_policy_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"action","type":"text"},{"name":"approved_at","type":"text"},{"name":"bytes_per_hour","type":"real"},{"name":"category","type":"text"},{"name":"category_type","type":"text"},{"name":"created_at","type":"text"},{"name":"dismissed_at","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"log_event_id","type":"text"},{"name":"log_event_name","type":"text"},{"name":"policy_id","type":"text"},{"name":"service_id","type":"text"},{"name":"service_name","type":"text"},{"name":"severity","type":"text"},{"name":"status","type":"text"},{"name":"subjective","type":"integer"},{"name":"survival_rate","type":"real"},{"name":"volume_per_hour","type":"real"},{"name":"workspace_id","type":"text"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]},{"name":"category_status","columns":[{"name":"category","ascending":true,"type":"text"},{"name":"status","ascending":true,"type":"text"}]}]},{"name":"log_event_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"approved_policy_count","type":"integer"},{"name":"bytes_per_hour","type":"real"},{"name":"cost_per_hour_bytes_usd","type":"real"},{"name":"cost_per_hour_usd","type":"real"},{"name":"cost_per_hour_volume_usd","type":"real"},{"name":"dismissed_policy_count","type":"integer"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"has_been_analyzed","type":"integer"},{"name":"has_volumes","type":"integer"},{"name":"log_event_id","type":"text"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"pending_policy_count","type":"integer"},{"name":"policy_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"service_id","type":"text"},{"name":"volume_per_hour","type":"real"}],"indexes":[{"name":"log_event_id","columns":[{"name":"log_event_id","ascending":true,"type":"text"}]},{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"log_events","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"baseline_avg_bytes","type":"real"},{"name":"baseline_volume_per_hour","type":"real"},{"name":"created_at","type":"text"},{"name":"description","type":"text"},{"name":"event_nature","type":"text"},{"name":"examples","type":"text"},{"name":"matchers","type":"text"},{"name":"name","type":"text"},{"name":"service_id","type":"text"},{"name":"severity","type":"text"},{"name":"signal_purpose","type":"text"}],"indexes":[{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"messages","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"content","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"model","type":"text"},{"name":"role","type":"text"},{"name":"stop_reason","type":"text"}],"indexes":[{"name":"conversation_id","columns":[{"name":"conversation_id","ascending":true,"type":"text"}]}]},{"name":"service_statuses_cache","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"datadog_account_id","type":"text"},{"name":"estimated_bytes_reduction_per_hour","type":"real"},{"name":"estimated_cost_reduction_per_hour_bytes_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_usd","type":"real"},{"name":"estimated_cost_reduction_per_hour_volume_usd","type":"real"},{"name":"estimated_volume_reduction_per_hour","type":"real"},{"name":"health","type":"text"},{"name":"log_event_analyzed_count","type":"integer"},{"name":"log_event_bytes_per_hour","type":"real"},{"name":"log_event_cost_per_hour_bytes_usd","type":"real"},{"name":"log_event_cost_per_hour_usd","type":"real"},{"name":"log_event_cost_per_hour_volume_usd","type":"real"},{"name":"log_event_count","type":"integer"},{"name":"log_event_volume_per_hour","type":"real"},{"name":"observed_bytes_per_hour_after","type":"real"},{"name":"observed_bytes_per_hour_before","type":"real"},{"name":"observed_cost_per_hour_after_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_after_usd","type":"real"},{"name":"observed_cost_per_hour_after_volume_usd","type":"real"},{"name":"observed_cost_per_hour_before_bytes_usd","type":"real"},{"name":"observed_cost_per_hour_before_usd","type":"real"},{"name":"observed_cost_per_hour_before_volume_usd","type":"real"},{"name":"observed_volume_per_hour_after","type":"real"},{"name":"observed_volume_per_hour_before","type":"real"},{"name":"policy_approved_count","type":"integer"},{"name":"policy_dismissed_count","type":"integer"},{"name":"policy_pending_count","type":"integer"},{"name":"policy_pending_critical_count","type":"integer"},{"name":"policy_pending_high_count","type":"integer"},{"name":"policy_pending_low_count","type":"integer"},{"name":"policy_pending_medium_count","type":"integer"},{"name":"service_cost_per_hour_volume_usd","type":"real"},{"name":"service_debug_volume_per_hour","type":"real"},{"name":"service_error_volume_per_hour","type":"real"},{"name":"service_id","type":"text"},{"name":"service_info_volume_per_hour","type":"real"},{"name":"service_other_volume_per_hour","type":"real"},{"name":"service_volume_per_hour","type":"real"},{"name":"service_warn_volume_per_hour","type":"real"}],"indexes":[{"name":"service_id","columns":[{"name":"service_id","ascending":true,"type":"text"}]}]},{"name":"services","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"description","type":"text"},{"name":"enabled","type":"integer"},{"name":"initial_weekly_log_count","type":"integer"},{"name":"name","type":"text"}],"indexes":[{"name":"name","columns":[{"name":"name","ascending":true,"type":"text"}]}]},{"name":"teams","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"workspace_id","type":"text"}],"indexes":[]},{"name":"view_favorites","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"user_id","type":"text"},{"name":"view_id","type":"text"}],"indexes":[]},{"name":"views","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"conversation_id","type":"text"},{"name":"created_at","type":"text"},{"name":"created_by","type":"text"},{"name":"entity_type","type":"text"},{"name":"forked_from_id","type":"text"},{"name":"message_id","type":"text"},{"name":"query","type":"text"}],"indexes":[]},{"name":"workspaces","columns":[{"name":"id","type":"text"},{"name":"account_id","type":"text"},{"name":"created_at","type":"text"},{"name":"name","type":"text"},{"name":"purpose","type":"text"}],"indexes":[]}]} \ No newline at end of file diff --git a/internal/powersync/extension/testdata/checkpoint_lines.ndjson b/internal/powersync/extension/testdata/checkpoint_lines.ndjson deleted file mode 100644 index 0f7fd628..00000000 --- a/internal/powersync/extension/testdata/checkpoint_lines.ndjson +++ /dev/null @@ -1,3 +0,0 @@ -{"token_expires_in":3600} -{"checkpoint":{"last_op_id":"0","buckets":[]}} -{"checkpoint":{"last_op_id":"42","buckets":[]}} diff --git a/internal/powersync/extension/testdata/dev-sanitized.ndjson b/internal/powersync/extension/testdata/dev-sanitized.ndjson deleted file mode 100644 index 1980c1bd..00000000 --- a/internal/powersync/extension/testdata/dev-sanitized.ndjson +++ /dev/null @@ -1,2 +0,0 @@ -{"checkpoint":{"buckets":[{"bucket":"redacted_9d6690cd17d4","checksum":-484225811,"count":50463,"priority":3,"subscriptions":[{"default":0}]}],"last_op_id":"17975363","streams":[{"errors":[],"is_default":true,"name":"redacted_1148223160fd"}],"write_checkpoint":"170"}} -{"checkpoint_complete":{"last_op_id":"17975363"}} diff --git a/internal/powersync/powersynctest/db.go b/internal/powersync/powersynctest/db.go deleted file mode 100644 index bb161db2..00000000 --- a/internal/powersync/powersynctest/db.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package powersynctest provides test utilities for the powersync package. -// -// For database test helpers, use powersync/db/dbtest. -// For mock API clients, use powersync/api/apitest. -package powersynctest - -import ( - "io" - "log/slog" - - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync" -) - -// NewSyncerWithMockClient creates a Syncer with a mock client for testing. -func NewSyncerWithMockClient(endpoint string, tokenRefresher powersync.TokenRefresher, mock *apitest.MockClient) powersync.Syncer { - return powersync.NewSyncer( - endpoint, - tokenRefresher, - discardScope(), - powersync.WithClientFactory(apitest.NewMockClientFactory(mock)), - ) -} - -func discardScope() log.Scope { - return log.RootScope(log.Wrap(slog.New(slog.NewTextHandler(io.Discard, nil)))) -} diff --git a/internal/powersync/powersynctest/mock_syncer.go b/internal/powersync/powersynctest/mock_syncer.go deleted file mode 100644 index 8632462e..00000000 --- a/internal/powersync/powersynctest/mock_syncer.go +++ /dev/null @@ -1,58 +0,0 @@ -package powersynctest - -import ( - "context" - - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/sqlite" -) - -// MockSyncer is a test double for powersync.Syncer. -type MockSyncer struct { - StartFunc func(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error - StopFunc func() - StateFunc func() powersync.State - IsReadyFunc func() bool - NotifyUploadCompletedFunc func(ctx context.Context) error -} - -var _ powersync.Syncer = (*MockSyncer)(nil) - -// NewMockSyncer creates a MockSyncer with sensible defaults. -func NewMockSyncer() *MockSyncer { - return &MockSyncer{} -} - -func (m *MockSyncer) Start(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error { - if m.StartFunc != nil { - return m.StartFunc(ctx, db, accountID, onFirstSync) - } - return nil -} - -func (m *MockSyncer) Stop() { - if m.StopFunc != nil { - m.StopFunc() - } -} - -func (m *MockSyncer) State() powersync.State { - if m.StateFunc != nil { - return m.StateFunc() - } - return powersync.NewDisconnected() -} - -func (m *MockSyncer) IsReady() bool { - if m.IsReadyFunc != nil { - return m.IsReadyFunc() - } - return false -} - -func (m *MockSyncer) NotifyUploadCompleted(ctx context.Context) error { - if m.NotifyUploadCompletedFunc != nil { - return m.NotifyUploadCompletedFunc(ctx) - } - return nil -} diff --git a/internal/powersync/powersynctest/mock_token_refresher.go b/internal/powersync/powersynctest/mock_token_refresher.go deleted file mode 100644 index 00a43654..00000000 --- a/internal/powersync/powersynctest/mock_token_refresher.go +++ /dev/null @@ -1,53 +0,0 @@ -package powersynctest - -import ( - "context" - - "github.com/usetero/cli/internal/powersync" -) - -// Ensure MockTokenRefresher implements powersync.TokenRefresher. -var _ powersync.TokenRefresher = (*MockTokenRefresher)(nil) - -// MockTokenRefresher is a test double for powersync.TokenRefresher. -type MockTokenRefresher struct { - // GetAccessTokenFunc is called when GetAccessToken is invoked. - GetAccessTokenFunc func(ctx context.Context) (string, error) - - // ForceRefreshAccessTokenFunc is called when ForceRefreshAccessToken is invoked. - ForceRefreshAccessTokenFunc func(ctx context.Context) (string, error) - - // Calls records the number of times GetAccessToken was called. - Calls int - - // ForceRefreshCalls records the number of times ForceRefreshAccessToken was called. - ForceRefreshCalls int -} - -// GetAccessToken implements powersync.TokenRefresher. -func (m *MockTokenRefresher) GetAccessToken(ctx context.Context) (string, error) { - m.Calls++ - if m.GetAccessTokenFunc != nil { - return m.GetAccessTokenFunc(ctx) - } - return "mock-token", nil -} - -// ForceRefreshAccessToken implements powersync.TokenRefresher. -func (m *MockTokenRefresher) ForceRefreshAccessToken(ctx context.Context) (string, error) { - m.ForceRefreshCalls++ - if m.ForceRefreshAccessTokenFunc != nil { - return m.ForceRefreshAccessTokenFunc(ctx) - } - // Default: delegate to GetAccessToken for backwards compatibility in tests - return m.GetAccessToken(ctx) -} - -// NewMockTokenRefresher creates a MockTokenRefresher that returns the given token. -func NewMockTokenRefresher(token string) *MockTokenRefresher { - return &MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return token, nil - }, - } -} diff --git a/internal/powersync/state.go b/internal/powersync/state.go deleted file mode 100644 index a1056e4b..00000000 --- a/internal/powersync/state.go +++ /dev/null @@ -1,98 +0,0 @@ -package powersync - -import ( - "fmt" -) - -// State represents the current syncer state. -// Use a type switch to handle each phase: -// -// switch s := syncer.State().(type) { -// case *powersync.Disconnected: -// case *powersync.Connecting: -// case *powersync.Syncing: -// case *powersync.Ready: -// case *powersync.Reconnecting: -// case *powersync.Error: -// } -type State interface { - state() // marker method -} - -// Disconnected means the syncer is not running. -type Disconnected struct{} - -func (*Disconnected) state() {} - -func NewDisconnected() *Disconnected { - return &Disconnected{} -} - -// Connecting means the syncer is establishing its first connection. -type Connecting struct{} - -func (*Connecting) state() {} - -func NewConnecting() *Connecting { - return &Connecting{} -} - -// Syncing means the syncer is actively downloading data. -type Syncing struct { - Progress *Progress // nil until progress is known -} - -func (*Syncing) state() {} - -func NewSyncing() *Syncing { - return &Syncing{} -} - -func (s *Syncing) WithProgress(downloaded, total int) *Syncing { - return &Syncing{ - Progress: &Progress{Downloaded: downloaded, Total: total}, - } -} - -// Ready means the initial sync is complete and data is fresh. -type Ready struct{} - -func (*Ready) state() {} - -func NewReady() *Ready { - return &Ready{} -} - -// Reconnecting means the syncer lost its connection and is retrying. -// Degraded is true after repeated consecutive failures. -type Reconnecting struct { - Degraded bool -} - -func (*Reconnecting) state() {} - -func NewReconnecting(degraded bool) *Reconnecting { - return &Reconnecting{Degraded: degraded} -} - -// Error means a fatal error occurred and syncing stopped. -type Error struct { - Err error -} - -func (*Error) state() {} - -func NewError(err error) *Error { - return &Error{Err: err} -} - -// Progress represents download progress. -type Progress struct { - Downloaded int - Total int -} - -// String returns a human-readable progress string like "50/100". -func (p *Progress) String() string { - return fmt.Sprintf("%d/%d", p.Downloaded, p.Total) -} diff --git a/internal/powersync/state_test.go b/internal/powersync/state_test.go deleted file mode 100644 index 04660380..00000000 --- a/internal/powersync/state_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package powersync_test - -import ( - "testing" - - "github.com/usetero/cli/internal/powersync" -) - -func TestSyncing_WithProgress(t *testing.T) { - t.Parallel() - - t.Run("sets progress", func(t *testing.T) { - t.Parallel() - - state := powersync.NewSyncing().WithProgress(50, 100) - - if state.Progress == nil { - t.Fatal("Progress should not be nil") - } - if state.Progress.Downloaded != 50 { - t.Errorf("Downloaded = %d, want 50", state.Progress.Downloaded) - } - if state.Progress.Total != 100 { - t.Errorf("Total = %d, want 100", state.Progress.Total) - } - }) -} - -func TestProgress_String(t *testing.T) { - t.Parallel() - - p := &powersync.Progress{Downloaded: 50, Total: 100} - want := "50/100" - if got := p.String(); got != want { - t.Errorf("String() = %q, want %q", got, want) - } -} - -func TestReconnecting(t *testing.T) { - t.Parallel() - - t.Run("not degraded", func(t *testing.T) { - t.Parallel() - - state := powersync.NewReconnecting(false) - if state.Degraded { - t.Error("expected Degraded to be false") - } - }) - - t.Run("degraded", func(t *testing.T) { - t.Parallel() - - state := powersync.NewReconnecting(true) - if !state.Degraded { - t.Error("expected Degraded to be true") - } - }) -} diff --git a/internal/powersync/stream_capture.go b/internal/powersync/stream_capture.go deleted file mode 100644 index 15268bc1..00000000 --- a/internal/powersync/stream_capture.go +++ /dev/null @@ -1,126 +0,0 @@ -package powersync - -import ( - "bufio" - "os" - "path/filepath" - "sync" - - "github.com/usetero/cli/internal/log" -) - -const defaultCaptureMaxBytes int64 = 25 * 1024 * 1024 - -// StreamCapture receives raw NDJSON sync-stream lines. -// -// Implementations must be best-effort and never panic. -type StreamCapture interface { - CaptureLine(line []byte) - Close() error -} - -type ndjsonStreamCapture struct { - path string - maxBytes int64 - scope log.Scope - - mu sync.Mutex - file *os.File - writer *bufio.Writer - written int64 - disabled bool -} - -var _ StreamCapture = (*ndjsonStreamCapture)(nil) - -// NewNDJSONStreamCapture creates a best-effort raw line capture sink. -// -// The capture file is created with permissions 0600, and parent directories -// are created with permissions 0700. -func NewNDJSONStreamCapture(path string, maxBytes int64, scope log.Scope) (StreamCapture, error) { - if maxBytes <= 0 { - maxBytes = defaultCaptureMaxBytes - } - - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, err - } - - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return nil, err - } - - info, err := f.Stat() - if err != nil { - _ = f.Close() - return nil, err - } - - return &ndjsonStreamCapture{ - path: path, - maxBytes: maxBytes, - scope: scope.Child("streamcapture"), - file: f, - writer: bufio.NewWriter(f), - written: info.Size(), - }, nil -} - -func (c *ndjsonStreamCapture) CaptureLine(line []byte) { - if len(line) == 0 { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - if c.disabled || c.writer == nil { - return - } - - writeLen := int64(len(line) + 1) - if c.written+writeLen > c.maxBytes { - c.scope.Warn("disabled stream capture after reaching size limit", "path", c.path, "max_bytes", c.maxBytes) - c.disabled = true - return - } - - if _, err := c.writer.Write(line); err != nil { - c.scope.Error("disabled stream capture after write failure", "path", c.path, "error", err) - c.disabled = true - return - } - if err := c.writer.WriteByte('\n'); err != nil { - c.scope.Error("disabled stream capture after write failure", "path", c.path, "error", err) - c.disabled = true - return - } - if err := c.writer.Flush(); err != nil { - c.scope.Error("disabled stream capture after flush failure", "path", c.path, "error", err) - c.disabled = true - return - } - - c.written += writeLen -} - -func (c *ndjsonStreamCapture) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.writer == nil { - return nil - } - - if err := c.writer.Flush(); err != nil { - _ = c.file.Close() - c.writer = nil - c.file = nil - return err - } - err := c.file.Close() - c.writer = nil - c.file = nil - return err -} diff --git a/internal/powersync/stream_capture_test.go b/internal/powersync/stream_capture_test.go deleted file mode 100644 index 9a3e5e75..00000000 --- a/internal/powersync/stream_capture_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package powersync - -import ( - "os" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/log/logtest" -) - -func TestNDJSONStreamCapture_WritesLines(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "capture.ndjson") - capture, err := NewNDJSONStreamCapture(path, 1024, logtest.NewScope(t)) - if err != nil { - t.Fatalf("NewNDJSONStreamCapture() error = %v", err) - } - defer func() { _ = capture.Close() }() - - capture.CaptureLine([]byte(`{"op":"put"}`)) - capture.CaptureLine([]byte(`{"op":"remove"}`)) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - got := string(data) - want := "{\"op\":\"put\"}\n{\"op\":\"remove\"}\n" - if got != want { - t.Fatalf("capture file = %q, want %q", got, want) - } -} - -func TestNDJSONStreamCapture_StopsAtMaxBytes(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "capture.ndjson") - capture, err := NewNDJSONStreamCapture(path, 8, logtest.NewScope(t)) - if err != nil { - t.Fatalf("NewNDJSONStreamCapture() error = %v", err) - } - defer func() { _ = capture.Close() }() - - capture.CaptureLine([]byte("abc")) // 4 bytes with newline - capture.CaptureLine([]byte("def")) // 4 bytes with newline - capture.CaptureLine([]byte("ghi")) // should be ignored - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - got := string(data) - want := "abc\ndef\n" - if got != want { - t.Fatalf("capture file = %q, want %q", got, want) - } -} diff --git a/internal/powersync/syncer.go b/internal/powersync/syncer.go deleted file mode 100644 index d006ebf1..00000000 --- a/internal/powersync/syncer.go +++ /dev/null @@ -1,219 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/powersync/db" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -// TokenRefresher provides access tokens for authentication. -type TokenRefresher interface { - GetAccessToken(ctx context.Context) (string, error) - // ForceRefreshAccessToken refreshes the token unconditionally, bypassing - // local expiration checks. Used when the server rejects a token the - // client still considers valid (e.g. clock skew). - ForceRefreshAccessToken(ctx context.Context) (string, error) -} - -// Syncer manages PowerSync synchronization. -type Syncer interface { - Start(ctx context.Context, db sqlite.DB, accountID string, onFirstSync func()) error - Stop() - State() State - IsReady() bool - NotifyUploadCompleted(ctx context.Context) error -} - -// ControlPlane wraps extension control operations used by the syncer. -type ControlPlane interface { - Start(ctx context.Context, req extension.StartRequest) ([]extension.Instruction, error) - SendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) - NotifyConnection(ctx context.Context, event extension.ConnectionEvent) ([]extension.Instruction, error) - NotifyTokenRefreshed(ctx context.Context) ([]extension.Instruction, error) - NotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) - Close() error -} - -var _ Syncer = (*syncer)(nil) -var _ ControlPlane = (*extension.Controller)(nil) - -// syncer implements Syncer. -type syncer struct { - endpoint string - tokenRefresher TokenRefresher - scope log.Scope - clientFactory func(endpoint string) psapi.Client - controlPlaneFn func(db sqlite.DB) ControlPlane - streamCapture StreamCapture - - database sqlite.DB - accountID string - control ControlPlane - client psapi.Client - onFirstSync func() - controlMu sync.Mutex - - // State - protected by atomic operations - state atomic.Pointer[stateWrapper] - - cancel context.CancelFunc - done chan struct{} -} - -// stateWrapper wraps State to allow atomic.Pointer usage. -type stateWrapper struct { - state State -} - -// SyncerOption configures a Syncer. -type SyncerOption func(*syncer) - -// WithClientFactory sets a custom client factory (for testing). -func WithClientFactory(factory func(endpoint string) psapi.Client) SyncerOption { - return func(s *syncer) { - s.clientFactory = factory - } -} - -// WithControlPlaneFactory sets a custom control plane factory (for testing). -func WithControlPlaneFactory(factory func(db sqlite.DB) ControlPlane) SyncerOption { - return func(s *syncer) { - s.controlPlaneFn = factory - } -} - -// WithStreamCapture sets a best-effort raw sync-stream capture sink. -func WithStreamCapture(capture StreamCapture) SyncerOption { - return func(s *syncer) { - s.streamCapture = capture - } -} - -// NewSyncer creates a new Syncer. Call Start() when you have a database ready. -func NewSyncer(endpoint string, tokenRefresher TokenRefresher, scope log.Scope, opts ...SyncerOption) Syncer { - s := &syncer{ - endpoint: endpoint, - tokenRefresher: tokenRefresher, - scope: scope.Child("powersync"), - clientFactory: psapi.NewClient, - controlPlaneFn: func(db sqlite.DB) ControlPlane { - return extension.NewController(db) - }, - } - for _, opt := range opts { - opt(s) - } - s.setState(NewDisconnected()) - return s -} - -// Start begins syncing. The onFirstSync callback fires once when initial sync completes. -func (s *syncer) Start(ctx context.Context, database sqlite.DB, accountID string, onFirstSync func()) error { - if accountID == "" { - return fmt.Errorf("powersync: accountID is required") - } - if s.cancel != nil { - return fmt.Errorf("already started") - } - - token, err := s.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return fmt.Errorf("get initial token: %w", err) - } - - if err := extension.ApplySchema(ctx, database); err != nil { - return err - } - - // Check database health before starting sync. - // A crash during migration or write can leave the database in an - // inconsistent state. If corrupt, return an error so caller can reset. - queue := db.NewCrudQueue(database) - if err := queue.CheckHealth(ctx); err != nil { - return err - } - - s.database = database - s.accountID = accountID - s.onFirstSync = onFirstSync - s.control = s.controlPlaneFn(database) - s.client = s.clientFactory(s.endpoint) - s.client.SetToken(token) - s.done = make(chan struct{}) - s.setState(NewConnecting()) - - ctx, s.cancel = context.WithCancel(ctx) - go s.run(ctx) - - s.scope.Info("sync started", log.String("accountID", accountID)) - return nil -} - -// Stop shuts down syncing. -func (s *syncer) Stop() { - if s.cancel != nil { - s.cancel() - <-s.done - s.cancel = nil - } - s.controlMu.Lock() - if s.control != nil { - _ = s.control.Close() - s.control = nil - } - s.controlMu.Unlock() - - if s.streamCapture != nil { - if err := s.streamCapture.Close(); err != nil { - s.scope.Warn("failed to close stream capture", "error", err) - } - s.streamCapture = nil - } - - s.client = nil - s.database = nil - s.done = nil - s.setState(NewDisconnected()) - s.scope.Info("sync stopped") -} - -// State returns the current syncer state. -func (s *syncer) State() State { - if w := s.state.Load(); w != nil { - return w.state - } - return NewDisconnected() -} - -// IsReady returns true if initial sync is complete. -func (s *syncer) IsReady() bool { - _, ok := s.State().(*Ready) - return ok -} - -func (s *syncer) NotifyUploadCompleted(ctx context.Context) error { - instructions, err := s.controlPlaneNotifyUploadCompleted(ctx) - if errors.Is(err, errControlPlaneUnavailable) { - // Upload completion can legitimately race with shutdown or run before start. - return nil - } - if err != nil { - return fmt.Errorf("notify upload completed: %w", err) - } - if _, err := s.applyInstructions(ctx, instructions); err != nil { - return fmt.Errorf("apply upload completion instructions: %w", err) - } - return nil -} - -func (s *syncer) setState(state State) { - s.state.Store(&stateWrapper{state: state}) -} diff --git a/internal/powersync/syncer_controlplane.go b/internal/powersync/syncer_controlplane.go deleted file mode 100644 index 3a076294..00000000 --- a/internal/powersync/syncer_controlplane.go +++ /dev/null @@ -1,69 +0,0 @@ -package powersync - -import ( - "context" - "errors" - - "github.com/usetero/cli/internal/powersync/extension" -) - -var errControlPlaneUnavailable = errors.New("control plane unavailable") - -func (s *syncer) withControlPlaneLocked(fn func(c ControlPlane) error) error { - s.controlMu.Lock() - defer s.controlMu.Unlock() - if s.control == nil { - return errControlPlaneUnavailable - } - return fn(s.control) -} - -func (s *syncer) controlPlaneStart(ctx context.Context, req extension.StartRequest) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.Start(ctx, req) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneSendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.SendTextLine(ctx, line) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyConnection(ctx context.Context, event extension.ConnectionEvent) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyConnection(ctx, event) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyTokenRefreshed(ctx context.Context) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyTokenRefreshed(ctx) - return err - }) - return instructions, err -} - -func (s *syncer) controlPlaneNotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) { - var instructions []extension.Instruction - err := s.withControlPlaneLocked(func(c ControlPlane) error { - var err error - instructions, err = c.NotifyUploadCompleted(ctx) - return err - }) - return instructions, err -} diff --git a/internal/powersync/syncer_controlplane_test.go b/internal/powersync/syncer_controlplane_test.go deleted file mode 100644 index dc205170..00000000 --- a/internal/powersync/syncer_controlplane_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_ControlPlaneAccessIsSerialized(t *testing.T) { - t.Parallel() - - cp := &blockingControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(2) - go func() { - defer wg.Done() - _ = s.processLine(ctx, []byte(`{"token_expires_in":3600}`)) - }() - go func() { - defer wg.Done() - _ = s.NotifyUploadCompleted(ctx) - }() - } - wg.Wait() - - if got := cp.maxInFlight.Load(); got > 1 { - t.Fatalf("control plane operations overlapped, max_in_flight=%d", got) - } -} - -func TestSyncer_NotifyUploadCompleted_ContextCancellationPropagates(t *testing.T) { - t.Parallel() - - cp := &contextAwareControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - - err := s.NotifyUploadCompleted(ctx) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("NotifyUploadCompleted() error = %v, want deadline exceeded", err) - } -} - -func TestSyncer_ProcessLine_ContextCancellationPropagates(t *testing.T) { - t.Parallel() - - cp := &contextAwareControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - - err := s.processLine(ctx, []byte(`{"token_expires_in":3600}`)) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("processLine() error = %v, want deadline exceeded", err) - } -} - -func TestSyncer_NotifyUploadCompleted_ConcurrentWithStop_IsSafe(t *testing.T) { - t.Parallel() - - cp := &blockingControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = s.NotifyUploadCompleted(ctx) - }() - } - - // Stop concurrently while notifications are in flight. - s.Stop() - wg.Wait() -} - -func TestSyncer_ProcessLine_ControlPlaneUnavailable(t *testing.T) { - t.Parallel() - - s := &syncer{ - scope: logtest.NewScope(t), - } - - err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)) - if err == nil { - t.Fatal("expected error, got nil") - } - if !errors.Is(err, errControlPlaneUnavailable) { - t.Fatalf("processLine() error = %v, want errControlPlaneUnavailable", err) - } -} - -type blockingControlPlane struct { - inFlight atomic.Int32 - maxInFlight atomic.Int32 -} - -func (b *blockingControlPlane) recordCall() { - current := b.inFlight.Add(1) - for { - max := b.maxInFlight.Load() - if current <= max { - break - } - if b.maxInFlight.CompareAndSwap(max, current) { - break - } - } - time.Sleep(2 * time.Millisecond) - b.inFlight.Add(-1) -} - -func (b *blockingControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - b.recordCall() - return nil, nil -} - -func (b *blockingControlPlane) Close() error { - return nil -} - -type contextAwareControlPlane struct{} - -func (*contextAwareControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) SendTextLine(ctx context.Context, _ string) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (*contextAwareControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (*contextAwareControlPlane) NotifyUploadCompleted(ctx context.Context) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (*contextAwareControlPlane) Close() error { - return nil -} - -func TestSyncer_NotifyUploadCompleted_PropagatesControlPlaneError(t *testing.T) { - t.Parallel() - - cp := &errorControlPlane{err: fmt.Errorf("boom")} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - err := s.NotifyUploadCompleted(context.Background()) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -type errorControlPlane struct { - err error -} - -func (e *errorControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (e *errorControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, e.err -} - -func (e *errorControlPlane) Close() error { - return nil -} diff --git a/internal/powersync/syncer_instructions.go b/internal/powersync/syncer_instructions.go deleted file mode 100644 index f7b76ca6..00000000 --- a/internal/powersync/syncer_instructions.go +++ /dev/null @@ -1,57 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/powersync/extension" -) - -type streamAction int - -const ( - streamActionContinue streamAction = iota - streamActionClose -) - -func (s *syncer) applyInstructions(ctx context.Context, instructions []extension.Instruction) (streamAction, error) { - for _, inst := range instructions { - switch inst.Type { - case extension.InstructionDidCompleteSync: - s.scope.Debug("sync complete") - s.setState(NewReady()) - s.fireFirstSync() - - case extension.InstructionUpdateSyncStatus: - if inst.SyncStatus != nil && inst.SyncStatus.Downloading != nil { - downloaded, total := inst.SyncStatus.Downloading.TotalProgress() - s.setState(NewSyncing().WithProgress(downloaded, total)) - s.scope.Debug("sync progress", "downloaded", downloaded, "total", total) - } - - case extension.InstructionFetchCredentials: - s.scope.Debug("received FetchCredentials", "didExpire", inst.DidExpire) - if err := s.refreshToken(ctx); err != nil { - return streamActionContinue, err - } - - case extension.InstructionCloseSyncStream: - s.scope.Debug("received CloseSyncStream") - return streamActionClose, nil - - case extension.InstructionFlushFileSystem: - // Native SQLite handles durability on commit. Treat this as a best-effort - // checkpoint request to reduce WAL growth and honor the extension signal. - if _, err := s.database.Exec(ctx, "PRAGMA wal_checkpoint(PASSIVE)"); err != nil { - return streamActionContinue, fmt.Errorf("flush file system: %w", err) - } - - case extension.InstructionLogLine: - s.scope.Debug("powersync", "severity", inst.Severity, "line", inst.Line) - default: - // Other instruction types not expected during line processing. - } - } - - return streamActionContinue, nil -} diff --git a/internal/powersync/syncer_instructions_test.go b/internal/powersync/syncer_instructions_test.go deleted file mode 100644 index 68a50b12..00000000 --- a/internal/powersync/syncer_instructions_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - "testing" - - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_ApplyInstructions_CloseSyncStream(t *testing.T) { - t.Parallel() - - s := &syncer{ - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionCloseSyncStream}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionClose { - t.Fatalf("action = %v, want %v", action, streamActionClose) - } -} - -func TestSyncer_ApplyInstructions_FlushFileSystem(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - s := &syncer{ - database: db, - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFlushFileSystem}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionContinue { - t.Fatalf("action = %v, want %v", action, streamActionContinue) - } -} - -func TestSyncer_ApplyInstructions_FetchCredentialsThenClose(t *testing.T) { - t.Parallel() - - mockClient := apitest.NewMockClient() - ctrl := &stubController{} - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "new-token"}, - client: mockClient, - control: ctrl, - scope: logtest.NewScope(t), - } - - action, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - {Type: extension.InstructionCloseSyncStream}, - }) - if err != nil { - t.Fatalf("applyInstructions() error = %v", err) - } - if action != streamActionClose { - t.Fatalf("action = %v, want %v", action, streamActionClose) - } - if mockClient.Token != "new-token" { - t.Fatalf("token = %q, want %q", mockClient.Token, "new-token") - } - if ctrl.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", ctrl.notifyTokenRefreshedCalls) - } -} - -func TestSyncer_ApplyInstructions_FetchCredentialsError(t *testing.T) { - t.Parallel() - - mockClient := apitest.NewMockClient() - ctrl := &stubController{ - notifyTokenRefreshedErr: fmt.Errorf("notify failed"), - } - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "new-token"}, - client: mockClient, - control: ctrl, - scope: logtest.NewScope(t), - } - - _, err := s.applyInstructions(context.Background(), []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - {Type: extension.InstructionCloseSyncStream}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if ctrl.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", ctrl.notifyTokenRefreshedCalls) - } -} - -type stubController struct { - notifyTokenRefreshedCalls int - notifyTokenRefreshedErr error -} - -func (s *stubController) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - s.notifyTokenRefreshedCalls++ - return nil, s.notifyTokenRefreshedErr -} - -func (s *stubController) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *stubController) Close() error { - return nil -} - -type stubTokenRefresher struct { - token string - err error -} - -func (s *stubTokenRefresher) GetAccessToken(context.Context) (string, error) { - if s.err != nil { - return "", s.err - } - return s.token, nil -} - -func (s *stubTokenRefresher) ForceRefreshAccessToken(context.Context) (string, error) { - if s.err != nil { - return "", s.err - } - return s.token, nil -} diff --git a/internal/powersync/syncer_lifecycle_test.go b/internal/powersync/syncer_lifecycle_test.go deleted file mode 100644 index 7761f6d3..00000000 --- a/internal/powersync/syncer_lifecycle_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package powersync_test - -import ( - "context" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" -) - -type closeCountCapture struct { - closeCalls int -} - -func (c *closeCountCapture) CaptureLine([]byte) {} -func (c *closeCountCapture) Close() error { - c.closeCalls++ - return nil -} - -func TestNewSyncer(t *testing.T) { - t.Parallel() - - t.Run("initial state is disconnected", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer("https://example.com", nil, logtest.NewScope(t)) - - if _, ok := syncer.State().(*powersync.Disconnected); !ok { - t.Errorf("State() = %T, want *Disconnected", syncer.State()) - } - }) - - t.Run("IsReady is false initially", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer("https://example.com", nil, logtest.NewScope(t)) - - if syncer.IsReady() { - t.Error("IsReady() should be false before Start") - } - }) -} - -func TestSyncer_Start(t *testing.T) { - t.Parallel() - - t.Run("returns error on empty accountID", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - ) - - err := syncer.Start(context.Background(), db, "", nil) - if err == nil { - t.Error("expected error for empty accountID") - } - }) - - t.Run("returns error if already started", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("first Start() error = %v", err) - } - defer syncer.Stop() - - err = syncer.Start(ctx, db, "account-123", nil) - if err == nil { - t.Error("expected error on second Start()") - } - }) - - t.Run("transitions to syncing when stream connects", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - started := make(chan struct{}) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - close(started) - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - <-started - - state := syncer.State() - _, isSyncing := state.(*powersync.Syncing) - _, isConnecting := state.(*powersync.Connecting) - if !isSyncing && !isConnecting { - t.Errorf("State() = %T, want *Syncing or *Connecting", state) - } - }) - - t.Run("processes lines from stream", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - handlerCalled := make(chan struct{}) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - if err := handler([]byte(`{"token_expires_in":3600}`)); err != nil { - return err - } - close(handlerCalled) - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - defer syncer.Stop() - - select { - case <-handlerCalled: - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for stream line to be processed") - } - }) - - t.Run("does not panic with nil onFirstSync callback", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - syncer.Stop() - }) -} - -func TestSyncer_Stop(t *testing.T) { - t.Parallel() - - t.Run("transitions to disconnected", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - syncer.Stop() - - if _, ok := syncer.State().(*powersync.Disconnected); !ok { - t.Errorf("State() = %T, want *Disconnected", syncer.State()) - } - }) - - t.Run("IsReady is false after stop", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - _ = syncer.Start(ctx, db, "account-123", nil) - syncer.Stop() - - if syncer.IsReady() { - t.Error("IsReady() should be false after Stop") - } - }) - - t.Run("is safe to call multiple times", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - ctx := context.Background() - _ = syncer.Start(ctx, db, "account-123", nil) - - syncer.Stop() - syncer.Stop() - syncer.Stop() - }) - - t.Run("closes configured stream capture only once", func(t *testing.T) { - t.Parallel() - - capture := &closeCountCapture{} - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - powersync.WithStreamCapture(capture), - ) - - syncer.Stop() - syncer.Stop() - - if capture.closeCalls != 1 { - t.Fatalf("capture close calls = %d, want 1", capture.closeCalls) - } - }) -} - -func TestSyncer_NotifyUploadCompleted(t *testing.T) { - t.Parallel() - - t.Run("is safe before start", func(t *testing.T) { - t.Parallel() - - syncer := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - ) - - if err := syncer.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - }) - - t.Run("is safe after stop", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - apitest.NewMockClient(), - ) - - if err := syncer.Start(context.Background(), db, "account-123", nil); err != nil { - t.Fatalf("Start() error = %v", err) - } - syncer.Stop() - - if err := syncer.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - }) -} diff --git a/internal/powersync/syncer_retry_test.go b/internal/powersync/syncer_retry_test.go deleted file mode 100644 index 5d60a47f..00000000 --- a/internal/powersync/syncer_retry_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package powersync_test - -import ( - "context" - "fmt" - "sync/atomic" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/powersynctest" -) - -func TestSyncer_ErrorHandling(t *testing.T) { - t.Parallel() - - t.Run("force-refreshes token on 401 and retries", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - <-ctx.Done() - return ctx.Err() - } - - var forceRefreshed atomic.Bool - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - if forceRefreshed.Load() { - return "new-token", nil - } - return "stale-token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - forceRefreshed.Store(true) - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(500 * time.Millisecond) - syncer.Stop() - - if !forceRefreshed.Load() { - t.Error("expected ForceRefreshAccessToken to be called on 401") - } - if mock.Token != "new-token" { - t.Errorf("Token = %q, want %q", mock.Token, "new-token") - } - }) - - t.Run("auth errors are never fatal", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n <= 5 { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - <-ctx.Done() - return ctx.Err() - } - - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(500 * time.Millisecond) - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("auth errors should never be fatal") - } - if connectCalls.Load() <= 5 { - t.Errorf("expected more than 5 connect calls, got %d", connectCalls.Load()) - } - - syncer.Stop() - }) - - t.Run("retries when token refresh fails", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return &psapi.Error{Kind: psapi.ErrorKindAuth, StatusCode: 401} - } - - var refreshCalls atomic.Int32 - refresher := &powersynctest.MockTokenRefresher{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - ForceRefreshAccessTokenFunc: func(ctx context.Context) (string, error) { - n := refreshCalls.Add(1) - if n <= 2 { - return "", fmt.Errorf("network error") - } - return "new-token", nil - }, - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - refresher, - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(4 * time.Second) - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("refresh failures should not be fatal") - } - if refreshCalls.Load() < 3 { - t.Errorf("expected at least 3 refresh calls, got %d", refreshCalls.Load()) - } - - syncer.Stop() - }) - - t.Run("transitions to error state on permanent error", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return &psapi.Error{Kind: psapi.ErrorKindPermanent, StatusCode: 400, Message: "bad request"} - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx := context.Background() - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(100 * time.Millisecond) - - errState, ok := syncer.State().(*powersync.Error) - if !ok { - t.Fatalf("State() = %T, want *Error", syncer.State()) - } - if errState.Err == nil { - t.Error("Error.Err should not be nil") - } - }) - - t.Run("retries on non-API error with backoff", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return fmt.Errorf("powersync_control: invalid state: No iteration is active") - } - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(2 * time.Second) - syncer.Stop() - - if connectCalls.Load() < 2 { - t.Errorf("expected at least 2 connect calls, got %d", connectCalls.Load()) - } - - if _, ok := syncer.State().(*powersync.Error); ok { - t.Error("non-API errors should be retried, not fatal") - } - if _, ok := syncer.State().(*powersync.Reconnecting); ok { - t.Error("expected recovery, not reconnecting") - } - }) - - t.Run("marks degraded after repeated failures", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return fmt.Errorf("powersync_control: invalid state: No iteration is active") - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(8 * time.Second) - - state, ok := syncer.State().(*powersync.Reconnecting) - if !ok { - t.Fatalf("State() = %T, want *Reconnecting", syncer.State()) - } - if !state.Degraded { - t.Error("expected Degraded to be true after repeated failures") - } - - syncer.Stop() - }) - - t.Run("retries on transient error with backoff", func(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - - var connectCalls atomic.Int32 - mock := apitest.NewMockClient() - mock.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - n := connectCalls.Add(1) - if n == 1 { - return &psapi.Error{Kind: psapi.ErrorKindTransient, StatusCode: 503} - } - <-ctx.Done() - return ctx.Err() - } - - syncer := powersynctest.NewSyncerWithMockClient( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - mock, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - err := syncer.Start(ctx, db, "account-123", nil) - if err != nil { - t.Fatalf("Start() error = %v", err) - } - - time.Sleep(2 * time.Second) - syncer.Stop() - - if connectCalls.Load() < 2 { - t.Errorf("expected at least 2 connect calls, got %d", connectCalls.Load()) - } - }) -} diff --git a/internal/powersync/syncer_run.go b/internal/powersync/syncer_run.go deleted file mode 100644 index d5be3c49..00000000 --- a/internal/powersync/syncer_run.go +++ /dev/null @@ -1,111 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/log" -) - -const ( - initialRetryDelay = 1 * time.Second - maxRetryDelay = 10 * time.Second - errorStateAfter = 3 // show error state after this many consecutive failures -) - -// run is the main sync loop with retry logic. -func (s *syncer) run(ctx context.Context) { - defer close(s.done) - - retryDelay := initialRetryDelay - retries := 0 - - for { - if ctx.Err() != nil { - return - } - - err := s.runSession(ctx) - if err == nil { - retryDelay = initialRetryDelay - retries = 0 - continue - } - if ctx.Err() != nil { - return - } - - var clientErr *psapi.Error - if errors.As(err, &clientErr) { - if clientErr.IsAuth() { - s.scope.Debug("auth error, force-refreshing token") - s.setState(NewReconnecting(false)) - if err := s.forceRefreshToken(ctx); err != nil { - // Refresh failed — backoff and try again later. - // Don't give up. The server may be down, we'll recover when it's back. - retries++ - s.scope.Debug("token refresh failed, retrying", log.Duration("delay", retryDelay), log.Any("error", err)) - s.setState(NewReconnecting(retries >= errorStateAfter)) - s.wait(ctx, retryDelay) - retryDelay = min(retryDelay*2, maxRetryDelay) - } - continue - } - - if clientErr.IsPermanent() { - s.setError(err) - return - } - } - - // Transient API errors and non-API errors (e.g. extension state - // errors) are retried with backoff. runSession calls Start() which - // resets the extension state via tear_down(), so retry is safe. - retries++ - s.scope.Debug("transient error, retrying", log.Duration("delay", retryDelay), log.Int("attempt", retries), log.Any("error", err)) - s.setState(NewReconnecting(retries >= errorStateAfter)) - s.wait(ctx, retryDelay) - retryDelay = min(retryDelay*2, maxRetryDelay) - } -} - -func (s *syncer) refreshToken(ctx context.Context) error { - token, err := s.tokenRefresher.GetAccessToken(ctx) - if err != nil { - return err - } - s.client.SetToken(token) - if _, err := s.controlPlaneNotifyTokenRefreshed(ctx); err != nil { - return fmt.Errorf("notify token refreshed: %w", err) - } - return nil -} - -// forceRefreshToken unconditionally refreshes the token, bypassing local -// expiration checks. Used when the server has rejected the current token. -func (s *syncer) forceRefreshToken(ctx context.Context) error { - token, err := s.tokenRefresher.ForceRefreshAccessToken(ctx) - if err != nil { - return err - } - s.client.SetToken(token) - if _, err := s.controlPlaneNotifyTokenRefreshed(ctx); err != nil { - return fmt.Errorf("notify token refreshed: %w", err) - } - return nil -} - -func (s *syncer) setError(err error) { - s.setState(NewError(err)) - s.scope.Error("sync failed", log.Any("error", err)) -} - -func (s *syncer) wait(ctx context.Context, d time.Duration) { - select { - case <-ctx.Done(): - case <-time.After(d): - } -} diff --git a/internal/powersync/syncer_shutdown_test.go b/internal/powersync/syncer_shutdown_test.go deleted file mode 100644 index 9e2ac01b..00000000 --- a/internal/powersync/syncer_shutdown_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package powersync_test - -import ( - "context" - "testing" - "time" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync" - "github.com/usetero/cli/internal/powersync/db/dbtest" - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/powersync/powersynctest" - "github.com/usetero/cli/internal/sqlite" -) - -func TestSyncer_Stop_UnblocksBlockedControlPlaneCalls(t *testing.T) { - t.Parallel() - - db := dbtest.OpenTestDB(t) - cp := &cancelAwareControlPlane{} - mockClient := apitest.NewMockClient() - mockClient.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - if err := handler([]byte(`{"token_expires_in":3600}`)); err != nil { - return err - } - <-ctx.Done() - return ctx.Err() - } - - s := powersync.NewSyncer( - "https://example.com", - powersynctest.NewMockTokenRefresher("token"), - logtest.NewScope(t), - powersync.WithClientFactory(apitest.NewMockClientFactory(mockClient)), - powersync.WithControlPlaneFactory(func(sqlite.DB) powersync.ControlPlane { return cp }), - ) - - ctx := context.Background() - if err := s.Start(ctx, db, "account-123", nil); err != nil { - t.Fatalf("Start() error = %v", err) - } - - done := make(chan struct{}) - go func() { - s.Stop() - close(done) - }() - - select { - case <-done: - // Expected: Stop cancels context and blocked control-plane calls return. - case <-time.After(2 * time.Second): - t.Fatal("Stop() timed out while control-plane call was blocked") - } -} - -type cancelAwareControlPlane struct{} - -func (c *cancelAwareControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return []extension.Instruction{{Type: extension.InstructionEstablishSyncStream, Request: &psapi.SyncStreamRequest{}}}, nil -} - -func (c *cancelAwareControlPlane) SendTextLine(ctx context.Context, line string) ([]extension.Instruction, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (c *cancelAwareControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - return nil, nil -} - -func (c *cancelAwareControlPlane) Close() error { - return nil -} - -var _ powersync.ControlPlane = (*cancelAwareControlPlane)(nil) diff --git a/internal/powersync/syncer_stream.go b/internal/powersync/syncer_stream.go deleted file mode 100644 index 019c6582..00000000 --- a/internal/powersync/syncer_stream.go +++ /dev/null @@ -1,129 +0,0 @@ -package powersync - -import ( - "context" - "errors" - "fmt" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/powersync/extension" -) - -var errCloseSyncStream = errors.New("close sync stream") - -// runSession runs one sync session: start control plane, connect stream, process lines. -func (s *syncer) runSession(ctx context.Context) error { - // Proactively refresh the token before connecting. This avoids a - // needless 401 round-trip when the stream dropped after hours and the - // token expired while we were connected. GetAccessToken checks the JWT - // exp claim and only hits the network if the token is actually expired. - // We only update the HTTP client here — no control plane notification, - // since the control plane hasn't started its iteration yet. - if token, err := s.tokenRefresher.GetAccessToken(ctx); err != nil { - return err - } else { - s.client.SetToken(token) - } - - instructions, err := s.controlPlaneStart(ctx, extension.StartRequest{ - IncludeDefaults: true, - Parameters: map[string]any{"account_id": s.accountID}, - }) - if err != nil { - return fmt.Errorf("control plane start: %w", err) - } - - for _, inst := range instructions { - if ctx.Err() != nil { - return ctx.Err() - } - - switch inst.Type { - case extension.InstructionEstablishSyncStream: - if err := s.runStream(ctx, inst.Request); err != nil { - return err - } - case extension.InstructionFetchCredentials: - if err := s.refreshToken(ctx); err != nil { - return err - } - default: - // Other instruction types are handled elsewhere or ignored at this level. - } - } - - return nil -} - -// runStream connects to the stream and processes lines until disconnect. -func (s *syncer) runStream(ctx context.Context, req *psapi.SyncStreamRequest) error { - if req == nil { - return fmt.Errorf("no sync request") - } - - s.scope.Debug("connecting stream") - s.setState(NewSyncing()) - - connected := false - - err := s.client.SyncStream(ctx, req, func(line []byte) error { - if !connected { - if _, err := s.controlPlaneNotifyConnection(ctx, extension.ConnectionEstablished); err != nil { - return fmt.Errorf("notify connected: %w", err) - } - connected = true - } - return s.processLine(ctx, line) - }) - - if connected { - _, _ = s.controlPlaneNotifyConnection(ctx, extension.ConnectionEnded) - } - - if err != nil { - if errors.Is(err, errCloseSyncStream) { - return nil - } - return fmt.Errorf("stream: %w", err) - } - return nil -} - -// processLine handles one line from the sync stream. -func (s *syncer) processLine(ctx context.Context, line []byte) error { - s.captureStreamLine(line) - - instructions, err := s.controlPlaneSendTextLine(ctx, string(line)) - if err != nil { - return fmt.Errorf("send line: %w", err) - } - - action, err := s.applyInstructions(ctx, instructions) - if err != nil { - return err - } - if action == streamActionClose { - return errCloseSyncStream - } - return nil -} - -func (s *syncer) captureStreamLine(line []byte) { - if s.streamCapture == nil { - return - } - defer func() { - if r := recover(); r != nil { - s.scope.Error("stream capture panicked; continuing sync", "panic", r) - } - }() - s.streamCapture.CaptureLine(line) -} - -func (s *syncer) fireFirstSync() { - if s.onFirstSync != nil { - s.scope.Info("sync connected") - s.onFirstSync() - s.onFirstSync = nil - } -} diff --git a/internal/powersync/syncer_stream_test.go b/internal/powersync/syncer_stream_test.go deleted file mode 100644 index 838793e3..00000000 --- a/internal/powersync/syncer_stream_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package powersync - -import ( - "context" - "fmt" - "testing" - - psapi "github.com/usetero/cli/internal/boundary/powersync" - "github.com/usetero/cli/internal/boundary/powersync/apitest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/powersync/extension" -) - -func TestSyncer_RunStream_CloseInstructionEndsStream(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - sendTextInstructions: []extension.Instruction{ - {Type: extension.InstructionCloseSyncStream}, - }, - } - mockClient := apitest.NewMockClient() - mockClient.SyncStreamFunc = func(ctx context.Context, req *psapi.SyncStreamRequest, handler psapi.LineHandler) error { - return handler([]byte(`{"x":1}`)) - } - - s := &syncer{ - client: mockClient, - control: cp, - scope: logtest.NewScope(t), - } - - err := s.runStream(context.Background(), &psapi.SyncStreamRequest{}) - if err != nil { - t.Fatalf("runStream() error = %v", err) - } - if cp.notifyConnectionCalls != 2 { - t.Fatalf("NotifyConnection calls = %d, want 2 (established + end)", cp.notifyConnectionCalls) - } -} - -func TestSyncer_ProcessLine_InstructionErrorPropagates(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - sendTextInstructions: []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - }, - } - s := &syncer{ - tokenRefresher: &stubTokenRefresher{err: fmt.Errorf("token unavailable")}, - client: apitest.NewMockClient(), - control: cp, - scope: logtest.NewScope(t), - } - - err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestSyncer_NotifyUploadCompleted_ForwardsToControlPlane(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - } - - if err := s.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - if cp.notifyUploadCompletedCalls != 1 { - t.Fatalf("NotifyUploadCompleted calls = %d, want 1", cp.notifyUploadCompletedCalls) - } -} - -func TestSyncer_NotifyUploadCompleted_AppliesReturnedInstructions(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{ - notifyUploadInstructions: []extension.Instruction{ - {Type: extension.InstructionFetchCredentials}, - }, - } - mockClient := apitest.NewMockClient() - s := &syncer{ - tokenRefresher: &stubTokenRefresher{token: "refreshed-token"}, - client: mockClient, - control: cp, - scope: logtest.NewScope(t), - } - - if err := s.NotifyUploadCompleted(context.Background()); err != nil { - t.Fatalf("NotifyUploadCompleted() error = %v", err) - } - if mockClient.Token != "refreshed-token" { - t.Fatalf("token = %q, want %q", mockClient.Token, "refreshed-token") - } - if cp.notifyTokenRefreshedCalls != 1 { - t.Fatalf("NotifyTokenRefreshed calls = %d, want 1", cp.notifyTokenRefreshedCalls) - } -} - -func TestSyncer_ProcessLine_CapturePanicIsolated(t *testing.T) { - t.Parallel() - - cp := &streamTestControlPlane{} - s := &syncer{ - control: cp, - scope: logtest.NewScope(t), - streamCapture: &panicCapture{ - panicValue: "boom", - }, - } - - if err := s.processLine(context.Background(), []byte(`{"token_expires_in":3600}`)); err != nil { - t.Fatalf("processLine() error = %v", err) - } -} - -type streamTestControlPlane struct { - sendTextInstructions []extension.Instruction - notifyUploadInstructions []extension.Instruction - notifyConnectionCalls int - notifyUploadCompletedCalls int - notifyTokenRefreshedCalls int -} - -type panicCapture struct { - panicValue any -} - -func (p *panicCapture) CaptureLine([]byte) { - panic(p.panicValue) -} - -func (p *panicCapture) Close() error { - return nil -} - -func (s *streamTestControlPlane) Start(context.Context, extension.StartRequest) ([]extension.Instruction, error) { - return nil, nil -} - -func (s *streamTestControlPlane) SendTextLine(context.Context, string) ([]extension.Instruction, error) { - return s.sendTextInstructions, nil -} - -func (s *streamTestControlPlane) NotifyConnection(context.Context, extension.ConnectionEvent) ([]extension.Instruction, error) { - s.notifyConnectionCalls++ - return nil, nil -} - -func (s *streamTestControlPlane) NotifyTokenRefreshed(context.Context) ([]extension.Instruction, error) { - s.notifyTokenRefreshedCalls++ - return nil, nil -} - -func (s *streamTestControlPlane) NotifyUploadCompleted(context.Context) ([]extension.Instruction, error) { - s.notifyUploadCompletedCalls++ - return s.notifyUploadInstructions, nil -} - -func (s *streamTestControlPlane) Close() error { - return nil -} diff --git a/internal/sqlite/compliance_policies.go b/internal/sqlite/compliance_policies.go deleted file mode 100644 index 1b7e6333..00000000 --- a/internal/sqlite/compliance_policies.go +++ /dev/null @@ -1,82 +0,0 @@ -package sqlite - -import ( - "context" - "encoding/json" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// CompliancePolicies provides type-safe access to compliance policies (PII, Secrets, PHI, Payment Data). -type CompliancePolicies interface { - ListPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.CompliancePolicy, error) -} - -// compliancePoliciesImpl implements CompliancePolicies. -type compliancePoliciesImpl struct { - queries *gen.Queries -} - -// ListPendingPoliciesByCategory returns pending compliance policies for a specific category. -func (c *compliancePoliciesImpl) ListPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.CompliancePolicy, error) { - catStr := string(category) - rows, err := c.queries.ListPendingCompliancePoliciesByCategory(ctx, gen.ListPendingCompliancePoliciesByCategoryParams{ - Category: &catStr, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list pending compliance policies by category") - } - - result := make([]domain.CompliancePolicy, 0, len(rows)) - for _, row := range rows { - p := domain.CompliancePolicy{ - Category: category, - LogEventName: row.LogEventName, - ServiceName: row.ServiceName, - VolumePerHour: row.VolumePerHour, - AnyObserved: row.AnyObserved != 0, - } - - // Parse the analysis JSON to extract sensitive fields for this category. - if row.Analysis != "" { - fields := extractSensitiveFields(row.Analysis, category) - p.Fields = fields - } - - result = append(result, p) - } - return result, nil -} - -// extractSensitiveFields parses the analysis JSON and extracts the fields array for the given category. -func extractSensitiveFields(analysisJSON string, category domain.PolicyCategory) []domain.SensitiveField { - var envelope domain.ComplianceAnalysisEnvelope - if err := json.Unmarshal([]byte(analysisJSON), &envelope); err != nil { - return nil - } - - switch category { - case domain.CategoryPIILeakage: - if envelope.PIILeakage != nil { - return envelope.PIILeakage.Fields - } - case domain.CategorySecretsLeakage: - if envelope.SecretsLeakage != nil { - return envelope.SecretsLeakage.Fields - } - case domain.CategoryPHILeakage: - if envelope.PHILeakage != nil { - return envelope.PHILeakage.Fields - } - case domain.CategoryPaymentDataLeakage: - if envelope.PaymentDataLeakage != nil { - return envelope.PaymentDataLeakage.Fields - } - default: - return nil - } - - return nil -} diff --git a/internal/sqlite/conversations.go b/internal/sqlite/conversations.go deleted file mode 100644 index f91b3dd2..00000000 --- a/internal/sqlite/conversations.go +++ /dev/null @@ -1,76 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Conversations provides type-safe access to conversations. -type Conversations interface { - Count(ctx context.Context) (int64, error) - Create(ctx context.Context, accountID domain.AccountID, workspaceID domain.WorkspaceID) (domain.ConversationID, error) - UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error - List(ctx context.Context, accountID domain.AccountID) ([]gen.Conversation, error) - Get(ctx context.Context, id domain.ConversationID) (gen.Conversation, error) -} - -// conversationsImpl implements Conversations. -type conversationsImpl struct { - read *gen.Queries // read pool — Count, List, Get - write *gen.Queries // write pool — Create, UpdateTitle -} - -// Count returns the total number of conversations. -func (c *conversationsImpl) Count(ctx context.Context) (int64, error) { - count, err := c.read.CountConversations(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count conversations") - } - return count, nil -} - -// Create creates a new conversation and returns its ID. -func (c *conversationsImpl) Create(ctx context.Context, accountID domain.AccountID, workspaceID domain.WorkspaceID) (domain.ConversationID, error) { - convID := uuid.New().String() - now := time.Now().UTC().Format(time.RFC3339) - accountIDStr := accountID.String() - workspaceIDStr := workspaceID.String() - - err := c.write.InsertConversation(ctx, gen.InsertConversationParams{ - ID: &convID, - AccountID: &accountIDStr, - WorkspaceID: &workspaceIDStr, - CreatedAt: &now, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert conversation") - } - - return domain.ConversationID(convID), nil -} - -// UpdateTitle sets the title on a conversation. -func (c *conversationsImpl) UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error { - idStr := id.String() - err := c.write.UpdateConversationTitle(ctx, gen.UpdateConversationTitleParams{ - Title: &title, - ID: &idStr, - }) - return WrapSQLiteError(err, "update conversation title") -} - -// List returns all conversations for an account. -func (c *conversationsImpl) List(ctx context.Context, accountID domain.AccountID) ([]gen.Conversation, error) { - accountIDStr := accountID.String() - return c.read.ListConversationsByAccount(ctx, &accountIDStr) -} - -// Get returns a conversation by ID. -func (c *conversationsImpl) Get(ctx context.Context, id domain.ConversationID) (gen.Conversation, error) { - idStr := id.String() - return c.read.GetConversation(ctx, &idStr) -} diff --git a/internal/sqlite/database.go b/internal/sqlite/database.go deleted file mode 100644 index 464d7510..00000000 --- a/internal/sqlite/database.go +++ /dev/null @@ -1,439 +0,0 @@ -// Package sqlite provides the local SQLite database for the CLI. -// This is the primary data layer - PowerSync keeps it in sync with the server, -// but you query it directly like any SQLite database. -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/mattn/go-sqlite3" - "github.com/usetero/cli/internal/sqlite/gen" -) - -var ( - // extensionPath is set by powersync.RegisterExtension() to enable - // automatic extension loading on every new connection. - extensionPath string - extensionPathOnce sync.Once - driversOnce sync.Once -) - -const ( - writeDriverName = "sqlite3_tero_write" - readDriverName = "sqlite3_tero_read" -) - -// DB is the interface for application code. -// It provides type-safe access to domain data and raw query execution. -type DB interface { - // Domain entities - Conversations() Conversations - LogEventPolicies() LogEventPolicies - LogEventPolicyStatuses() LogEventPolicyStatuses - CompliancePolicies() CompliancePolicies - LogEvents() LogEvents - Messages() Messages - Services() Services - - // Aggregated statuses - DatadogAccountStatuses() DatadogAccountStatuses - LogEventPolicyCategoryStatuses() LogEventPolicyCategoryStatuses - LogEventStatuses() LogEventStatuses - ServiceStatuses() ServiceStatuses - - // Sync - PendingUploadCounts(ctx context.Context) (map[Table]int64, error) - - // Low-level - Query(ctx context.Context, sql string, args ...any) (*sql.Rows, error) - QueryRow(ctx context.Context, sql string, args ...any) *sql.Row - Exec(ctx context.Context, sql string, args ...any) (sql.Result, error) - WithTx(ctx context.Context, fn func(tx *Tx) error) error - Raw() *sql.DB // Write pool — for PowerSync controller and direct writes - ReadRaw() *sql.DB // Read pool — for query tool and direct reads - Close() error -} - -// database is the concrete implementation of DB. -type database struct { - db *sql.DB // write pool — WAL writer, PowerSync controller, mutations - readDB *sql.DB // read pool — query_only, all domain reads - path string -} - -// Ensure database implements DB. -var _ DB = (*database)(nil) - -// SetExtensionPath configures the PowerSync extension to be loaded on every -// new database connection. This must be called before Open() to have effect. -// Typically called once at startup by the powersync package. -func SetExtensionPath(path string) { - extensionPathOnce.Do(func() { - extensionPath = path - }) -} - -// Per-connection pragmas applied to every connection via driver hooks. -// These must be set per-connection because database/sql pools create -// connections on demand, and pragmas are connection-scoped. -var basePragmas = []string{ - "PRAGMA busy_timeout = 30000", // Wait up to 30s for locks instead of failing immediately - "PRAGMA synchronous = NORMAL", // Safe with WAL, avoids fsync on every commit - "PRAGMA cache_size = -51200", // 50MB page cache (negative = KB) - "PRAGMA temp_store = MEMORY", // Keep temp tables in memory - "PRAGMA recursive_triggers = TRUE", // Required by PowerSync extension for trigger chains -} - -// registerDrivers registers the write and read-only SQLite drivers exactly once. -// Both drivers load the PowerSync extension (if configured) and apply base pragmas -// on every new connection. The read driver additionally sets query_only = ON. -func registerDrivers() { - driversOnce.Do(func() { - sql.Register(writeDriverName, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if extensionPath != "" { - if err := conn.LoadExtension(extensionPath, "sqlite3_powersync_init"); err != nil { - return err - } - } - return execPragmas(conn, basePragmas) - }, - }) - - readPragmas := append(basePragmas, "PRAGMA query_only = ON") - sql.Register(readDriverName, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if extensionPath != "" { - if err := conn.LoadExtension(extensionPath, "sqlite3_powersync_init"); err != nil { - return err - } - } - return execPragmas(conn, readPragmas) - }, - }) - }) -} - -// execPragmas runs a list of PRAGMA statements on a raw SQLite connection. -func execPragmas(conn *sqlite3.SQLiteConn, pragmas []string) error { - for _, p := range pragmas { - if _, err := conn.Exec(p, nil); err != nil { - return fmt.Errorf("%s: %w", p, err) - } - } - return nil -} - -// Open opens a SQLite database at the given path with separate read and write -// connection pools. The write pool enables WAL mode for concurrent read access -// during writes. The read pool enforces query_only = ON via the driver hook. -// -// The database file and parent directories are created if they don't exist. -func Open(ctx context.Context, path string) (DB, error) { - // Ensure parent directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, fmt.Errorf("create database directory: %w", err) - } - - registerDrivers() - - // Open write pool - db, err := sql.Open(writeDriverName, path) - if err != nil { - return nil, fmt.Errorf("open write pool: %w", err) - } - if err := db.PingContext(ctx); err != nil { - db.Close() - return nil, fmt.Errorf("ping write pool: %w", err) - } - // Database-level pragmas (not per-connection — one exec is correct). - // WAL allows concurrent readers during writes — the core fix for query blocking during sync. - // journal_size_limit caps the WAL file at 6MB to prevent unbounded growth. - for _, p := range []string{ - "PRAGMA journal_mode = WAL", - "PRAGMA journal_size_limit = 6291456", - } { - if _, err := db.ExecContext(ctx, p); err != nil { - db.Close() - return nil, fmt.Errorf("%s: %w", p, err) - } - } - - // Open read pool — every connection has query_only = ON and busy_timeout - // set automatically by the driver hook. - readDB, err := sql.Open(readDriverName, path) - if err != nil { - db.Close() - return nil, fmt.Errorf("open read pool: %w", err) - } - if err := readDB.PingContext(ctx); err != nil { - db.Close() - readDB.Close() - return nil, fmt.Errorf("ping read pool: %w", err) - } - - return &database{ - db: db, - readDB: readDB, - path: path, - }, nil -} - -// Close closes both the read and write connection pools. -func (d *database) Close() error { - readErr := d.readDB.Close() - writeErr := d.db.Close() - if writeErr != nil { - return writeErr - } - return readErr -} - -// Path returns the database file path. -func (d *database) Path() string { - return d.path -} - -// --------------------------------------------------------------------------- -// Raw pool access -// --------------------------------------------------------------------------- - -// Raw returns the write pool for direct access. -// Used by the PowerSync controller which needs write access. -func (d *database) Raw() *sql.DB { - return d.db -} - -// ReadRaw returns the read pool for direct access. -// Used by the query tool for user-initiated SQL queries. -func (d *database) ReadRaw() *sql.DB { - return d.readDB -} - -// --------------------------------------------------------------------------- -// Typed query access (sqlc generated) -// --------------------------------------------------------------------------- - -// ReadQueries returns a Queries instance backed by the read pool. -func (d *database) ReadQueries() *gen.Queries { - return gen.New(&timeoutDB{db: d.readDB}) -} - -// WriteQueries returns a Queries instance backed by the write pool. -func (d *database) WriteQueries() *gen.Queries { - return gen.New(&timeoutDB{db: d.db}) -} - -// --------------------------------------------------------------------------- -// Domain entity factories -// --------------------------------------------------------------------------- - -// Messages returns type-safe message operations. -// Uses both pools: reads from readDB, writes (create/update) from writeDB. -func (d *database) Messages() Messages { - return &messagesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// Conversations returns type-safe conversation operations. -// Uses both pools: reads from readDB, writes (create/update) from writeDB. -func (d *database) Conversations() Conversations { - return &conversationsImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// DatadogAccountStatuses returns type-safe Datadog account status operations. -func (d *database) DatadogAccountStatuses() DatadogAccountStatuses { - return &datadogAccountStatusesImpl{queries: d.ReadQueries()} -} - -// LogEventPolicyCategoryStatuses returns type-safe pre-computed policy category rollups. -func (d *database) LogEventPolicyCategoryStatuses() LogEventPolicyCategoryStatuses { - return &logEventPolicyCategoryStatusesImpl{queries: d.ReadQueries()} -} - -// LogEventStatuses returns type-safe log event status operations. -func (d *database) LogEventStatuses() LogEventStatuses { - return &logEventStatusesImpl{queries: d.ReadQueries()} -} - -// ServiceStatuses returns type-safe service status operations. -func (d *database) ServiceStatuses() ServiceStatuses { - return &serviceStatusesImpl{queries: d.ReadQueries()} -} - -// Services returns type-safe service operations. -func (d *database) Services() Services { - return &servicesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// LogEvents returns type-safe log event operations. -func (d *database) LogEvents() LogEvents { - return &logEventsImpl{queries: d.ReadQueries()} -} - -// LogEventPolicies returns type-safe log event policy operations. -func (d *database) LogEventPolicies() LogEventPolicies { - return &logEventPoliciesImpl{read: d.ReadQueries(), write: d.WriteQueries()} -} - -// LogEventPolicyStatuses returns type-safe pre-computed policy status data. -func (d *database) LogEventPolicyStatuses() LogEventPolicyStatuses { - return &logEventPolicyStatusesImpl{queries: d.ReadQueries()} -} - -// CompliancePolicies returns type-safe compliance policy operations. -func (d *database) CompliancePolicies() CompliancePolicies { - return &compliancePoliciesImpl{queries: d.ReadQueries()} -} - -// --------------------------------------------------------------------------- -// Low-level query methods -// --------------------------------------------------------------------------- - -// Query executes a read query via the read pool. -// Note: withTimeout is not applied here because the caller iterates the -// returned *sql.Rows after this method returns — canceling the context -// before iteration causes "context canceled". The sqlc layer (via timeoutDB) -// handles timeouts correctly since iteration happens inside generated methods. -func (d *database) Query(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return d.readDB.QueryContext(ctx, query, args...) -} - -// QueryRow executes a query that returns at most one row via the read pool. -// Note: withTimeout is not applied here because the caller calls Scan() on -// the returned *sql.Row after this method returns — canceling the context -// before Scan causes "context canceled". The sqlc layer (via timeoutDB) -// handles timeouts correctly since Scan happens inside the generated method. -func (d *database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { - return d.readDB.QueryRowContext(ctx, query, args...) -} - -// Exec executes a statement via the write pool. -func (d *database) Exec(ctx context.Context, query string, args ...any) (sql.Result, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return d.db.ExecContext(ctx, query, args...) -} - -// BeginTx starts a transaction on the write pool. -func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { - tx, err := d.db.BeginTx(ctx, opts) - if err != nil { - return nil, err - } - return &Tx{tx: tx}, nil -} - -// Count returns the number of rows in the given table via the read pool. -func (d *database) Count(ctx context.Context, table string) (int64, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - var count int64 - // Use quote identifier to prevent SQL injection - err := d.readDB.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM \"%s\"", table)).Scan(&count) - if err != nil { - return 0, fmt.Errorf("count %s: %w", table, err) - } - return count, nil -} - -// PendingUploadCounts returns pending upload counts grouped by entity table via the read pool. -func (d *database) PendingUploadCounts(ctx context.Context) (map[Table]int64, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - rows, err := d.readDB.QueryContext(ctx, - "SELECT json_extract(data, '$.type') AS entity, COUNT(*) AS cnt FROM ps_crud GROUP BY 1") - if err != nil { - return nil, fmt.Errorf("count pending uploads: %w", err) - } - defer rows.Close() - - counts := make(map[Table]int64) - for rows.Next() { - var entity string - var count int64 - if err := rows.Scan(&entity, &count); err != nil { - return nil, fmt.Errorf("scan pending upload count: %w", err) - } - counts[Table(entity)] = count - } - return counts, rows.Err() -} - -// LoadExtension loads a SQLite extension on the write pool. -func (d *database) LoadExtension(ctx context.Context, path, entryPoint string) error { - conn, err := d.db.Conn(ctx) - if err != nil { - return fmt.Errorf("get connection: %w", err) - } - defer conn.Close() - - return conn.Raw(func(driverConn any) error { - sqliteConn, ok := driverConn.(*sqlite3.SQLiteConn) - if !ok { - return fmt.Errorf("unexpected driver connection type: %T", driverConn) - } - return sqliteConn.LoadExtension(path, entryPoint) - }) -} - -// --------------------------------------------------------------------------- -// Transactions -// --------------------------------------------------------------------------- - -// Tx wraps a SQL transaction with convenience methods. -type Tx struct { - tx *sql.Tx -} - -// Exec executes a statement within the transaction. -func (t *Tx) Exec(ctx context.Context, query string, args ...any) (sql.Result, error) { - return t.tx.ExecContext(ctx, query, args...) -} - -// QueryRow executes a query that returns at most one row within the transaction. -func (t *Tx) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { - return t.tx.QueryRowContext(ctx, query, args...) -} - -// Query executes a query within the transaction. -func (t *Tx) Query(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return t.tx.QueryContext(ctx, query, args...) -} - -// Commit commits the transaction. -func (t *Tx) Commit() error { - return t.tx.Commit() -} - -// Rollback aborts the transaction. -func (t *Tx) Rollback() error { - return t.tx.Rollback() -} - -// WithTx executes a function within a database transaction on the write pool. -// If the function returns an error, the transaction is rolled back. -// If the function succeeds, the transaction is committed. -func (d *database) WithTx(ctx context.Context, fn func(tx *Tx) error) error { - tx, err := d.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - - if fnErr := fn(&Tx{tx: tx}); fnErr != nil { - if rbErr := tx.Rollback(); rbErr != nil { - return fmt.Errorf("rollback failed: %w (original error: %w)", rbErr, fnErr) - } - return fnErr - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit transaction: %w", err) - } - return nil -} diff --git a/internal/sqlite/datadog_account_statuses.go b/internal/sqlite/datadog_account_statuses.go deleted file mode 100644 index 56545345..00000000 --- a/internal/sqlite/datadog_account_statuses.go +++ /dev/null @@ -1,84 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// DatadogAccountStatuses provides access to Datadog account status data. -type DatadogAccountStatuses interface { - GetSummary(ctx context.Context) (domain.AccountSummary, error) -} - -// datadogAccountStatusesImpl implements DatadogAccountStatuses. -type datadogAccountStatusesImpl struct { - queries *gen.Queries -} - -// GetSummary returns aggregated status across all Datadog accounts. -func (d *datadogAccountStatusesImpl) GetSummary(ctx context.Context) (domain.AccountSummary, error) { - row, err := d.queries.GetAccountSummary(ctx) - if err != nil { - return domain.AccountSummary{}, WrapSQLiteError(err, "get account summary") - } - - return domain.AccountSummary{ - ReadyForUse: row.ReadyForUse != 0, - - // Health - Health: domain.ServiceHealth(fmt.Sprint(row.Health)), - - // Services - ServiceCount: row.ServiceCount, - ActiveServices: row.ActiveServices, - OkServices: row.OkServices, - DisabledServices: row.DisabledServices, - InactiveServices: row.InactiveServices, - - // Events - EventCount: row.EventCount, - AnalyzedCount: row.AnalyzedCount, - - // Policies - PendingPolicyCount: row.PendingPolicyCount, - ApprovedPolicyCount: row.ApprovedPolicyCount, - DismissedPolicyCount: row.DismissedPolicyCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - - // Estimated savings - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedCostPerHourBytes: row.EstimatedCostPerHourBytes, - EstimatedCostPerHourVolume: row.EstimatedCostPerHourVolume, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - - // Observed impact - ObservedCostBefore: row.ObservedCostBefore, - ObservedCostBeforeBytes: row.ObservedCostBeforeBytes, - ObservedCostBeforeVolume: row.ObservedCostBeforeVolume, - ObservedCostAfter: row.ObservedCostAfter, - ObservedCostAfterBytes: row.ObservedCostAfterBytes, - ObservedCostAfterVolume: row.ObservedCostAfterVolume, - ObservedVolumeBefore: row.ObservedVolumeBefore, - ObservedVolumeAfter: row.ObservedVolumeAfter, - ObservedBytesBefore: row.ObservedBytesBefore, - ObservedBytesAfter: row.ObservedBytesAfter, - - // Totals - TotalCostPerHour: row.TotalCostPerHour, - TotalCostPerHourBytes: row.TotalCostPerHourBytes, - TotalCostPerHourVolume: row.TotalCostPerHourVolume, - TotalVolumePerHour: row.TotalVolumePerHour, - TotalBytesPerHour: row.TotalBytesPerHour, - - // Service-level throughput - TotalServiceVolumePerHour: row.TotalServiceVolumePerHour, - TotalServiceCostPerHour: row.TotalServiceCostPerHour, - }, nil -} diff --git a/internal/sqlite/db_test.go b/internal/sqlite/db_test.go deleted file mode 100644 index 0ffe3485..00000000 --- a/internal/sqlite/db_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package sqlite - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -// asDatabase casts DB interface to *database for testing internal methods. -func asDatabase(t *testing.T, db DB) *database { - t.Helper() - d, ok := db.(*database) - if !ok { - t.Fatal("expected db to be *database") - } - return d -} - -func TestDB_Count(t *testing.T) { - t.Parallel() - - t.Run("returns zero for empty table", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = db.Exec(ctx, "CREATE TABLE items (id INTEGER PRIMARY KEY)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - - count, err := asDatabase(t, db).Count(ctx, "items") - if err != nil { - t.Fatalf("Count() error = %v", err) - } - - if count != 0 { - t.Errorf("Count() = %d, want 0", count) - } - }) - - t.Run("returns correct count", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = db.Exec(ctx, "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - - _, err = db.Exec(ctx, "INSERT INTO items (name) VALUES ('a'), ('b'), ('c')") - if err != nil { - t.Fatalf("INSERT error = %v", err) - } - - count, err := asDatabase(t, db).Count(ctx, "items") - if err != nil { - t.Fatalf("Count() error = %v", err) - } - - if count != 3 { - t.Errorf("Count() = %d, want 3", count) - } - }) - - t.Run("returns error for nonexistent table", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - _, err = asDatabase(t, db).Count(ctx, "nonexistent") - if err == nil { - t.Error("expected error for nonexistent table, got nil") - } - }) -} - -func TestDB_Path(t *testing.T) { - t.Parallel() - - t.Run("returns the database path", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "mydb.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - d := asDatabase(t, db) - if d.Path() != dbPath { - t.Errorf("Path() = %q, want %q", d.Path(), dbPath) - } - }) -} - -func TestDB_Queries(t *testing.T) { - t.Parallel() - - t.Run("returns non-nil ReadQueries", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - q := asDatabase(t, db).ReadQueries() - if q == nil { - t.Error("ReadQueries() returned nil") - } - }) - - t.Run("returns non-nil WriteQueries", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "test.sqlite") - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - q := asDatabase(t, db).WriteQueries() - if q == nil { - t.Error("WriteQueries() returned nil") - } - }) -} - -func TestOpen(t *testing.T) { - t.Parallel() - - t.Run("creates parent directories if they do not exist", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - // Arrange: create a temp dir and a nested path that doesn't exist - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "nested", "dirs", "test.sqlite") - - // Verify the parent doesn't exist yet - parentDir := filepath.Dir(dbPath) - if _, err := os.Stat(parentDir); !os.IsNotExist(err) { - t.Fatalf("expected parent dir to not exist, got err: %v", err) - } - - // Act - db, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("Open() error = %v", err) - } - defer db.Close() - - // Assert: parent directory was created - info, err := os.Stat(parentDir) - if err != nil { - t.Fatalf("expected parent dir to exist, got err: %v", err) - } - if !info.IsDir() { - t.Error("expected parent to be a directory") - } - - // Assert: directory has correct permissions (0700) - perm := info.Mode().Perm() - if perm != 0700 { - t.Errorf("expected permissions 0700, got %o", perm) - } - }) - - t.Run("opens existing database", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - - // Arrange: create a database first - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "existing.sqlite") - - db1, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("first Open() error = %v", err) - } - - // Create a table to verify it's a real database - _, err = db1.Exec(ctx, "CREATE TABLE test (id INTEGER PRIMARY KEY)") - if err != nil { - t.Fatalf("CREATE TABLE error = %v", err) - } - db1.Close() - - // Act: open the same database again - db2, err := Open(ctx, dbPath) - if err != nil { - t.Fatalf("second Open() error = %v", err) - } - defer db2.Close() - - // Assert: table exists - var count int64 - err = db2.QueryRow(ctx, "SELECT COUNT(*) FROM test").Scan(&count) - if err != nil { - t.Errorf("expected table to exist, got err: %v", err) - } - }) -} diff --git a/internal/sqlite/doc.go b/internal/sqlite/doc.go deleted file mode 100644 index 9f620d54..00000000 --- a/internal/sqlite/doc.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package sqlite provides the local SQLite database for the CLI. -// -// This is the primary data layer. PowerSync keeps it in sync with the server, -// but you query it directly like any SQLite database. -// -// # Code Generation -// -// The schema types are generated from the PowerSync service. To regenerate: -// -// doppler run -- go generate ./internal/sqlite -// -// This fetches the current schema and sync rules from the PowerSync API -// and generates Go types for each synced table. -package sqlite - -//go:generate go run ./generate diff --git a/internal/sqlite/error.go b/internal/sqlite/error.go deleted file mode 100644 index 04a939d7..00000000 --- a/internal/sqlite/error.go +++ /dev/null @@ -1,53 +0,0 @@ -package sqlite - -import ( - "errors" - "fmt" - - "github.com/mattn/go-sqlite3" -) - -// SQLiteError wraps a sqlite3.Error with additional context. -// It provides structured access to error codes for debugging. -type SQLiteError struct { - Code int // Primary error code - ExtendedCode int // Extended error code with more detail - Message string // Error message from sqlite3_errmsg() - Op string // Operation that failed (e.g., "insert message") -} - -func (e *SQLiteError) Error() string { - if e.Op != "" { - return fmt.Sprintf("%s: %s (code=%d, extended=%d)", e.Op, e.Message, e.Code, e.ExtendedCode) - } - return fmt.Sprintf("%s (code=%d, extended=%d)", e.Message, e.Code, e.ExtendedCode) -} - -// Unwrap returns nil since this is the root error. -func (e *SQLiteError) Unwrap() error { - return nil -} - -// WrapSQLiteError wraps an error with SQLite-specific details if available. -// If the error is not a sqlite3.Error, it returns a generic wrapped error. -func WrapSQLiteError(err error, op string) error { - if err == nil { - return nil - } - - var sqliteErr sqlite3.Error - if errors.As(err, &sqliteErr) { - return &SQLiteError{ - Code: int(sqliteErr.Code), - ExtendedCode: int(sqliteErr.ExtendedCode), - Message: err.Error(), - Op: op, - } - } - - // Not a SQLite error, wrap with context - if op != "" { - return fmt.Errorf("%s: %w", op, err) - } - return err -} diff --git a/internal/sqlite/gen/compliance_policies.sql.go b/internal/sqlite/gen/compliance_policies.sql.go deleted file mode 100644 index b326536c..00000000 --- a/internal/sqlite/gen/compliance_policies.sql.go +++ /dev/null @@ -1,117 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: compliance_policies.sql - -package gen - -import ( - "context" -) - -const countObservedPoliciesByComplianceCategory = `-- name: CountObservedPoliciesByComplianceCategory :many -SELECT - leps.category, - CAST(SUM(CASE WHEN COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || leps.category || '.fields')) f - ), 0) = 1 THEN 1 ELSE 0 END) AS INTEGER) AS observed_count -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category_type = 'compliance' AND leps.status = 'PENDING' -GROUP BY leps.category -` - -type CountObservedPoliciesByComplianceCategoryRow struct { - Category *string - ObservedCount int64 -} - -// Returns, per compliance category, how many pending policies have observed (leaking) data. -func (q *Queries) CountObservedPoliciesByComplianceCategory(ctx context.Context) ([]CountObservedPoliciesByComplianceCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, countObservedPoliciesByComplianceCategory) - if err != nil { - return nil, err - } - defer rows.Close() - var items []CountObservedPoliciesByComplianceCategoryRow - for rows.Next() { - var i CountObservedPoliciesByComplianceCategoryRow - if err := rows.Scan(&i.Category, &i.ObservedCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listPendingCompliancePoliciesByCategory = `-- name: ListPendingCompliancePoliciesByCategory :many - -SELECT - COALESCE(s.name, '') AS service_name, - COALESCE(le.name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - les.volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || ?1 || '.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -JOIN log_events le ON le.id = leps.log_event_id -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = leps.log_event_id -WHERE leps.category = ?1 AND leps.status = 'PENDING' -ORDER BY any_observed DESC, les.volume_per_hour DESC -LIMIT ?2 -` - -type ListPendingCompliancePoliciesByCategoryParams struct { - Category *string - Limit int64 -} - -type ListPendingCompliancePoliciesByCategoryRow struct { - ServiceName string - LogEventName string - Analysis string - VolumePerHour *float64 - AnyObserved int64 -} - -// Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. -// Returns pending compliance policies for a specific category, sorted by observed then volume. -func (q *Queries) ListPendingCompliancePoliciesByCategory(ctx context.Context, arg ListPendingCompliancePoliciesByCategoryParams) ([]ListPendingCompliancePoliciesByCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, listPendingCompliancePoliciesByCategory, arg.Category, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListPendingCompliancePoliciesByCategoryRow - for rows.Next() { - var i ListPendingCompliancePoliciesByCategoryRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.Analysis, - &i.VolumePerHour, - &i.AnyObserved, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/conversations.sql.go b/internal/sqlite/gen/conversations.sql.go deleted file mode 100644 index d72179a4..00000000 --- a/internal/sqlite/gen/conversations.sql.go +++ /dev/null @@ -1,135 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: conversations.sql - -package gen - -import ( - "context" -) - -const countConversations = `-- name: CountConversations :one -SELECT COUNT(*) FROM conversations -` - -func (q *Queries) CountConversations(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countConversations) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getConversation = `-- name: GetConversation :one -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations WHERE id = ? -` - -func (q *Queries) GetConversation(ctx context.Context, id *string) (Conversation, error) { - row := q.db.QueryRowContext(ctx, getConversation, id) - var i Conversation - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ) - return i, err -} - -const getLatestConversationByAccount = `-- name: GetLatestConversationByAccount :one -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLatestConversationByAccount(ctx context.Context, accountID *string) (Conversation, error) { - row := q.db.QueryRowContext(ctx, getLatestConversationByAccount, accountID) - var i Conversation - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ) - return i, err -} - -const insertConversation = `-- name: InsertConversation :exec -INSERT INTO conversations (id, account_id, workspace_id, created_at) -VALUES (?, ?, ?, ?) -` - -type InsertConversationParams struct { - ID *string - AccountID *string - WorkspaceID *string - CreatedAt *string -} - -func (q *Queries) InsertConversation(ctx context.Context, arg InsertConversationParams) error { - _, err := q.db.ExecContext(ctx, insertConversation, - arg.ID, - arg.AccountID, - arg.WorkspaceID, - arg.CreatedAt, - ) - return err -} - -const listConversationsByAccount = `-- name: ListConversationsByAccount :many -SELECT id, account_id, created_at, title, user_id, view_id, workspace_id FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -` - -func (q *Queries) ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) { - rows, err := q.db.QueryContext(ctx, listConversationsByAccount, accountID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Conversation - for rows.Next() { - var i Conversation - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Title, - &i.UserID, - &i.ViewID, - &i.WorkspaceID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateConversationTitle = `-- name: UpdateConversationTitle :exec -UPDATE conversations SET title = ? WHERE id = ? -` - -type UpdateConversationTitleParams struct { - Title *string - ID *string -} - -func (q *Queries) UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error { - _, err := q.db.ExecContext(ctx, updateConversationTitle, arg.Title, arg.ID) - return err -} diff --git a/internal/sqlite/gen/datadog_account_statuses.sql.go b/internal/sqlite/gen/datadog_account_statuses.sql.go deleted file mode 100644 index 36227698..00000000 --- a/internal/sqlite/gen/datadog_account_statuses.sql.go +++ /dev/null @@ -1,157 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: datadog_account_statuses.sql - -package gen - -import ( - "context" -) - -const getAccountSummary = `-- name: GetAccountSummary :one -SELECT - -- ready - CAST(COALESCE(MAX(ready_for_use), 0) AS INTEGER) AS ready_for_use, - - -- health - COALESCE(MAX(health), '') AS health, - - -- services - CAST(COALESCE(SUM(log_service_count), 0) AS INTEGER) AS service_count, - CAST(COALESCE(SUM(log_active_services), 0) AS INTEGER) AS active_services, - CAST(COALESCE(SUM(ok_services), 0) AS INTEGER) AS ok_services, - CAST(COALESCE(SUM(disabled_services), 0) AS INTEGER) AS disabled_services, - CAST(COALESCE(SUM(inactive_services), 0) AS INTEGER) AS inactive_services, - - -- events - CAST(COALESCE(SUM(log_event_count), 0) AS INTEGER) AS event_count, - CAST(COALESCE(SUM(log_event_analyzed_count), 0) AS INTEGER) AS analyzed_count, - - -- policies - CAST(COALESCE(SUM(policy_pending_count), 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(SUM(policy_approved_count), 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(SUM(policy_dismissed_count), 0) AS INTEGER) AS dismissed_policy_count, - CAST(COALESCE(SUM(policy_pending_critical_count), 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(SUM(policy_pending_high_count), 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(SUM(policy_pending_medium_count), 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(SUM(policy_pending_low_count), 0) AS INTEGER) AS policy_pending_low_count, - - -- estimated savings - SUM(estimated_cost_reduction_per_hour_usd) AS estimated_cost_per_hour, - SUM(estimated_cost_reduction_per_hour_bytes_usd) AS estimated_cost_per_hour_bytes, - SUM(estimated_cost_reduction_per_hour_volume_usd) AS estimated_cost_per_hour_volume, - SUM(estimated_volume_reduction_per_hour) AS estimated_volume_per_hour, - SUM(estimated_bytes_reduction_per_hour) AS estimated_bytes_per_hour, - - -- observed impact - SUM(observed_cost_per_hour_before_usd) AS observed_cost_before, - SUM(observed_cost_per_hour_before_bytes_usd) AS observed_cost_before_bytes, - SUM(observed_cost_per_hour_before_volume_usd) AS observed_cost_before_volume, - SUM(observed_cost_per_hour_after_usd) AS observed_cost_after, - SUM(observed_cost_per_hour_after_bytes_usd) AS observed_cost_after_bytes, - SUM(observed_cost_per_hour_after_volume_usd) AS observed_cost_after_volume, - SUM(observed_volume_per_hour_before) AS observed_volume_before, - SUM(observed_volume_per_hour_after) AS observed_volume_after, - SUM(observed_bytes_per_hour_before) AS observed_bytes_before, - SUM(observed_bytes_per_hour_after) AS observed_bytes_after, - - -- totals - SUM(log_event_cost_per_hour_usd) AS total_cost_per_hour, - SUM(log_event_cost_per_hour_bytes_usd) AS total_cost_per_hour_bytes, - SUM(log_event_cost_per_hour_volume_usd) AS total_cost_per_hour_volume, - SUM(log_event_volume_per_hour) AS total_volume_per_hour, - SUM(log_event_bytes_per_hour) AS total_bytes_per_hour, - - -- service-level throughput - SUM(service_volume_per_hour) AS total_service_volume_per_hour, - SUM(service_cost_per_hour_volume_usd) AS total_service_cost_per_hour -FROM datadog_account_statuses_cache -` - -type GetAccountSummaryRow struct { - ReadyForUse int64 - Health interface{} - ServiceCount int64 - ActiveServices int64 - OkServices int64 - DisabledServices int64 - InactiveServices int64 - EventCount int64 - AnalyzedCount int64 - PendingPolicyCount int64 - ApprovedPolicyCount int64 - DismissedPolicyCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - EstimatedCostPerHour *float64 - EstimatedCostPerHourBytes *float64 - EstimatedCostPerHourVolume *float64 - EstimatedVolumePerHour *float64 - EstimatedBytesPerHour *float64 - ObservedCostBefore *float64 - ObservedCostBeforeBytes *float64 - ObservedCostBeforeVolume *float64 - ObservedCostAfter *float64 - ObservedCostAfterBytes *float64 - ObservedCostAfterVolume *float64 - ObservedVolumeBefore *float64 - ObservedVolumeAfter *float64 - ObservedBytesBefore *float64 - ObservedBytesAfter *float64 - TotalCostPerHour *float64 - TotalCostPerHourBytes *float64 - TotalCostPerHourVolume *float64 - TotalVolumePerHour *float64 - TotalBytesPerHour *float64 - TotalServiceVolumePerHour *float64 - TotalServiceCostPerHour *float64 -} - -func (q *Queries) GetAccountSummary(ctx context.Context) (GetAccountSummaryRow, error) { - row := q.db.QueryRowContext(ctx, getAccountSummary) - var i GetAccountSummaryRow - err := row.Scan( - &i.ReadyForUse, - &i.Health, - &i.ServiceCount, - &i.ActiveServices, - &i.OkServices, - &i.DisabledServices, - &i.InactiveServices, - &i.EventCount, - &i.AnalyzedCount, - &i.PendingPolicyCount, - &i.ApprovedPolicyCount, - &i.DismissedPolicyCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.EstimatedCostPerHour, - &i.EstimatedCostPerHourBytes, - &i.EstimatedCostPerHourVolume, - &i.EstimatedVolumePerHour, - &i.EstimatedBytesPerHour, - &i.ObservedCostBefore, - &i.ObservedCostBeforeBytes, - &i.ObservedCostBeforeVolume, - &i.ObservedCostAfter, - &i.ObservedCostAfterBytes, - &i.ObservedCostAfterVolume, - &i.ObservedVolumeBefore, - &i.ObservedVolumeAfter, - &i.ObservedBytesBefore, - &i.ObservedBytesAfter, - &i.TotalCostPerHour, - &i.TotalCostPerHourBytes, - &i.TotalCostPerHourVolume, - &i.TotalVolumePerHour, - &i.TotalBytesPerHour, - &i.TotalServiceVolumePerHour, - &i.TotalServiceCostPerHour, - ) - return i, err -} diff --git a/internal/sqlite/gen/db.go b/internal/sqlite/gen/db.go deleted file mode 100644 index d577e39d..00000000 --- a/internal/sqlite/gen/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go deleted file mode 100644 index e1548fc5..00000000 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policies.sql - -package gen - -import ( - "context" -) - -const approveLogEventPolicy = `-- name: ApproveLogEventPolicy :exec -UPDATE log_event_policies -SET approved_at = ?, approved_by = ? -WHERE id = ? -` - -type ApproveLogEventPolicyParams struct { - ApprovedAt *string - ApprovedBy *string - ID *string -} - -func (q *Queries) ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error { - _, err := q.db.ExecContext(ctx, approveLogEventPolicy, arg.ApprovedAt, arg.ApprovedBy, arg.ID) - return err -} - -const countLogEventPolicies = `-- name: CountLogEventPolicies :one -SELECT COUNT(*) FROM log_event_policies -` - -func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countLogEventPolicies) - var count int64 - err := row.Scan(&count) - return count, err -} - -const dismissLogEventPolicy = `-- name: DismissLogEventPolicy :exec -UPDATE log_event_policies -SET dismissed_at = ?, dismissed_by = ? -WHERE id = ? -` - -type DismissLogEventPolicyParams struct { - DismissedAt *string - DismissedBy *string - ID *string -} - -func (q *Queries) DismissLogEventPolicy(ctx context.Context, arg DismissLogEventPolicyParams) error { - _, err := q.db.ExecContext(ctx, dismissLogEventPolicy, arg.DismissedAt, arg.DismissedBy, arg.ID) - return err -} diff --git a/internal/sqlite/gen/log_event_policy_category_statuses.sql.go b/internal/sqlite/gen/log_event_policy_category_statuses.sql.go deleted file mode 100644 index 076d4a02..00000000 --- a/internal/sqlite/gen/log_event_policy_category_statuses.sql.go +++ /dev/null @@ -1,103 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policy_category_statuses.sql - -package gen - -import ( - "context" -) - -const listCategoryStatusesByCostAndType = `-- name: ListCategoryStatusesByCostAndType :many -SELECT - COALESCE(category, '') AS category, - COALESCE(category_type, '') AS category_type, - COALESCE("action", '') AS policy_action, - COALESCE(display_name, '') AS display_name, - COALESCE(principle, '') AS principle, - CAST(COALESCE(pending_count, 0) AS INTEGER) AS pending_count, - CAST(COALESCE(approved_count, 0) AS INTEGER) AS approved_count, - CAST(COALESCE(dismissed_count, 0) AS INTEGER) AS dismissed_count, - estimated_volume_reduction_per_hour, - estimated_bytes_reduction_per_hour, - estimated_cost_reduction_per_hour_usd, - estimated_cost_reduction_per_hour_bytes_usd, - estimated_cost_reduction_per_hour_volume_usd, - CAST(COALESCE(events_with_volumes, 0) AS INTEGER) AS events_with_volumes, - CAST(COALESCE(total_event_count, 0) AS INTEGER) AS total_event_count, - CAST(COALESCE(policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_event_policy_category_statuses_cache -WHERE category IS NOT NULL AND category != '' - AND category_type = ?1 -ORDER BY estimated_cost_reduction_per_hour_usd DESC NULLS LAST, pending_count DESC -` - -type ListCategoryStatusesByCostAndTypeRow struct { - Category string - CategoryType string - PolicyAction string - DisplayName string - Principle string - PendingCount int64 - ApprovedCount int64 - DismissedCount int64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EventsWithVolumes int64 - TotalEventCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 -} - -// Pre-computed per-category rollup filtered by category_type (waste or compliance). -func (q *Queries) ListCategoryStatusesByCostAndType(ctx context.Context, categoryType *string) ([]ListCategoryStatusesByCostAndTypeRow, error) { - rows, err := q.db.QueryContext(ctx, listCategoryStatusesByCostAndType, categoryType) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListCategoryStatusesByCostAndTypeRow - for rows.Next() { - var i ListCategoryStatusesByCostAndTypeRow - if err := rows.Scan( - &i.Category, - &i.CategoryType, - &i.PolicyAction, - &i.DisplayName, - &i.Principle, - &i.PendingCount, - &i.ApprovedCount, - &i.DismissedCount, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.EventsWithVolumes, - &i.TotalEventCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_event_policy_statuses.sql.go b/internal/sqlite/gen/log_event_policy_statuses.sql.go deleted file mode 100644 index b6bb64d1..00000000 --- a/internal/sqlite/gen/log_event_policy_statuses.sql.go +++ /dev/null @@ -1,142 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_policy_statuses.sql - -package gen - -import ( - "context" -) - -const countFixedPIIPolicies = `-- name: CountFixedPIIPolicies :one -SELECT CAST(COUNT(*) AS INTEGER) FROM log_event_policy_statuses_cache -WHERE category = 'pii_leakage' AND status = 'APPROVED' -` - -func (q *Queries) CountFixedPIIPolicies(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countFixedPIIPolicies) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const listPendingPIIPolicies = `-- name: ListPendingPIIPolicies :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.pii_leakage.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category = 'pii_leakage' AND leps.status = 'PENDING' -ORDER BY any_observed DESC, leps.volume_per_hour DESC -` - -type ListPendingPIIPoliciesRow struct { - ServiceName string - LogEventName string - Analysis string - VolumePerHour *float64 - AnyObserved int64 -} - -func (q *Queries) ListPendingPIIPolicies(ctx context.Context) ([]ListPendingPIIPoliciesRow, error) { - rows, err := q.db.QueryContext(ctx, listPendingPIIPolicies) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListPendingPIIPoliciesRow - for rows.Next() { - var i ListPendingPIIPoliciesRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.Analysis, - &i.VolumePerHour, - &i.AnyObserved, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listTopPendingPoliciesByCategory = `-- name: ListTopPendingPoliciesByCategory :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - volume_per_hour, - bytes_per_hour, - estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - estimated_cost_reduction_per_hour_bytes_usd AS estimated_cost_per_hour_bytes, - estimated_cost_reduction_per_hour_volume_usd AS estimated_cost_per_hour_volume, - estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - estimated_volume_reduction_per_hour AS estimated_volume_per_hour -FROM log_event_policy_statuses_cache -WHERE category = ?1 AND status = 'PENDING' -ORDER BY estimated_cost_reduction_per_hour_usd DESC, volume_per_hour DESC -LIMIT ?2 -` - -type ListTopPendingPoliciesByCategoryParams struct { - Category *string - Limit int64 -} - -type ListTopPendingPoliciesByCategoryRow struct { - ServiceName string - LogEventName string - VolumePerHour *float64 - BytesPerHour *float64 - EstimatedCostPerHour *float64 - EstimatedCostPerHourBytes *float64 - EstimatedCostPerHourVolume *float64 - EstimatedBytesPerHour *float64 - EstimatedVolumePerHour *float64 -} - -func (q *Queries) ListTopPendingPoliciesByCategory(ctx context.Context, arg ListTopPendingPoliciesByCategoryParams) ([]ListTopPendingPoliciesByCategoryRow, error) { - rows, err := q.db.QueryContext(ctx, listTopPendingPoliciesByCategory, arg.Category, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListTopPendingPoliciesByCategoryRow - for rows.Next() { - var i ListTopPendingPoliciesByCategoryRow - if err := rows.Scan( - &i.ServiceName, - &i.LogEventName, - &i.VolumePerHour, - &i.BytesPerHour, - &i.EstimatedCostPerHour, - &i.EstimatedCostPerHourBytes, - &i.EstimatedCostPerHourVolume, - &i.EstimatedBytesPerHour, - &i.EstimatedVolumePerHour, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_event_statuses.sql.go b/internal/sqlite/gen/log_event_statuses.sql.go deleted file mode 100644 index a8454883..00000000 --- a/internal/sqlite/gen/log_event_statuses.sql.go +++ /dev/null @@ -1,82 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_event_statuses.sql - -package gen - -import ( - "context" -) - -const listLogEventStatusesByService = `-- name: ListLogEventStatusesByService :many -SELECT - COALESCE(le.name, '') AS log_event_name, - les.volume_per_hour, - les.bytes_per_hour, - les.cost_per_hour_usd, - CAST(COALESCE(les.pending_policy_count, 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(les.approved_policy_count, 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(les.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(les.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(les.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(les.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_events le -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = le.id -WHERE s.name = ?1 -ORDER BY les.cost_per_hour_usd DESC, les.volume_per_hour DESC -LIMIT ?2 -` - -type ListLogEventStatusesByServiceParams struct { - Name *string - Limit int64 -} - -type ListLogEventStatusesByServiceRow struct { - LogEventName string - VolumePerHour *float64 - BytesPerHour *float64 - CostPerHourUsd *float64 - PendingPolicyCount int64 - ApprovedPolicyCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 -} - -func (q *Queries) ListLogEventStatusesByService(ctx context.Context, arg ListLogEventStatusesByServiceParams) ([]ListLogEventStatusesByServiceRow, error) { - rows, err := q.db.QueryContext(ctx, listLogEventStatusesByService, arg.Name, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListLogEventStatusesByServiceRow - for rows.Next() { - var i ListLogEventStatusesByServiceRow - if err := rows.Scan( - &i.LogEventName, - &i.VolumePerHour, - &i.BytesPerHour, - &i.CostPerHourUsd, - &i.PendingPolicyCount, - &i.ApprovedPolicyCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/log_events.sql.go b/internal/sqlite/gen/log_events.sql.go deleted file mode 100644 index 83e29f25..00000000 --- a/internal/sqlite/gen/log_events.sql.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: log_events.sql - -package gen - -import ( - "context" -) - -const countLogEvents = `-- name: CountLogEvents :one -SELECT COUNT(*) FROM log_events -` - -func (q *Queries) CountLogEvents(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countLogEvents) - var count int64 - err := row.Scan(&count) - return count, err -} diff --git a/internal/sqlite/gen/messages.sql.go b/internal/sqlite/gen/messages.sql.go deleted file mode 100644 index 723700e4..00000000 --- a/internal/sqlite/gen/messages.sql.go +++ /dev/null @@ -1,219 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: messages.sql - -package gen - -import ( - "context" -) - -const countMessages = `-- name: CountMessages :one -SELECT COUNT(*) FROM messages -` - -func (q *Queries) CountMessages(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countMessages) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countMessagesByConversation = `-- name: CountMessagesByConversation :one -SELECT COUNT(*) FROM messages WHERE conversation_id = ? -` - -func (q *Queries) CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, error) { - row := q.db.QueryRowContext(ctx, countMessagesByConversation, conversationID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const deleteMessage = `-- name: DeleteMessage :exec -DELETE FROM messages WHERE id = ? -` - -func (q *Queries) DeleteMessage(ctx context.Context, id *string) error { - _, err := q.db.ExecContext(ctx, deleteMessage, id) - return err -} - -const getLatestMessageByConversation = `-- name: GetLatestMessageByConversation :one -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetLatestMessageByConversation(ctx context.Context, conversationID *string) (Message, error) { - row := q.db.QueryRowContext(ctx, getLatestMessageByConversation, conversationID) - var i Message - err := row.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ) - return i, err -} - -const getMessage = `-- name: GetMessage :one -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages WHERE id = ? -` - -func (q *Queries) GetMessage(ctx context.Context, id *string) (Message, error) { - row := q.db.QueryRowContext(ctx, getMessage, id) - var i Message - err := row.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ) - return i, err -} - -const insertMessage = `-- name: InsertMessage :exec -INSERT INTO messages (id, account_id, content, conversation_id, created_at, model, role, stop_reason) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -` - -type InsertMessageParams struct { - ID *string - AccountID *string - Content *string - ConversationID *string - CreatedAt *string - Model *string - Role *string - StopReason *string -} - -func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) error { - _, err := q.db.ExecContext(ctx, insertMessage, - arg.ID, - arg.AccountID, - arg.Content, - arg.ConversationID, - arg.CreatedAt, - arg.Model, - arg.Role, - arg.StopReason, - ) - return err -} - -const listMessagesByConversation = `-- name: ListMessagesByConversation :many -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at ASC -` - -func (q *Queries) ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, listMessagesByConversation, conversationID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listMessagesByConversationDesc = `-- name: ListMessagesByConversationDesc :many -SELECT id, account_id, content, conversation_id, created_at, model, role, stop_reason FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -` - -func (q *Queries) ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) { - rows, err := q.db.QueryContext(ctx, listMessagesByConversationDesc, conversationID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Message - for rows.Next() { - var i Message - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.Content, - &i.ConversationID, - &i.CreatedAt, - &i.Model, - &i.Role, - &i.StopReason, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateMessageContent = `-- name: UpdateMessageContent :exec -UPDATE messages SET content = ? WHERE id = ? -` - -type UpdateMessageContentParams struct { - Content *string - ID *string -} - -func (q *Queries) UpdateMessageContent(ctx context.Context, arg UpdateMessageContentParams) error { - _, err := q.db.ExecContext(ctx, updateMessageContent, arg.Content, arg.ID) - return err -} - -const updateMessageMeta = `-- name: UpdateMessageMeta :exec -UPDATE messages SET model = ?, stop_reason = ? WHERE id = ? -` - -type UpdateMessageMetaParams struct { - Model *string - StopReason *string - ID *string -} - -func (q *Queries) UpdateMessageMeta(ctx context.Context, arg UpdateMessageMetaParams) error { - _, err := q.db.ExecContext(ctx, updateMessageMeta, arg.Model, arg.StopReason, arg.ID) - return err -} diff --git a/internal/sqlite/gen/models.go b/internal/sqlite/gen/models.go deleted file mode 100644 index 56484f63..00000000 --- a/internal/sqlite/gen/models.go +++ /dev/null @@ -1,325 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -type Conversation struct { - ID *string - AccountID *string - CreatedAt *string - Title *string - UserID *string - ViewID *string - WorkspaceID *string -} - -type ConversationContext struct { - ID *string - AccountID *string - AddedBy *string - ConversationID *string - CreatedAt *string - EntityID *string - EntityType *string -} - -type DatadogAccount struct { - ID *string - AccountID *string - CostPerGbIngested *float64 - CreatedAt *string - Name *string - Site *string -} - -type DatadogAccountStatusesCache struct { - ID *string - AccountID *string - DatadogAccountID *string - DisabledServices *int64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - Health *string - InactiveServices *int64 - LogActiveServices *int64 - LogEventAnalyzedCount *int64 - LogEventBytesPerHour *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - LogEventCount *int64 - LogEventVolumePerHour *float64 - LogServiceCount *int64 - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - OkServices *int64 - PolicyApprovedCount *int64 - PolicyDismissedCount *int64 - PolicyPendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ReadyForUse *int64 - ServiceCostPerHourVolumeUsd *float64 - ServiceVolumePerHour *float64 -} - -type DatadogLogIndex struct { - ID *string - AccountID *string - CostPerMillionEventsIndexed *float64 - CreatedAt *string - DatadogAccountID *string - Name *string -} - -type LogEvent struct { - ID *string - AccountID *string - BaselineAvgBytes *float64 - BaselineVolumePerHour *float64 - CreatedAt *string - Description *string - EventNature *string - Examples *string - Matchers *string - Name *string - ServiceID *string - Severity *string - SignalPurpose *string -} - -type LogEventField struct { - ID *string - AccountID *string - BaselineAvgBytes *float64 - CreatedAt *string - FieldPath *string - LogEventID *string - ValueDistribution *string -} - -type LogEventPolicy struct { - ID *string - AccountID *string - Action *string - Analysis *string - ApprovedAt *string - ApprovedBaselineAvgBytes *float64 - ApprovedBaselineVolumePerHour *float64 - ApprovedBy *string - Category *string - CategoryType *string - CreatedAt *string - DismissedAt *string - DismissedBy *string - LogEventID *string - Severity *string - Subjective *int64 - WorkspaceID *string -} - -type LogEventPolicyCategoryStatusesCache struct { - ID *string - AccountID *string - Action *string - ApprovedCount *int64 - Boundary *string - Category *string - CategoryType *string - DismissedCount *int64 - DisplayName *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EventsWithVolumes *int64 - PendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - Principle *string - Subjective *int64 - TotalEventCount *int64 -} - -type LogEventPolicyStatusesCache struct { - ID *string - AccountID *string - Action *string - ApprovedAt *string - BytesPerHour *float64 - Category *string - CategoryType *string - CreatedAt *string - DismissedAt *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - LogEventID *string - LogEventName *string - PolicyID *string - ServiceID *string - ServiceName *string - Severity *string - Status *string - Subjective *int64 - SurvivalRate *float64 - VolumePerHour *float64 - WorkspaceID *string -} - -type LogEventStatusesCache struct { - ID *string - AccountID *string - ApprovedPolicyCount *int64 - BytesPerHour *float64 - CostPerHourBytesUsd *float64 - CostPerHourUsd *float64 - CostPerHourVolumeUsd *float64 - DismissedPolicyCount *int64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - HasBeenAnalyzed *int64 - HasVolumes *int64 - LogEventID *string - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - PendingPolicyCount *int64 - PolicyCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ServiceID *string - VolumePerHour *float64 -} - -type Message struct { - ID *string - AccountID *string - Content *string - ConversationID *string - CreatedAt *string - Model *string - Role *string - StopReason *string -} - -type Service struct { - ID *string - AccountID *string - CreatedAt *string - Description *string - Enabled *int64 - InitialWeeklyLogCount *int64 - Name *string -} - -type ServiceStatusesCache struct { - ID *string - AccountID *string - DatadogAccountID *string - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - Health *string - LogEventAnalyzedCount *int64 - LogEventBytesPerHour *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - LogEventCount *int64 - LogEventVolumePerHour *float64 - ObservedBytesPerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedVolumePerHourAfter *float64 - ObservedVolumePerHourBefore *float64 - PolicyApprovedCount *int64 - PolicyDismissedCount *int64 - PolicyPendingCount *int64 - PolicyPendingCriticalCount *int64 - PolicyPendingHighCount *int64 - PolicyPendingLowCount *int64 - PolicyPendingMediumCount *int64 - ServiceCostPerHourVolumeUsd *float64 - ServiceDebugVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceID *string - ServiceInfoVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 -} - -type Team struct { - ID *string - AccountID *string - CreatedAt *string - Name *string - WorkspaceID *string -} - -type View struct { - ID *string - AccountID *string - ConversationID *string - CreatedAt *string - CreatedBy *string - EntityType *string - ForkedFromID *string - MessageID *string - Query *string -} - -type ViewFavorite struct { - ID *string - AccountID *string - CreatedAt *string - UserID *string - ViewID *string -} - -type Workspace struct { - ID *string - AccountID *string - CreatedAt *string - Name *string - Purpose *string -} diff --git a/internal/sqlite/gen/policy_cards.sql.go b/internal/sqlite/gen/policy_cards.sql.go deleted file mode 100644 index d80d7bfe..00000000 --- a/internal/sqlite/gen/policy_cards.sql.go +++ /dev/null @@ -1,132 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: policy_cards.sql - -package gen - -import ( - "context" -) - -const getPolicyCard = `-- name: GetPolicyCard :one -SELECT - COALESCE(ps.policy_id, '') AS policy_id, - COALESCE(ps.service_name, '') AS service_name, - COALESCE(ps.log_event_name, '') AS log_event_name, - COALESCE(ps.category, '') AS category, - COALESCE(ps.category_type, '') AS category_type, - COALESCE(ps.action, '') AS "action", - COALESCE(ps.status, '') AS status, - ps.volume_per_hour, - ps.bytes_per_hour, - ps.estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - ps.estimated_volume_reduction_per_hour AS estimated_volume_per_hour, - ps.estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - COALESCE(ps.severity, '') AS severity, - ps.survival_rate, - COALESCE(cat.display_name, '') AS category_display_name, - COALESCE(lep.analysis, '') AS analysis, - COALESCE(le.examples, '') AS examples, - le.baseline_avg_bytes AS event_baseline_avg_bytes, - le.baseline_volume_per_hour AS event_baseline_volume_per_hour, - COALESCE(ps.log_event_id, '') AS log_event_id -FROM log_event_policy_statuses_cache ps -LEFT JOIN log_event_policy_category_statuses_cache cat ON cat.category = ps.category -LEFT JOIN log_event_policies lep ON lep.id = ps.policy_id -LEFT JOIN log_events le ON le.id = ps.log_event_id -WHERE ps.policy_id = ?1 -` - -type GetPolicyCardRow struct { - PolicyID string - ServiceName string - LogEventName string - Category string - CategoryType string - Action string - Status string - VolumePerHour *float64 - BytesPerHour *float64 - EstimatedCostPerHour *float64 - EstimatedVolumePerHour *float64 - EstimatedBytesPerHour *float64 - Severity string - SurvivalRate *float64 - CategoryDisplayName string - Analysis string - Examples string - EventBaselineAvgBytes *float64 - EventBaselineVolumePerHour *float64 - LogEventID string -} - -// Fetches a single policy with all context needed for rich card rendering. -// Main table: log_event_policy_statuses_cache (denormalized policy + metrics). -// JOINs enrich with: category display name, AI analysis, log examples, baselines. -func (q *Queries) GetPolicyCard(ctx context.Context, policyID *string) (GetPolicyCardRow, error) { - row := q.db.QueryRowContext(ctx, getPolicyCard, policyID) - var i GetPolicyCardRow - err := row.Scan( - &i.PolicyID, - &i.ServiceName, - &i.LogEventName, - &i.Category, - &i.CategoryType, - &i.Action, - &i.Status, - &i.VolumePerHour, - &i.BytesPerHour, - &i.EstimatedCostPerHour, - &i.EstimatedVolumePerHour, - &i.EstimatedBytesPerHour, - &i.Severity, - &i.SurvivalRate, - &i.CategoryDisplayName, - &i.Analysis, - &i.Examples, - &i.EventBaselineAvgBytes, - &i.EventBaselineVolumePerHour, - &i.LogEventID, - ) - return i, err -} - -const listFieldsByLogEvent = `-- name: ListFieldsByLogEvent :many -SELECT - COALESCE(field_path, '') AS field_path, - baseline_avg_bytes -FROM log_event_fields -WHERE log_event_id = ?1 -ORDER BY baseline_avg_bytes DESC -` - -type ListFieldsByLogEventRow struct { - FieldPath string - BaselineAvgBytes *float64 -} - -// Returns per-field metadata for a log event, used to show per-field byte impact -// in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). -func (q *Queries) ListFieldsByLogEvent(ctx context.Context, logEventID *string) ([]ListFieldsByLogEventRow, error) { - rows, err := q.db.QueryContext(ctx, listFieldsByLogEvent, logEventID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListFieldsByLogEventRow - for rows.Next() { - var i ListFieldsByLogEventRow - if err := rows.Scan(&i.FieldPath, &i.BaselineAvgBytes); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go deleted file mode 100644 index 02a3dcf1..00000000 --- a/internal/sqlite/gen/querier.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package gen - -import ( - "context" -) - -type Querier interface { - ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error - CountConversations(ctx context.Context) (int64, error) - CountFixedPIIPolicies(ctx context.Context) (int64, error) - CountLogEventPolicies(ctx context.Context) (int64, error) - CountLogEvents(ctx context.Context) (int64, error) - CountMessages(ctx context.Context) (int64, error) - CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, error) - // Returns, per compliance category, how many pending policies have observed (leaking) data. - CountObservedPoliciesByComplianceCategory(ctx context.Context) ([]CountObservedPoliciesByComplianceCategoryRow, error) - CountServices(ctx context.Context) (int64, error) - DeleteMessage(ctx context.Context, id *string) error - DismissLogEventPolicy(ctx context.Context, arg DismissLogEventPolicyParams) error - GetAccountSummary(ctx context.Context) (GetAccountSummaryRow, error) - GetConversation(ctx context.Context, id *string) (Conversation, error) - GetLatestConversationByAccount(ctx context.Context, accountID *string) (Conversation, error) - GetLatestMessageByConversation(ctx context.Context, conversationID *string) (Message, error) - GetMessage(ctx context.Context, id *string) (Message, error) - // Fetches a single policy with all context needed for rich card rendering. - // Main table: log_event_policy_statuses_cache (denormalized policy + metrics). - // JOINs enrich with: category display name, AI analysis, log examples, baselines. - GetPolicyCard(ctx context.Context, policyID *string) (GetPolicyCardRow, error) - GetService(ctx context.Context, id *string) (Service, error) - InsertConversation(ctx context.Context, arg InsertConversationParams) error - InsertMessage(ctx context.Context, arg InsertMessageParams) error - ListAllServiceStatuses(ctx context.Context) ([]ListAllServiceStatusesRow, error) - // Pre-computed per-category rollup filtered by category_type (waste or compliance). - ListCategoryStatusesByCostAndType(ctx context.Context, categoryType *string) ([]ListCategoryStatusesByCostAndTypeRow, error) - ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) - ListEnabledServiceStatuses(ctx context.Context, rowLimit int64) ([]ListEnabledServiceStatusesRow, error) - // Returns per-field metadata for a log event, used to show per-field byte impact - // in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). - ListFieldsByLogEvent(ctx context.Context, logEventID *string) ([]ListFieldsByLogEventRow, error) - ListLogEventStatusesByService(ctx context.Context, arg ListLogEventStatusesByServiceParams) ([]ListLogEventStatusesByServiceRow, error) - ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) - ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) - // Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. - // Returns pending compliance policies for a specific category, sorted by observed then volume. - ListPendingCompliancePoliciesByCategory(ctx context.Context, arg ListPendingCompliancePoliciesByCategoryParams) ([]ListPendingCompliancePoliciesByCategoryRow, error) - ListPendingPIIPolicies(ctx context.Context) ([]ListPendingPIIPoliciesRow, error) - ListServices(ctx context.Context) ([]Service, error) - ListServicesByAccount(ctx context.Context, accountID *string) ([]Service, error) - ListTopPendingPoliciesByCategory(ctx context.Context, arg ListTopPendingPoliciesByCategoryParams) ([]ListTopPendingPoliciesByCategoryRow, error) - SetServiceEnabled(ctx context.Context, arg SetServiceEnabledParams) error - UpdateConversationTitle(ctx context.Context, arg UpdateConversationTitleParams) error - UpdateMessageContent(ctx context.Context, arg UpdateMessageContentParams) error - UpdateMessageMeta(ctx context.Context, arg UpdateMessageMetaParams) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/sqlite/gen/service_statuses.sql.go b/internal/sqlite/gen/service_statuses.sql.go deleted file mode 100644 index b748e219..00000000 --- a/internal/sqlite/gen/service_statuses.sql.go +++ /dev/null @@ -1,322 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: service_statuses.sql - -package gen - -import ( - "context" -) - -const listAllServiceStatuses = `-- name: ListAllServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - WHEN 'DISABLED' THEN 2 - WHEN 'INACTIVE' THEN 3 - ELSE 4 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -` - -type ListAllServiceStatusesRow struct { - ServiceName *string - Health string - LogEventCount int64 - LogEventAnalyzedCount int64 - PolicyPendingCount int64 - PolicyApprovedCount int64 - PolicyDismissedCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - ServiceVolumePerHour *float64 - ServiceDebugVolumePerHour *float64 - ServiceInfoVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceCostPerHourVolumeUsd *float64 - LogEventVolumePerHour *float64 - LogEventBytesPerHour *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - ObservedVolumePerHourBefore *float64 - ObservedVolumePerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedBytesPerHourAfter *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 -} - -func (q *Queries) ListAllServiceStatuses(ctx context.Context) ([]ListAllServiceStatusesRow, error) { - rows, err := q.db.QueryContext(ctx, listAllServiceStatuses) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListAllServiceStatusesRow - for rows.Next() { - var i ListAllServiceStatusesRow - if err := rows.Scan( - &i.ServiceName, - &i.Health, - &i.LogEventCount, - &i.LogEventAnalyzedCount, - &i.PolicyPendingCount, - &i.PolicyApprovedCount, - &i.PolicyDismissedCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.ServiceVolumePerHour, - &i.ServiceDebugVolumePerHour, - &i.ServiceInfoVolumePerHour, - &i.ServiceWarnVolumePerHour, - &i.ServiceErrorVolumePerHour, - &i.ServiceOtherVolumePerHour, - &i.ServiceCostPerHourVolumeUsd, - &i.LogEventVolumePerHour, - &i.LogEventBytesPerHour, - &i.LogEventCostPerHourUsd, - &i.LogEventCostPerHourBytesUsd, - &i.LogEventCostPerHourVolumeUsd, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.ObservedVolumePerHourBefore, - &i.ObservedVolumePerHourAfter, - &i.ObservedBytesPerHourBefore, - &i.ObservedBytesPerHourAfter, - &i.ObservedCostPerHourBeforeUsd, - &i.ObservedCostPerHourBeforeBytesUsd, - &i.ObservedCostPerHourBeforeVolumeUsd, - &i.ObservedCostPerHourAfterUsd, - &i.ObservedCostPerHourAfterBytesUsd, - &i.ObservedCostPerHourAfterVolumeUsd, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listEnabledServiceStatuses = `-- name: ListEnabledServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -WHERE ssc.health NOT IN ('DISABLED', 'INACTIVE') -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - ELSE 2 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -LIMIT ?1 -` - -type ListEnabledServiceStatusesRow struct { - ServiceName *string - Health string - LogEventCount int64 - LogEventAnalyzedCount int64 - PolicyPendingCount int64 - PolicyApprovedCount int64 - PolicyDismissedCount int64 - PolicyPendingCriticalCount int64 - PolicyPendingHighCount int64 - PolicyPendingMediumCount int64 - PolicyPendingLowCount int64 - ServiceVolumePerHour *float64 - ServiceDebugVolumePerHour *float64 - ServiceInfoVolumePerHour *float64 - ServiceWarnVolumePerHour *float64 - ServiceErrorVolumePerHour *float64 - ServiceOtherVolumePerHour *float64 - ServiceCostPerHourVolumeUsd *float64 - LogEventVolumePerHour *float64 - LogEventBytesPerHour *float64 - LogEventCostPerHourUsd *float64 - LogEventCostPerHourBytesUsd *float64 - LogEventCostPerHourVolumeUsd *float64 - EstimatedVolumeReductionPerHour *float64 - EstimatedBytesReductionPerHour *float64 - EstimatedCostReductionPerHourUsd *float64 - EstimatedCostReductionPerHourBytesUsd *float64 - EstimatedCostReductionPerHourVolumeUsd *float64 - ObservedVolumePerHourBefore *float64 - ObservedVolumePerHourAfter *float64 - ObservedBytesPerHourBefore *float64 - ObservedBytesPerHourAfter *float64 - ObservedCostPerHourBeforeUsd *float64 - ObservedCostPerHourBeforeBytesUsd *float64 - ObservedCostPerHourBeforeVolumeUsd *float64 - ObservedCostPerHourAfterUsd *float64 - ObservedCostPerHourAfterBytesUsd *float64 - ObservedCostPerHourAfterVolumeUsd *float64 -} - -func (q *Queries) ListEnabledServiceStatuses(ctx context.Context, rowLimit int64) ([]ListEnabledServiceStatusesRow, error) { - rows, err := q.db.QueryContext(ctx, listEnabledServiceStatuses, rowLimit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListEnabledServiceStatusesRow - for rows.Next() { - var i ListEnabledServiceStatusesRow - if err := rows.Scan( - &i.ServiceName, - &i.Health, - &i.LogEventCount, - &i.LogEventAnalyzedCount, - &i.PolicyPendingCount, - &i.PolicyApprovedCount, - &i.PolicyDismissedCount, - &i.PolicyPendingCriticalCount, - &i.PolicyPendingHighCount, - &i.PolicyPendingMediumCount, - &i.PolicyPendingLowCount, - &i.ServiceVolumePerHour, - &i.ServiceDebugVolumePerHour, - &i.ServiceInfoVolumePerHour, - &i.ServiceWarnVolumePerHour, - &i.ServiceErrorVolumePerHour, - &i.ServiceOtherVolumePerHour, - &i.ServiceCostPerHourVolumeUsd, - &i.LogEventVolumePerHour, - &i.LogEventBytesPerHour, - &i.LogEventCostPerHourUsd, - &i.LogEventCostPerHourBytesUsd, - &i.LogEventCostPerHourVolumeUsd, - &i.EstimatedVolumeReductionPerHour, - &i.EstimatedBytesReductionPerHour, - &i.EstimatedCostReductionPerHourUsd, - &i.EstimatedCostReductionPerHourBytesUsd, - &i.EstimatedCostReductionPerHourVolumeUsd, - &i.ObservedVolumePerHourBefore, - &i.ObservedVolumePerHourAfter, - &i.ObservedBytesPerHourBefore, - &i.ObservedBytesPerHourAfter, - &i.ObservedCostPerHourBeforeUsd, - &i.ObservedCostPerHourBeforeBytesUsd, - &i.ObservedCostPerHourBeforeVolumeUsd, - &i.ObservedCostPerHourAfterUsd, - &i.ObservedCostPerHourAfterBytesUsd, - &i.ObservedCostPerHourAfterVolumeUsd, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/sqlite/gen/services.sql.go b/internal/sqlite/gen/services.sql.go deleted file mode 100644 index 6bc51e37..00000000 --- a/internal/sqlite/gen/services.sql.go +++ /dev/null @@ -1,124 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: services.sql - -package gen - -import ( - "context" -) - -const countServices = `-- name: CountServices :one -SELECT COUNT(*) FROM services -` - -func (q *Queries) CountServices(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countServices) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getService = `-- name: GetService :one -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services WHERE id = ? -` - -func (q *Queries) GetService(ctx context.Context, id *string) (Service, error) { - row := q.db.QueryRowContext(ctx, getService, id) - var i Service - err := row.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ) - return i, err -} - -const listServices = `-- name: ListServices :many -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services ORDER BY name -` - -func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { - rows, err := q.db.QueryContext(ctx, listServices) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Service - for rows.Next() { - var i Service - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listServicesByAccount = `-- name: ListServicesByAccount :many -SELECT id, account_id, created_at, description, enabled, initial_weekly_log_count, name FROM services WHERE account_id = ? ORDER BY name -` - -func (q *Queries) ListServicesByAccount(ctx context.Context, accountID *string) ([]Service, error) { - rows, err := q.db.QueryContext(ctx, listServicesByAccount, accountID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Service - for rows.Next() { - var i Service - if err := rows.Scan( - &i.ID, - &i.AccountID, - &i.CreatedAt, - &i.Description, - &i.Enabled, - &i.InitialWeeklyLogCount, - &i.Name, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setServiceEnabled = `-- name: SetServiceEnabled :exec -UPDATE services SET enabled = ? WHERE id = ? -` - -type SetServiceEnabledParams struct { - Enabled *int64 - ID *string -} - -func (q *Queries) SetServiceEnabled(ctx context.Context, arg SetServiceEnabledParams) error { - _, err := q.db.ExecContext(ctx, setServiceEnabled, arg.Enabled, arg.ID) - return err -} diff --git a/internal/sqlite/generate/main.go b/internal/sqlite/generate/main.go deleted file mode 100644 index bb0e30af..00000000 --- a/internal/sqlite/generate/main.go +++ /dev/null @@ -1,249 +0,0 @@ -// Package main generates SQLite schema types from PowerSync. -// -// It boots a temp database with the PowerSync extension and embedded schema, -// then reflects the schema back to a SQL file for sqlc. -// -// Prerequisites: Run `go generate ./internal/powersync` first to generate schema.json. -// -// Usage: -// -// doppler run -- go generate ./internal/sqlite -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/usetero/cli/internal/powersync/extension" - "github.com/usetero/cli/internal/sqlite" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - ctx := context.Background() - - // Create temp database - tmpDir, err := os.MkdirTemp("", "tero-generate-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - - dbPath := filepath.Join(tmpDir, "generate.db") - db, err := sqlite.Open(ctx, dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - fmt.Println("Applying embedded PowerSync schema...") - - // Apply embedded schema to create views (just like runtime) - if _, err := db.Exec(ctx, "SELECT powersync_replace_schema(?)", extension.SchemaJSON()); err != nil { - return fmt.Errorf("replace schema: %w", err) - } - - fmt.Println("Reflecting schema from SQLite...") - - // Reflect schema to SQL - schemaSQL, err := reflectSchema(ctx, db) - if err != nil { - return fmt.Errorf("reflect schema: %w", err) - } - - // Write schema.sql - outputDir := mustGetwd() - schemaPath := filepath.Join(outputDir, "schema.sql") - if err := os.WriteFile(schemaPath, []byte(schemaSQL), 0o644); err != nil { - return fmt.Errorf("write schema.sql: %w", err) - } - - fmt.Printf("Wrote %s\n", schemaPath) - - // Run sqlc generate - fmt.Println("Running sqlc generate...") - if err := runSqlc(ctx); err != nil { - return fmt.Errorf("sqlc generate: %w", err) - } - - fmt.Println("Done!") - return nil -} - -// reflectSchema reflects the schema from sqlite_master to SQL. -// It extracts CREATE VIEW statements for PowerSync views (excluding internal tables). -func reflectSchema(ctx context.Context, db sqlite.DB) (string, error) { - // Query all views created by PowerSync (they have the auto-generated comment) - rows, err := db.Query(ctx, ` - SELECT name, sql - FROM sqlite_master - WHERE type = 'view' - AND sql LIKE '%-- powersync-auto-generated%' - ORDER BY name - `) - if err != nil { - return "", fmt.Errorf("query sqlite_master: %w", err) - } - defer rows.Close() - - var views []struct { - name string - sql string - } - - for rows.Next() { - var name, sql string - if err := rows.Scan(&name, &sql); err != nil { - return "", fmt.Errorf("scan row: %w", err) - } - views = append(views, struct { - name string - sql string - }{name, sql}) - } - - if err := rows.Err(); err != nil { - return "", fmt.Errorf("iterate rows: %w", err) - } - - // Sort by name for deterministic output - sort.Slice(views, func(i, j int) bool { - return views[i].name < views[j].name - }) - - // Convert views to CREATE TABLE statements for sqlc - // (sqlc needs tables, not views, to generate types) - var statements []string - statements = append(statements, "-- Code generated by go generate; DO NOT EDIT.") - statements = append(statements, "-- Source: PowerSync schema reflected from sqlite_master") - statements = append(statements, "") - - for _, view := range views { - // Parse the view to extract column info and generate CREATE TABLE - tableSQL, err := viewToTable(ctx, db, view.name) - if err != nil { - return "", fmt.Errorf("convert view %s: %w", view.name, err) - } - statements = append(statements, tableSQL) - statements = append(statements, "") - } - - return strings.Join(statements, "\n"), nil -} - -// viewToTable converts a view definition to a CREATE TABLE statement. -// It queries the view's column info using PRAGMA table_info. -func viewToTable(ctx context.Context, db sqlite.DB, viewName string) (string, error) { - // Get column info from the view - rows, err := db.Query(ctx, fmt.Sprintf("PRAGMA table_info(%s)", viewName)) - if err != nil { - return "", fmt.Errorf("pragma table_info: %w", err) - } - defer rows.Close() - - var columns []string - seen := make(map[string]bool) - for rows.Next() { - var cid int - var name, colType string - var notNull, pk int - var dfltValue *string - - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return "", fmt.Errorf("scan column: %w", err) - } - - // Skip columns with invalid names (like "id:1" aliases from SQLite) - if strings.Contains(name, ":") { - continue - } - - // Skip duplicates - if seen[name] { - continue - } - seen[name] = true - - // Normalize type for sqlc - sqlType := normalizeSQLiteType(colType) - - col := fmt.Sprintf(" %s %s", name, sqlType) - if pk == 1 { - col += " PRIMARY KEY" - } - if notNull == 1 && pk != 1 { - col += " NOT NULL" - } - columns = append(columns, col) - } - - if err := rows.Err(); err != nil { - return "", fmt.Errorf("iterate columns: %w", err) - } - - return fmt.Sprintf("CREATE TABLE %s (\n%s\n);", viewName, strings.Join(columns, ",\n")), nil -} - -// normalizeSQLiteType normalizes SQLite type names for sqlc. -func normalizeSQLiteType(t string) string { - t = strings.ToUpper(strings.TrimSpace(t)) - switch t { - case "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT": - return "INTEGER" - case "REAL", "DOUBLE", "FLOAT": - return "REAL" - case "BLOB": - return "BLOB" - default: - return "TEXT" - } -} - -// runSqlc runs sqlc generate from the repository root. -func runSqlc(ctx context.Context) error { - // Find repo root (where sqlc.yaml lives) - repoRoot, err := findRepoRoot() - if err != nil { - return err - } - - cmd := exec.CommandContext(ctx, "sqlc", "generate") - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// findRepoRoot finds the repository root by looking for go.mod. -func findRepoRoot() (string, error) { - dir := mustGetwd() - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir, nil - } - parent := filepath.Dir(dir) - if parent == dir { - return "", fmt.Errorf("could not find repository root (go.mod)") - } - dir = parent - } -} - -func mustGetwd() string { - dir, err := os.Getwd() - if err != nil { - panic(err) - } - return dir -} diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go deleted file mode 100644 index 5c1f8f98..00000000 --- a/internal/sqlite/log_event_policies.go +++ /dev/null @@ -1,56 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicies provides type-safe access to log event policies. -type LogEventPolicies interface { - Count(ctx context.Context) (int64, error) - Approve(ctx context.Context, id, userID string) error - Dismiss(ctx context.Context, id, userID string) error -} - -// logEventPoliciesImpl implements LogEventPolicies. -type logEventPoliciesImpl struct { - read *gen.Queries - write *gen.Queries -} - -// Count returns the total number of log event policies. -func (l *logEventPoliciesImpl) Count(ctx context.Context) (int64, error) { - count, err := l.read.CountLogEventPolicies(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count log event policies") - } - return count, nil -} - -func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) error { - now := time.Now().UTC().Format(time.RFC3339) - err := l.write.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ - ID: &id, - ApprovedAt: &now, - ApprovedBy: &userID, - }) - if err != nil { - return WrapSQLiteError(err, "approve log event policy") - } - return nil -} - -func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userID string) error { - now := time.Now().UTC().Format(time.RFC3339) - err := l.write.DismissLogEventPolicy(ctx, gen.DismissLogEventPolicyParams{ - ID: &id, - DismissedAt: &now, - DismissedBy: &userID, - }) - if err != nil { - return WrapSQLiteError(err, "dismiss log event policy") - } - return nil -} diff --git a/internal/sqlite/log_event_policy_category_statuses.go b/internal/sqlite/log_event_policy_category_statuses.go deleted file mode 100644 index 2c21dd1b..00000000 --- a/internal/sqlite/log_event_policy_category_statuses.go +++ /dev/null @@ -1,83 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicyCategoryStatuses provides access to pre-computed per-category policy rollups. -type LogEventPolicyCategoryStatuses interface { - ListWasteCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - ListQualityCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - ListComplianceCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - CountObservedByComplianceCategory(ctx context.Context) (map[domain.PolicyCategory]int64, error) -} - -// logEventPolicyCategoryStatusesImpl implements LogEventPolicyCategoryStatuses. -type logEventPolicyCategoryStatusesImpl struct { - queries *gen.Queries -} - -// ListWasteCategoryStatuses returns pre-computed category rollups for the waste tab. -func (l *logEventPolicyCategoryStatusesImpl) ListWasteCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeWaste) -} - -// ListQualityCategoryStatuses returns pre-computed category rollups for the quality tab. -func (l *logEventPolicyCategoryStatusesImpl) ListQualityCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeQuality) -} - -// ListComplianceCategoryStatuses returns pre-computed category rollups for the compliance tab. -func (l *logEventPolicyCategoryStatusesImpl) ListComplianceCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - return l.listByType(ctx, domain.CategoryTypeCompliance) -} - -// CountObservedByComplianceCategory returns the number of leaking (observed) pending policies per compliance category. -func (l *logEventPolicyCategoryStatusesImpl) CountObservedByComplianceCategory(ctx context.Context) (map[domain.PolicyCategory]int64, error) { - rows, err := l.queries.CountObservedPoliciesByComplianceCategory(ctx) - if err != nil { - return nil, WrapSQLiteError(err, "count observed policies by compliance category") - } - - result := make(map[domain.PolicyCategory]int64, len(rows)) - for _, row := range rows { - if row.Category != nil { - result[domain.PolicyCategory(*row.Category)] = row.ObservedCount - } - } - return result, nil -} - -func (l *logEventPolicyCategoryStatusesImpl) listByType(ctx context.Context, categoryType domain.CategoryType) ([]domain.PolicyCategoryStatus, error) { - ct := string(categoryType) - rows, err := l.queries.ListCategoryStatusesByCostAndType(ctx, &ct) - if err != nil { - return nil, WrapSQLiteError(err, "list policy category statuses by type") - } - - result := make([]domain.PolicyCategoryStatus, len(rows)) - for i, row := range rows { - result[i] = domain.PolicyCategoryStatus{ - Category: domain.PolicyCategory(row.Category), - DisplayName: row.DisplayName, - Principle: row.Principle, - PendingCount: row.PendingCount, - ApprovedCount: row.ApprovedCount, - DismissedCount: row.DismissedCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - EstimatedVolumePerHour: row.EstimatedVolumeReductionPerHour, - EstimatedBytesPerHour: row.EstimatedBytesReductionPerHour, - EstimatedCostPerHour: row.EstimatedCostReductionPerHourUsd, - EventsWithVolumes: row.EventsWithVolumes, - TotalEvents: row.TotalEventCount, - Action: domain.PolicyAction(row.PolicyAction), - } - } - return result, nil -} diff --git a/internal/sqlite/log_event_policy_statuses.go b/internal/sqlite/log_event_policy_statuses.go deleted file mode 100644 index fe26736e..00000000 --- a/internal/sqlite/log_event_policy_statuses.go +++ /dev/null @@ -1,105 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventPolicyStatuses provides type-safe access to pre-computed policy status data. -type LogEventPolicyStatuses interface { - GetPolicyCard(ctx context.Context, policyID string) (*domain.PolicyCard, error) - ListTopPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.WastePolicy, error) -} - -type logEventPolicyStatusesImpl struct { - queries *gen.Queries -} - -func (l *logEventPolicyStatusesImpl) GetPolicyCard(ctx context.Context, policyID string) (*domain.PolicyCard, error) { - row, err := l.queries.GetPolicyCard(ctx, &policyID) - if err != nil { - return nil, WrapSQLiteError(err, "get policy card") - } - - card := &domain.PolicyCard{ - PolicyID: row.PolicyID, - ServiceName: row.ServiceName, - LogEventName: row.LogEventName, - Category: row.Category, - CategoryType: row.CategoryType, - Action: row.Action, - Status: row.Status, - Severity: row.Severity, - CategoryDisplayName: row.CategoryDisplayName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - SurvivalRate: row.SurvivalRate, - Analysis: row.Analysis, - Examples: row.Examples, - EventBaselineAvgBytes: row.EventBaselineAvgBytes, - EventBaselineVolumePerHour: row.EventBaselineVolumePerHour, - } - - // Quality policies operate on fields — enrich with per-field byte sizes - // so BuildEvidence can produce FieldListEvidence. - if row.LogEventID != "" && row.CategoryType == string(domain.CategoryTypeQuality) { - card.FieldSizes = l.fieldSizes(ctx, row.LogEventID) - } - - return card, nil -} - -// fieldSizes fetches per-field byte sizes for a log event and returns them -// keyed by dot-path. Returns nil if no data is available. -func (l *logEventPolicyStatusesImpl) fieldSizes(ctx context.Context, logEventID string) map[string]float64 { - rows, err := l.queries.ListFieldsByLogEvent(ctx, &logEventID) - if err != nil || len(rows) == 0 { - return nil - } - - sizes := make(map[string]float64, len(rows)) - for _, row := range rows { - if row.BaselineAvgBytes != nil { - fp := domain.ParseFieldPathPg(row.FieldPath) - if !fp.IsEmpty() { - sizes[fp.Key()] = *row.BaselineAvgBytes - } - } - } - if len(sizes) == 0 { - return nil - } - return sizes -} - -func (l *logEventPolicyStatusesImpl) ListTopPendingPoliciesByCategory(ctx context.Context, category domain.PolicyCategory, limit int64) ([]domain.WastePolicy, error) { - catStr := string(category) - rows, err := l.queries.ListTopPendingPoliciesByCategory(ctx, gen.ListTopPendingPoliciesByCategoryParams{ - Category: &catStr, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list top pending policies by category") - } - - result := make([]domain.WastePolicy, len(rows)) - for i, row := range rows { - result[i] = domain.WastePolicy{ - LogEventName: row.LogEventName, - ServiceName: row.ServiceName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - EstimatedCostPerHour: row.EstimatedCostPerHour, - EstimatedCostPerHourBytes: row.EstimatedCostPerHourBytes, - EstimatedCostPerHourVolume: row.EstimatedCostPerHourVolume, - EstimatedBytesPerHour: row.EstimatedBytesPerHour, - EstimatedVolumePerHour: row.EstimatedVolumePerHour, - } - } - return result, nil -} diff --git a/internal/sqlite/log_event_statuses.go b/internal/sqlite/log_event_statuses.go deleted file mode 100644 index 5de01bf0..00000000 --- a/internal/sqlite/log_event_statuses.go +++ /dev/null @@ -1,45 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEventStatuses provides access to per-log-event status data. -type LogEventStatuses interface { - ListByService(ctx context.Context, serviceName string, limit int64) ([]domain.LogEventStatus, error) -} - -type logEventStatusesImpl struct { - queries *gen.Queries -} - -// ListByService returns log event statuses for a service, ordered by cost. -func (l *logEventStatusesImpl) ListByService(ctx context.Context, serviceName string, limit int64) ([]domain.LogEventStatus, error) { - rows, err := l.queries.ListLogEventStatusesByService(ctx, gen.ListLogEventStatusesByServiceParams{ - Name: &serviceName, - Limit: limit, - }) - if err != nil { - return nil, WrapSQLiteError(err, "list log event statuses by service") - } - - result := make([]domain.LogEventStatus, len(rows)) - for i, row := range rows { - result[i] = domain.LogEventStatus{ - Name: row.LogEventName, - VolumePerHour: row.VolumePerHour, - BytesPerHour: row.BytesPerHour, - CostPerHourUSD: row.CostPerHourUsd, - PendingPolicyCount: row.PendingPolicyCount, - ApprovedPolicyCount: row.ApprovedPolicyCount, - PolicyPendingCriticalCount: row.PolicyPendingCriticalCount, - PolicyPendingHighCount: row.PolicyPendingHighCount, - PolicyPendingMediumCount: row.PolicyPendingMediumCount, - PolicyPendingLowCount: row.PolicyPendingLowCount, - } - } - return result, nil -} diff --git a/internal/sqlite/log_events.go b/internal/sqlite/log_events.go deleted file mode 100644 index 62bbab63..00000000 --- a/internal/sqlite/log_events.go +++ /dev/null @@ -1,26 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// LogEvents provides type-safe access to log events. -type LogEvents interface { - Count(ctx context.Context) (int64, error) -} - -// logEventsImpl implements LogEvents. -type logEventsImpl struct { - queries *gen.Queries -} - -// Count returns the total number of log events. -func (l *logEventsImpl) Count(ctx context.Context) (int64, error) { - count, err := l.queries.CountLogEvents(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count log events") - } - return count, nil -} diff --git a/internal/sqlite/messages.go b/internal/sqlite/messages.go deleted file mode 100644 index a54c1437..00000000 --- a/internal/sqlite/messages.go +++ /dev/null @@ -1,213 +0,0 @@ -package sqlite - -import ( - "context" - "time" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Messages provides type-safe access to messages. -type Messages interface { - Count(ctx context.Context) (int64, error) - Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) - CreateUserMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, text string) (domain.MessageID, error) - CreateToolResultMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, results []domain.ToolResult) (domain.MessageID, error) - CreateAssistantMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, model string) (domain.MessageID, error) - UpdateContent(ctx context.Context, id domain.MessageID, content string) error - UpdateMeta(ctx context.Context, id domain.MessageID, model, stopReason string) error - Delete(ctx context.Context, id domain.MessageID) error - List(ctx context.Context, conversationID domain.ConversationID) ([]domain.Message, error) -} - -// messagesImpl implements Messages. -type messagesImpl struct { - read *gen.Queries // read pool — List, Count, Get - write *gen.Queries // write pool — Create*, Update* -} - -// Count returns the total number of messages. -func (m *messagesImpl) Count(ctx context.Context) (int64, error) { - count, err := m.read.CountMessages(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count messages") - } - return count, nil -} - -// Get retrieves a message by ID. -func (m *messagesImpl) Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) { - idStr := id.String() - row, err := m.read.GetMessage(ctx, &idStr) - if err != nil { - return nil, WrapSQLiteError(err, "get message") - } - return toMessage(row), nil -} - -// CreateUserMessage creates a user message with properly encoded content. -// Returns the new message ID. -func (m *messagesImpl) CreateUserMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, text string) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleUser) - - content, err := domain.EncodeText(text) - if err != nil { - return "", err - } - - err = m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert user message") - } - - return msgID, nil -} - -// CreateToolResultMessage creates a user message containing tool results. -func (m *messagesImpl) CreateToolResultMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, results []domain.ToolResult) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleUser) - - content, err := domain.EncodeToolResults(results) - if err != nil { - return "", err - } - - err = m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert tool result message") - } - - return msgID, nil -} - -// CreateAssistantMessage creates an empty assistant message placeholder. -// Returns the new message ID. Content is added via UpdateContent as it streams in. -func (m *messagesImpl) CreateAssistantMessage(ctx context.Context, accountID domain.AccountID, conversationID domain.ConversationID, model string) (domain.MessageID, error) { - msgID := domain.NewMessageID() - msgIDStr := msgID.String() - accountIDStr := accountID.String() - convIDStr := conversationID.String() - now := time.Now().UTC().Format(time.RFC3339) - role := string(domain.RoleAssistant) - content := "[]" // Empty JSON array - - err := m.write.InsertMessage(ctx, gen.InsertMessageParams{ - ID: &msgIDStr, - AccountID: &accountIDStr, - ConversationID: &convIDStr, - Content: &content, - CreatedAt: &now, - Model: &model, - Role: &role, - }) - if err != nil { - return "", WrapSQLiteError(err, "insert assistant message") - } - - return msgID, nil -} - -// UpdateContent updates the content of a message. -func (m *messagesImpl) UpdateContent(ctx context.Context, id domain.MessageID, content string) error { - idStr := id.String() - err := m.write.UpdateMessageContent(ctx, gen.UpdateMessageContentParams{ - ID: &idStr, - Content: &content, - }) - return WrapSQLiteError(err, "update message content") -} - -// UpdateMeta updates the model and stop_reason of a message. -func (m *messagesImpl) UpdateMeta(ctx context.Context, id domain.MessageID, model, stopReason string) error { - idStr := id.String() - err := m.write.UpdateMessageMeta(ctx, gen.UpdateMessageMetaParams{ - ID: &idStr, - Model: &model, - StopReason: &stopReason, - }) - return WrapSQLiteError(err, "update message meta") -} - -// Delete removes a message by ID. -func (m *messagesImpl) Delete(ctx context.Context, id domain.MessageID) error { - idStr := id.String() - err := m.write.DeleteMessage(ctx, &idStr) - return WrapSQLiteError(err, "delete message") -} - -// List returns all messages for a conversation, ordered by creation time. -func (m *messagesImpl) List(ctx context.Context, conversationID domain.ConversationID) ([]domain.Message, error) { - convIDStr := conversationID.String() - rows, err := m.read.ListMessagesByConversation(ctx, &convIDStr) - if err != nil { - return nil, WrapSQLiteError(err, "list messages") - } - - messages := make([]domain.Message, 0, len(rows)) - for _, row := range rows { - if msg := toMessage(row); msg != nil { - messages = append(messages, *msg) - } - } - return messages, nil -} - -// toMessage converts a gen.Message to a domain.Message. -// Returns nil if essential fields are missing. -func toMessage(row gen.Message) *domain.Message { - if row.ID == nil || row.Role == nil { - return nil - } - - msg := &domain.Message{ - ID: domain.MessageID(*row.ID), - Role: domain.Role(*row.Role), - } - - if row.ConversationID != nil { - msg.ConversationID = domain.ConversationID(*row.ConversationID) - } - if row.Model != nil { - msg.Model = *row.Model - } - if row.StopReason != nil { - msg.StopReason = *row.StopReason - } - if row.CreatedAt != nil { - if t, err := time.Parse(time.RFC3339, *row.CreatedAt); err == nil { - msg.CreatedAt = t - } - } - if row.Content != nil { - if blocks, err := domain.ParseBlocks(*row.Content); err == nil { - msg.Content = blocks - } - } - - return msg -} diff --git a/internal/sqlite/queries/AGENTS.md b/internal/sqlite/queries/AGENTS.md deleted file mode 100644 index a665d7cb..00000000 --- a/internal/sqlite/queries/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# SQLite Queries - -This directory contains sqlc source queries. Generated Go lives in `internal/sqlite/gen`. - -## Query Naming - -Use explicit intent and scope in names: - -1. Prefix by operation: `Get`, `List`, `Count`, `Insert`, `Update`, `Delete`, `Set`. -2. Include filter scope: `ByAccount`, `ByConversation`, etc. -3. Encode sort in the name when SQL has `ORDER BY`. - -Examples: - -1. `ListMessagesByConversationByCreatedAsc` -2. `ListWasteCategoryStatusesByCost` - -## Ordering Rules - -1. If SQL orders data, query name must indicate that order. -2. If a caller needs a different order and query uses `LIMIT`, add a new query. -3. For small result sets, re-sort in wrapper code (not in generated code). - -## Null Handling Conventions - -1. Use `COALESCE` for nullable text where empty string is acceptable. -2. Keep nullable floats nullable when semantics require distinguishing missing vs zero. -3. Prefer consistent parameter style across files. - diff --git a/internal/sqlite/queries/CLAUDE.md b/internal/sqlite/queries/CLAUDE.md deleted file mode 100644 index 0480db90..00000000 --- a/internal/sqlite/queries/CLAUDE.md +++ /dev/null @@ -1,31 +0,0 @@ -# SQLite Queries - -This file mirrors `AGENTS.md` for tools that still load `CLAUDE.md`. -Authoritative source: `AGENTS.md`. - -This directory contains sqlc source queries. Generated Go lives in `internal/sqlite/gen`. - -## Query Naming - -Use explicit intent and scope in names: - -1. Prefix by operation: `Get`, `List`, `Count`, `Insert`, `Update`, `Delete`, `Set`. -2. Include filter scope: `ByAccount`, `ByConversation`, etc. -3. Encode sort in the name when SQL has `ORDER BY`. - -Examples: - -1. `ListMessagesByConversationByCreatedAsc` -2. `ListWasteCategoryStatusesByCost` - -## Ordering Rules - -1. If SQL orders data, query name must indicate that order. -2. If a caller needs a different order and query uses `LIMIT`, add a new query. -3. For small result sets, re-sort in wrapper code (not in generated code). - -## Null Handling Conventions - -1. Use `COALESCE` for nullable text where empty string is acceptable. -2. Keep nullable floats nullable when semantics require distinguishing missing vs zero. -3. Prefer consistent parameter style across files. diff --git a/internal/sqlite/queries/compliance_policies.sql b/internal/sqlite/queries/compliance_policies.sql deleted file mode 100644 index 48697fa0..00000000 --- a/internal/sqlite/queries/compliance_policies.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Compliance policy queries for PII, Secrets, PHI, and Payment Data leakage. - --- name: ListPendingCompliancePoliciesByCategory :many --- Returns pending compliance policies for a specific category, sorted by observed then volume. -SELECT - COALESCE(s.name, '') AS service_name, - COALESCE(le.name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - les.volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || ?1 || '.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -JOIN log_events le ON le.id = leps.log_event_id -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = leps.log_event_id -WHERE leps.category = ?1 AND leps.status = 'PENDING' -ORDER BY any_observed DESC, les.volume_per_hour DESC -LIMIT ?2; - --- name: CountObservedPoliciesByComplianceCategory :many --- Returns, per compliance category, how many pending policies have observed (leaking) data. -SELECT - leps.category, - CAST(SUM(CASE WHEN COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.' || leps.category || '.fields')) f - ), 0) = 1 THEN 1 ELSE 0 END) AS INTEGER) AS observed_count -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category_type = 'compliance' AND leps.status = 'PENDING' -GROUP BY leps.category; diff --git a/internal/sqlite/queries/conversations.sql b/internal/sqlite/queries/conversations.sql deleted file mode 100644 index 099fdbd0..00000000 --- a/internal/sqlite/queries/conversations.sql +++ /dev/null @@ -1,23 +0,0 @@ --- name: GetConversation :one -SELECT * FROM conversations WHERE id = ?; - --- name: ListConversationsByAccount :many -SELECT * FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC; - --- name: GetLatestConversationByAccount :one -SELECT * FROM conversations -WHERE account_id = ? -ORDER BY created_at DESC -LIMIT 1; - --- name: CountConversations :one -SELECT COUNT(*) FROM conversations; - --- name: InsertConversation :exec -INSERT INTO conversations (id, account_id, workspace_id, created_at) -VALUES (?, ?, ?, ?); - --- name: UpdateConversationTitle :exec -UPDATE conversations SET title = ? WHERE id = ?; diff --git a/internal/sqlite/queries/datadog_account_statuses.sql b/internal/sqlite/queries/datadog_account_statuses.sql deleted file mode 100644 index 5f44d0c6..00000000 --- a/internal/sqlite/queries/datadog_account_statuses.sql +++ /dev/null @@ -1,58 +0,0 @@ --- name: GetAccountSummary :one -SELECT - -- ready - CAST(COALESCE(MAX(ready_for_use), 0) AS INTEGER) AS ready_for_use, - - -- health - COALESCE(MAX(health), '') AS health, - - -- services - CAST(COALESCE(SUM(log_service_count), 0) AS INTEGER) AS service_count, - CAST(COALESCE(SUM(log_active_services), 0) AS INTEGER) AS active_services, - CAST(COALESCE(SUM(ok_services), 0) AS INTEGER) AS ok_services, - CAST(COALESCE(SUM(disabled_services), 0) AS INTEGER) AS disabled_services, - CAST(COALESCE(SUM(inactive_services), 0) AS INTEGER) AS inactive_services, - - -- events - CAST(COALESCE(SUM(log_event_count), 0) AS INTEGER) AS event_count, - CAST(COALESCE(SUM(log_event_analyzed_count), 0) AS INTEGER) AS analyzed_count, - - -- policies - CAST(COALESCE(SUM(policy_pending_count), 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(SUM(policy_approved_count), 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(SUM(policy_dismissed_count), 0) AS INTEGER) AS dismissed_policy_count, - CAST(COALESCE(SUM(policy_pending_critical_count), 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(SUM(policy_pending_high_count), 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(SUM(policy_pending_medium_count), 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(SUM(policy_pending_low_count), 0) AS INTEGER) AS policy_pending_low_count, - - -- estimated savings - SUM(estimated_cost_reduction_per_hour_usd) AS estimated_cost_per_hour, - SUM(estimated_cost_reduction_per_hour_bytes_usd) AS estimated_cost_per_hour_bytes, - SUM(estimated_cost_reduction_per_hour_volume_usd) AS estimated_cost_per_hour_volume, - SUM(estimated_volume_reduction_per_hour) AS estimated_volume_per_hour, - SUM(estimated_bytes_reduction_per_hour) AS estimated_bytes_per_hour, - - -- observed impact - SUM(observed_cost_per_hour_before_usd) AS observed_cost_before, - SUM(observed_cost_per_hour_before_bytes_usd) AS observed_cost_before_bytes, - SUM(observed_cost_per_hour_before_volume_usd) AS observed_cost_before_volume, - SUM(observed_cost_per_hour_after_usd) AS observed_cost_after, - SUM(observed_cost_per_hour_after_bytes_usd) AS observed_cost_after_bytes, - SUM(observed_cost_per_hour_after_volume_usd) AS observed_cost_after_volume, - SUM(observed_volume_per_hour_before) AS observed_volume_before, - SUM(observed_volume_per_hour_after) AS observed_volume_after, - SUM(observed_bytes_per_hour_before) AS observed_bytes_before, - SUM(observed_bytes_per_hour_after) AS observed_bytes_after, - - -- totals - SUM(log_event_cost_per_hour_usd) AS total_cost_per_hour, - SUM(log_event_cost_per_hour_bytes_usd) AS total_cost_per_hour_bytes, - SUM(log_event_cost_per_hour_volume_usd) AS total_cost_per_hour_volume, - SUM(log_event_volume_per_hour) AS total_volume_per_hour, - SUM(log_event_bytes_per_hour) AS total_bytes_per_hour, - - -- service-level throughput - SUM(service_volume_per_hour) AS total_service_volume_per_hour, - SUM(service_cost_per_hour_volume_usd) AS total_service_cost_per_hour -FROM datadog_account_statuses_cache; diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql deleted file mode 100644 index c9bffb35..00000000 --- a/internal/sqlite/queries/log_event_policies.sql +++ /dev/null @@ -1,12 +0,0 @@ --- name: CountLogEventPolicies :one -SELECT COUNT(*) FROM log_event_policies; - --- name: ApproveLogEventPolicy :exec -UPDATE log_event_policies -SET approved_at = ?, approved_by = ? -WHERE id = ?; - --- name: DismissLogEventPolicy :exec -UPDATE log_event_policies -SET dismissed_at = ?, dismissed_by = ? -WHERE id = ?; diff --git a/internal/sqlite/queries/log_event_policy_category_statuses.sql b/internal/sqlite/queries/log_event_policy_category_statuses.sql deleted file mode 100644 index b5285bf6..00000000 --- a/internal/sqlite/queries/log_event_policy_category_statuses.sql +++ /dev/null @@ -1,26 +0,0 @@ --- name: ListCategoryStatusesByCostAndType :many --- Pre-computed per-category rollup filtered by category_type (waste or compliance). -SELECT - COALESCE(category, '') AS category, - COALESCE(category_type, '') AS category_type, - COALESCE("action", '') AS policy_action, - COALESCE(display_name, '') AS display_name, - COALESCE(principle, '') AS principle, - CAST(COALESCE(pending_count, 0) AS INTEGER) AS pending_count, - CAST(COALESCE(approved_count, 0) AS INTEGER) AS approved_count, - CAST(COALESCE(dismissed_count, 0) AS INTEGER) AS dismissed_count, - estimated_volume_reduction_per_hour, - estimated_bytes_reduction_per_hour, - estimated_cost_reduction_per_hour_usd, - estimated_cost_reduction_per_hour_bytes_usd, - estimated_cost_reduction_per_hour_volume_usd, - CAST(COALESCE(events_with_volumes, 0) AS INTEGER) AS events_with_volumes, - CAST(COALESCE(total_event_count, 0) AS INTEGER) AS total_event_count, - CAST(COALESCE(policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_event_policy_category_statuses_cache -WHERE category IS NOT NULL AND category != '' - AND category_type = ?1 -ORDER BY estimated_cost_reduction_per_hour_usd DESC NULLS LAST, pending_count DESC; diff --git a/internal/sqlite/queries/log_event_policy_statuses.sql b/internal/sqlite/queries/log_event_policy_statuses.sql deleted file mode 100644 index 1c6980c2..00000000 --- a/internal/sqlite/queries/log_event_policy_statuses.sql +++ /dev/null @@ -1,34 +0,0 @@ --- name: ListTopPendingPoliciesByCategory :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - volume_per_hour, - bytes_per_hour, - estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - estimated_cost_reduction_per_hour_bytes_usd AS estimated_cost_per_hour_bytes, - estimated_cost_reduction_per_hour_volume_usd AS estimated_cost_per_hour_volume, - estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - estimated_volume_reduction_per_hour AS estimated_volume_per_hour -FROM log_event_policy_statuses_cache -WHERE category = ?1 AND status = 'PENDING' -ORDER BY estimated_cost_reduction_per_hour_usd DESC, volume_per_hour DESC -LIMIT ?2; - --- name: ListPendingPIIPolicies :many -SELECT - COALESCE(service_name, '') AS service_name, - COALESCE(log_event_name, '') AS log_event_name, - COALESCE(lep.analysis, '') AS analysis, - volume_per_hour, - CAST(COALESCE(( - SELECT MAX(CASE json_extract(f.value, '$.observed') WHEN 1 THEN 1 ELSE 0 END) - FROM json_each(json_extract(lep.analysis, '$.pii_leakage.fields')) f - ), 0) AS INTEGER) AS any_observed -FROM log_event_policy_statuses_cache leps -LEFT JOIN log_event_policies lep ON lep.id = leps.policy_id -WHERE leps.category = 'pii_leakage' AND leps.status = 'PENDING' -ORDER BY any_observed DESC, leps.volume_per_hour DESC; - --- name: CountFixedPIIPolicies :one -SELECT CAST(COUNT(*) AS INTEGER) FROM log_event_policy_statuses_cache -WHERE category = 'pii_leakage' AND status = 'APPROVED'; diff --git a/internal/sqlite/queries/log_event_statuses.sql b/internal/sqlite/queries/log_event_statuses.sql deleted file mode 100644 index 5bc309b1..00000000 --- a/internal/sqlite/queries/log_event_statuses.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: ListLogEventStatusesByService :many -SELECT - COALESCE(le.name, '') AS log_event_name, - les.volume_per_hour, - les.bytes_per_hour, - les.cost_per_hour_usd, - CAST(COALESCE(les.pending_policy_count, 0) AS INTEGER) AS pending_policy_count, - CAST(COALESCE(les.approved_policy_count, 0) AS INTEGER) AS approved_policy_count, - CAST(COALESCE(les.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(les.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(les.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(les.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count -FROM log_events le -JOIN services s ON s.id = le.service_id -LEFT JOIN log_event_statuses_cache les ON les.log_event_id = le.id -WHERE s.name = ?1 -ORDER BY les.cost_per_hour_usd DESC, les.volume_per_hour DESC -LIMIT ?2; diff --git a/internal/sqlite/queries/log_events.sql b/internal/sqlite/queries/log_events.sql deleted file mode 100644 index b2f04d41..00000000 --- a/internal/sqlite/queries/log_events.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: CountLogEvents :one -SELECT COUNT(*) FROM log_events; diff --git a/internal/sqlite/queries/messages.sql b/internal/sqlite/queries/messages.sql deleted file mode 100644 index 959a3537..00000000 --- a/internal/sqlite/queries/messages.sql +++ /dev/null @@ -1,37 +0,0 @@ --- name: GetMessage :one -SELECT * FROM messages WHERE id = ?; - --- name: ListMessagesByConversation :many -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at ASC; - --- name: ListMessagesByConversationDesc :many -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC; - --- name: GetLatestMessageByConversation :one -SELECT * FROM messages -WHERE conversation_id = ? -ORDER BY created_at DESC -LIMIT 1; - --- name: CountMessages :one -SELECT COUNT(*) FROM messages; - --- name: CountMessagesByConversation :one -SELECT COUNT(*) FROM messages WHERE conversation_id = ?; - --- name: InsertMessage :exec -INSERT INTO messages (id, account_id, content, conversation_id, created_at, model, role, stop_reason) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); - --- name: UpdateMessageContent :exec -UPDATE messages SET content = ? WHERE id = ?; - --- name: UpdateMessageMeta :exec -UPDATE messages SET model = ?, stop_reason = ? WHERE id = ?; - --- name: DeleteMessage :exec -DELETE FROM messages WHERE id = ?; diff --git a/internal/sqlite/queries/policy_cards.sql b/internal/sqlite/queries/policy_cards.sql deleted file mode 100644 index 151c63fd..00000000 --- a/internal/sqlite/queries/policy_cards.sql +++ /dev/null @@ -1,40 +0,0 @@ --- name: GetPolicyCard :one --- Fetches a single policy with all context needed for rich card rendering. --- Main table: log_event_policy_statuses_cache (denormalized policy + metrics). --- JOINs enrich with: category display name, AI analysis, log examples, baselines. -SELECT - COALESCE(ps.policy_id, '') AS policy_id, - COALESCE(ps.service_name, '') AS service_name, - COALESCE(ps.log_event_name, '') AS log_event_name, - COALESCE(ps.category, '') AS category, - COALESCE(ps.category_type, '') AS category_type, - COALESCE(ps.action, '') AS "action", - COALESCE(ps.status, '') AS status, - ps.volume_per_hour, - ps.bytes_per_hour, - ps.estimated_cost_reduction_per_hour_usd AS estimated_cost_per_hour, - ps.estimated_volume_reduction_per_hour AS estimated_volume_per_hour, - ps.estimated_bytes_reduction_per_hour AS estimated_bytes_per_hour, - COALESCE(ps.severity, '') AS severity, - ps.survival_rate, - COALESCE(cat.display_name, '') AS category_display_name, - COALESCE(lep.analysis, '') AS analysis, - COALESCE(le.examples, '') AS examples, - le.baseline_avg_bytes AS event_baseline_avg_bytes, - le.baseline_volume_per_hour AS event_baseline_volume_per_hour, - COALESCE(ps.log_event_id, '') AS log_event_id -FROM log_event_policy_statuses_cache ps -LEFT JOIN log_event_policy_category_statuses_cache cat ON cat.category = ps.category -LEFT JOIN log_event_policies lep ON lep.id = ps.policy_id -LEFT JOIN log_events le ON le.id = ps.log_event_id -WHERE ps.policy_id = ?1; - --- name: ListFieldsByLogEvent :many --- Returns per-field metadata for a log event, used to show per-field byte impact --- in quality policies (instrumentation_bloat, oversized_fields, duplicate_fields). -SELECT - COALESCE(field_path, '') AS field_path, - baseline_avg_bytes -FROM log_event_fields -WHERE log_event_id = ?1 -ORDER BY baseline_avg_bytes DESC; diff --git a/internal/sqlite/queries/service_statuses.sql b/internal/sqlite/queries/service_statuses.sql deleted file mode 100644 index 0b82e7a7..00000000 --- a/internal/sqlite/queries/service_statuses.sql +++ /dev/null @@ -1,103 +0,0 @@ --- name: ListAllServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - WHEN 'DISABLED' THEN 2 - WHEN 'INACTIVE' THEN 3 - ELSE 4 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name; - --- name: ListEnabledServiceStatuses :many -SELECT - s.name AS service_name, - COALESCE(ssc.health, '') AS health, - CAST(COALESCE(ssc.log_event_count, 0) AS INTEGER) AS log_event_count, - CAST(COALESCE(ssc.log_event_analyzed_count, 0) AS INTEGER) AS log_event_analyzed_count, - CAST(COALESCE(ssc.policy_pending_count, 0) AS INTEGER) AS policy_pending_count, - CAST(COALESCE(ssc.policy_approved_count, 0) AS INTEGER) AS policy_approved_count, - CAST(COALESCE(ssc.policy_dismissed_count, 0) AS INTEGER) AS policy_dismissed_count, - CAST(COALESCE(ssc.policy_pending_critical_count, 0) AS INTEGER) AS policy_pending_critical_count, - CAST(COALESCE(ssc.policy_pending_high_count, 0) AS INTEGER) AS policy_pending_high_count, - CAST(COALESCE(ssc.policy_pending_medium_count, 0) AS INTEGER) AS policy_pending_medium_count, - CAST(COALESCE(ssc.policy_pending_low_count, 0) AS INTEGER) AS policy_pending_low_count, - ssc.service_volume_per_hour, - ssc.service_debug_volume_per_hour, - ssc.service_info_volume_per_hour, - ssc.service_warn_volume_per_hour, - ssc.service_error_volume_per_hour, - ssc.service_other_volume_per_hour, - ssc.service_cost_per_hour_volume_usd, - ssc.log_event_volume_per_hour, - ssc.log_event_bytes_per_hour, - ssc.log_event_cost_per_hour_usd, - ssc.log_event_cost_per_hour_bytes_usd, - ssc.log_event_cost_per_hour_volume_usd, - ssc.estimated_volume_reduction_per_hour, - ssc.estimated_bytes_reduction_per_hour, - ssc.estimated_cost_reduction_per_hour_usd, - ssc.estimated_cost_reduction_per_hour_bytes_usd, - ssc.estimated_cost_reduction_per_hour_volume_usd, - ssc.observed_volume_per_hour_before, - ssc.observed_volume_per_hour_after, - ssc.observed_bytes_per_hour_before, - ssc.observed_bytes_per_hour_after, - ssc.observed_cost_per_hour_before_usd, - ssc.observed_cost_per_hour_before_bytes_usd, - ssc.observed_cost_per_hour_before_volume_usd, - ssc.observed_cost_per_hour_after_usd, - ssc.observed_cost_per_hour_after_bytes_usd, - ssc.observed_cost_per_hour_after_volume_usd -FROM service_statuses_cache ssc -JOIN services s ON ssc.service_id = s.id -WHERE ssc.health NOT IN ('DISABLED', 'INACTIVE') -ORDER BY - CASE ssc.health - WHEN 'OK' THEN 1 - ELSE 2 - END, - ssc.log_event_cost_per_hour_usd DESC, - s.name -LIMIT sqlc.arg('row_limit'); diff --git a/internal/sqlite/queries/services.sql b/internal/sqlite/queries/services.sql deleted file mode 100644 index 55189fd7..00000000 --- a/internal/sqlite/queries/services.sql +++ /dev/null @@ -1,14 +0,0 @@ --- name: GetService :one -SELECT * FROM services WHERE id = ?; - --- name: ListServices :many -SELECT * FROM services ORDER BY name; - --- name: ListServicesByAccount :many -SELECT * FROM services WHERE account_id = ? ORDER BY name; - --- name: CountServices :one -SELECT COUNT(*) FROM services; - --- name: SetServiceEnabled :exec -UPDATE services SET enabled = ? WHERE id = ?; diff --git a/internal/sqlite/schema.sql b/internal/sqlite/schema.sql deleted file mode 100644 index e287370a..00000000 --- a/internal/sqlite/schema.sql +++ /dev/null @@ -1,322 +0,0 @@ --- Code generated by go generate; DO NOT EDIT. --- Source: PowerSync schema reflected from sqlite_master - -CREATE TABLE conversation_contexts ( - id TEXT, - account_id TEXT, - added_by TEXT, - conversation_id TEXT, - created_at TEXT, - entity_id TEXT, - entity_type TEXT -); - -CREATE TABLE conversations ( - id TEXT, - account_id TEXT, - created_at TEXT, - title TEXT, - user_id TEXT, - view_id TEXT, - workspace_id TEXT -); - -CREATE TABLE datadog_account_statuses_cache ( - id TEXT, - account_id TEXT, - datadog_account_id TEXT, - disabled_services INTEGER, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - health TEXT, - inactive_services INTEGER, - log_active_services INTEGER, - log_event_analyzed_count INTEGER, - log_event_bytes_per_hour REAL, - log_event_cost_per_hour_bytes_usd REAL, - log_event_cost_per_hour_usd REAL, - log_event_cost_per_hour_volume_usd REAL, - log_event_count INTEGER, - log_event_volume_per_hour REAL, - log_service_count INTEGER, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - ok_services INTEGER, - policy_approved_count INTEGER, - policy_dismissed_count INTEGER, - policy_pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - ready_for_use INTEGER, - service_cost_per_hour_volume_usd REAL, - service_volume_per_hour REAL -); - -CREATE TABLE datadog_accounts ( - id TEXT, - account_id TEXT, - cost_per_gb_ingested REAL, - created_at TEXT, - name TEXT, - site TEXT -); - -CREATE TABLE datadog_log_indexes ( - id TEXT, - account_id TEXT, - cost_per_million_events_indexed REAL, - created_at TEXT, - datadog_account_id TEXT, - name TEXT -); - -CREATE TABLE log_event_fields ( - id TEXT, - account_id TEXT, - baseline_avg_bytes REAL, - created_at TEXT, - field_path TEXT, - log_event_id TEXT, - value_distribution TEXT -); - -CREATE TABLE log_event_policies ( - id TEXT, - account_id TEXT, - action TEXT, - analysis TEXT, - approved_at TEXT, - approved_baseline_avg_bytes REAL, - approved_baseline_volume_per_hour REAL, - approved_by TEXT, - category TEXT, - category_type TEXT, - created_at TEXT, - dismissed_at TEXT, - dismissed_by TEXT, - log_event_id TEXT, - severity TEXT, - subjective INTEGER, - workspace_id TEXT -); - -CREATE TABLE log_event_policy_category_statuses_cache ( - id TEXT, - account_id TEXT, - action TEXT, - approved_count INTEGER, - boundary TEXT, - category TEXT, - category_type TEXT, - dismissed_count INTEGER, - display_name TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - events_with_volumes INTEGER, - pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - principle TEXT, - subjective INTEGER, - total_event_count INTEGER -); - -CREATE TABLE log_event_policy_statuses_cache ( - id TEXT, - account_id TEXT, - action TEXT, - approved_at TEXT, - bytes_per_hour REAL, - category TEXT, - category_type TEXT, - created_at TEXT, - dismissed_at TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - log_event_id TEXT, - log_event_name TEXT, - policy_id TEXT, - service_id TEXT, - service_name TEXT, - severity TEXT, - status TEXT, - subjective INTEGER, - survival_rate REAL, - volume_per_hour REAL, - workspace_id TEXT -); - -CREATE TABLE log_event_statuses_cache ( - id TEXT, - account_id TEXT, - approved_policy_count INTEGER, - bytes_per_hour REAL, - cost_per_hour_bytes_usd REAL, - cost_per_hour_usd REAL, - cost_per_hour_volume_usd REAL, - dismissed_policy_count INTEGER, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - has_been_analyzed INTEGER, - has_volumes INTEGER, - log_event_id TEXT, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - pending_policy_count INTEGER, - policy_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - service_id TEXT, - volume_per_hour REAL -); - -CREATE TABLE log_events ( - id TEXT, - account_id TEXT, - baseline_avg_bytes REAL, - baseline_volume_per_hour REAL, - created_at TEXT, - description TEXT, - event_nature TEXT, - examples TEXT, - matchers TEXT, - name TEXT, - service_id TEXT, - severity TEXT, - signal_purpose TEXT -); - -CREATE TABLE messages ( - id TEXT, - account_id TEXT, - content TEXT, - conversation_id TEXT, - created_at TEXT, - model TEXT, - role TEXT, - stop_reason TEXT -); - -CREATE TABLE service_statuses_cache ( - id TEXT, - account_id TEXT, - datadog_account_id TEXT, - estimated_bytes_reduction_per_hour REAL, - estimated_cost_reduction_per_hour_bytes_usd REAL, - estimated_cost_reduction_per_hour_usd REAL, - estimated_cost_reduction_per_hour_volume_usd REAL, - estimated_volume_reduction_per_hour REAL, - health TEXT, - log_event_analyzed_count INTEGER, - log_event_bytes_per_hour REAL, - log_event_cost_per_hour_bytes_usd REAL, - log_event_cost_per_hour_usd REAL, - log_event_cost_per_hour_volume_usd REAL, - log_event_count INTEGER, - log_event_volume_per_hour REAL, - observed_bytes_per_hour_after REAL, - observed_bytes_per_hour_before REAL, - observed_cost_per_hour_after_bytes_usd REAL, - observed_cost_per_hour_after_usd REAL, - observed_cost_per_hour_after_volume_usd REAL, - observed_cost_per_hour_before_bytes_usd REAL, - observed_cost_per_hour_before_usd REAL, - observed_cost_per_hour_before_volume_usd REAL, - observed_volume_per_hour_after REAL, - observed_volume_per_hour_before REAL, - policy_approved_count INTEGER, - policy_dismissed_count INTEGER, - policy_pending_count INTEGER, - policy_pending_critical_count INTEGER, - policy_pending_high_count INTEGER, - policy_pending_low_count INTEGER, - policy_pending_medium_count INTEGER, - service_cost_per_hour_volume_usd REAL, - service_debug_volume_per_hour REAL, - service_error_volume_per_hour REAL, - service_id TEXT, - service_info_volume_per_hour REAL, - service_other_volume_per_hour REAL, - service_volume_per_hour REAL, - service_warn_volume_per_hour REAL -); - -CREATE TABLE services ( - id TEXT, - account_id TEXT, - created_at TEXT, - description TEXT, - enabled INTEGER, - initial_weekly_log_count INTEGER, - name TEXT -); - -CREATE TABLE teams ( - id TEXT, - account_id TEXT, - created_at TEXT, - name TEXT, - workspace_id TEXT -); - -CREATE TABLE view_favorites ( - id TEXT, - account_id TEXT, - created_at TEXT, - user_id TEXT, - view_id TEXT -); - -CREATE TABLE views ( - id TEXT, - account_id TEXT, - conversation_id TEXT, - created_at TEXT, - created_by TEXT, - entity_type TEXT, - forked_from_id TEXT, - message_id TEXT, - query TEXT -); - -CREATE TABLE workspaces ( - id TEXT, - account_id TEXT, - created_at TEXT, - name TEXT, - purpose TEXT -); diff --git a/internal/sqlite/service_statuses.go b/internal/sqlite/service_statuses.go deleted file mode 100644 index a76e2ab9..00000000 --- a/internal/sqlite/service_statuses.go +++ /dev/null @@ -1,149 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// ServiceStatuses provides access to per-service status data. -type ServiceStatuses interface { - ListAllServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) - ListEnabledServiceStatuses(ctx context.Context, limit int64) ([]domain.ServiceStatus, error) -} - -type serviceStatusesImpl struct { - queries *gen.Queries -} - -// ListAllServiceStatuses returns all services sorted by severity. -func (s *serviceStatusesImpl) ListAllServiceStatuses(ctx context.Context) ([]domain.ServiceStatus, error) { - rows, err := s.queries.ListAllServiceStatuses(ctx) - if err != nil { - return nil, WrapSQLiteError(err, "list all service statuses") - } - - result := make([]domain.ServiceStatus, len(rows)) - for i, row := range rows { - result[i] = mapServiceStatus( - row.ServiceName, row.Health, - row.LogEventCount, row.LogEventAnalyzedCount, - row.PolicyPendingCount, row.PolicyApprovedCount, row.PolicyDismissedCount, - row.PolicyPendingCriticalCount, row.PolicyPendingHighCount, row.PolicyPendingMediumCount, row.PolicyPendingLowCount, - row.ServiceVolumePerHour, - row.ServiceDebugVolumePerHour, row.ServiceInfoVolumePerHour, row.ServiceWarnVolumePerHour, - row.ServiceErrorVolumePerHour, row.ServiceOtherVolumePerHour, - row.ServiceCostPerHourVolumeUsd, - row.LogEventVolumePerHour, row.LogEventBytesPerHour, - row.LogEventCostPerHourUsd, row.LogEventCostPerHourBytesUsd, row.LogEventCostPerHourVolumeUsd, - row.EstimatedVolumeReductionPerHour, row.EstimatedBytesReductionPerHour, - row.EstimatedCostReductionPerHourUsd, row.EstimatedCostReductionPerHourBytesUsd, row.EstimatedCostReductionPerHourVolumeUsd, - row.ObservedVolumePerHourBefore, row.ObservedVolumePerHourAfter, - row.ObservedBytesPerHourBefore, row.ObservedBytesPerHourAfter, - row.ObservedCostPerHourBeforeUsd, row.ObservedCostPerHourBeforeBytesUsd, row.ObservedCostPerHourBeforeVolumeUsd, - row.ObservedCostPerHourAfterUsd, row.ObservedCostPerHourAfterBytesUsd, row.ObservedCostPerHourAfterVolumeUsd, - ) - } - return result, nil -} - -// ListEnabledServiceStatuses returns enabled services (not DISABLED/INACTIVE), -// sorted by severity, limited to the given count. -func (s *serviceStatusesImpl) ListEnabledServiceStatuses(ctx context.Context, limit int64) ([]domain.ServiceStatus, error) { - rows, err := s.queries.ListEnabledServiceStatuses(ctx, limit) - if err != nil { - return nil, WrapSQLiteError(err, "list enabled service statuses") - } - - result := make([]domain.ServiceStatus, len(rows)) - for i, row := range rows { - result[i] = mapServiceStatus( - row.ServiceName, row.Health, - row.LogEventCount, row.LogEventAnalyzedCount, - row.PolicyPendingCount, row.PolicyApprovedCount, row.PolicyDismissedCount, - row.PolicyPendingCriticalCount, row.PolicyPendingHighCount, row.PolicyPendingMediumCount, row.PolicyPendingLowCount, - row.ServiceVolumePerHour, - row.ServiceDebugVolumePerHour, row.ServiceInfoVolumePerHour, row.ServiceWarnVolumePerHour, - row.ServiceErrorVolumePerHour, row.ServiceOtherVolumePerHour, - row.ServiceCostPerHourVolumeUsd, - row.LogEventVolumePerHour, row.LogEventBytesPerHour, - row.LogEventCostPerHourUsd, row.LogEventCostPerHourBytesUsd, row.LogEventCostPerHourVolumeUsd, - row.EstimatedVolumeReductionPerHour, row.EstimatedBytesReductionPerHour, - row.EstimatedCostReductionPerHourUsd, row.EstimatedCostReductionPerHourBytesUsd, row.EstimatedCostReductionPerHourVolumeUsd, - row.ObservedVolumePerHourBefore, row.ObservedVolumePerHourAfter, - row.ObservedBytesPerHourBefore, row.ObservedBytesPerHourAfter, - row.ObservedCostPerHourBeforeUsd, row.ObservedCostPerHourBeforeBytesUsd, row.ObservedCostPerHourBeforeVolumeUsd, - row.ObservedCostPerHourAfterUsd, row.ObservedCostPerHourAfterBytesUsd, row.ObservedCostPerHourAfterVolumeUsd, - ) - } - return result, nil -} - -//nolint:unparam // positional args mirror generated row fields -func mapServiceStatus( - serviceName *string, - health string, - eventCount, analyzedCount int64, - policyPending, policyApproved, policyDismissed int64, - pendingCritical, pendingHigh, pendingMedium, pendingLow int64, - svcVolume *float64, - svcDebugVolume, svcInfoVolume, svcWarnVolume, svcErrorVolume, svcOtherVolume *float64, - svcCostVolume *float64, - volume, bytes, costUSD, costBytes, costVolume *float64, - estVolume, estBytes, estCostUSD, estCostBytes, estCostVolume *float64, - obsVolBefore, obsVolAfter, obsBytesBefore, obsBytesAfter *float64, - obsCostBefore, obsCostBeforeBytes, obsCostBeforeVolume *float64, - obsCostAfter, obsCostAfterBytes, obsCostAfterVolume *float64, -) domain.ServiceStatus { - name := "" - if serviceName != nil { - name = *serviceName - } - return domain.ServiceStatus{ - Name: name, - Health: domain.ServiceHealth(health), - - LogEventCount: eventCount, - LogEventAnalyzedCount: analyzedCount, - - PolicyPendingCount: policyPending, - PolicyApprovedCount: policyApproved, - PolicyDismissedCount: policyDismissed, - PolicyPendingCriticalCount: pendingCritical, - PolicyPendingHighCount: pendingHigh, - PolicyPendingMediumCount: pendingMedium, - PolicyPendingLowCount: pendingLow, - - ServiceVolumePerHour: svcVolume, - ServiceDebugVolumePerHour: svcDebugVolume, - ServiceInfoVolumePerHour: svcInfoVolume, - ServiceWarnVolumePerHour: svcWarnVolume, - ServiceErrorVolumePerHour: svcErrorVolume, - ServiceOtherVolumePerHour: svcOtherVolume, - ServiceCostPerHourVolumeUSD: svcCostVolume, - - LogEventVolumePerHour: volume, - LogEventBytesPerHour: bytes, - LogEventCostPerHourUSD: costUSD, - LogEventCostPerHourBytesUSD: costBytes, - LogEventCostPerHourVolumeUSD: costVolume, - - EstimatedVolumeReductionPerHour: estVolume, - EstimatedBytesReductionPerHour: estBytes, - EstimatedCostReductionPerHourUSD: estCostUSD, - EstimatedCostReductionPerHourBytes: estCostBytes, - EstimatedCostReductionPerHourVolume: estCostVolume, - - ObservedVolumePerHourBefore: obsVolBefore, - ObservedVolumePerHourAfter: obsVolAfter, - ObservedBytesPerHourBefore: obsBytesBefore, - ObservedBytesPerHourAfter: obsBytesAfter, - ObservedCostPerHourBeforeUSD: obsCostBefore, - ObservedCostPerHourBeforeBytesUSD: obsCostBeforeBytes, - ObservedCostPerHourBeforeVolumeUSD: obsCostBeforeVolume, - ObservedCostPerHourAfterUSD: obsCostAfter, - ObservedCostPerHourAfterBytesUSD: obsCostAfterBytes, - ObservedCostPerHourAfterVolumeUSD: obsCostAfterVolume, - } -} diff --git a/internal/sqlite/services.go b/internal/sqlite/services.go deleted file mode 100644 index 0bf2fb27..00000000 --- a/internal/sqlite/services.go +++ /dev/null @@ -1,57 +0,0 @@ -package sqlite - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/sqlite/gen" -) - -// Services provides type-safe access to services. -type Services interface { - Count(ctx context.Context) (int64, error) - Get(ctx context.Context, id domain.ServiceID) (gen.Service, error) - SetEnabled(ctx context.Context, id domain.ServiceID, enabled bool) error -} - -// servicesImpl implements Services. -type servicesImpl struct { - read *gen.Queries - write *gen.Queries -} - -// Count returns the total number of services. -func (s *servicesImpl) Count(ctx context.Context) (int64, error) { - count, err := s.read.CountServices(ctx) - if err != nil { - return 0, WrapSQLiteError(err, "count services") - } - return count, nil -} - -// Get returns a service by ID. -func (s *servicesImpl) Get(ctx context.Context, id domain.ServiceID) (gen.Service, error) { - idStr := id.String() - svc, err := s.read.GetService(ctx, &idStr) - if err != nil { - return gen.Service{}, WrapSQLiteError(err, "get service") - } - return svc, nil -} - -// SetEnabled enables or disables a service. -func (s *servicesImpl) SetEnabled(ctx context.Context, id domain.ServiceID, enabled bool) error { - var val int64 - if enabled { - val = 1 - } - idStr := id.String() - err := s.write.SetServiceEnabled(ctx, gen.SetServiceEnabledParams{ - Enabled: &val, - ID: &idStr, - }) - if err != nil { - return WrapSQLiteError(err, "set service enabled") - } - return nil -} diff --git a/internal/sqlite/sqlitetest/db.go b/internal/sqlite/sqlitetest/db.go deleted file mode 100644 index 93d40c6c..00000000 --- a/internal/sqlite/sqlitetest/db.go +++ /dev/null @@ -1,25 +0,0 @@ -package sqlitetest - -import ( - "context" - "path/filepath" - "testing" - - "github.com/usetero/cli/internal/sqlite" -) - -// OpenBareDB creates a temporary SQLite database WITHOUT the PowerSync schema. -// Use this only for low-level tests (extension loading, watch hooks with custom tables). -// For most tests, use dbtest.OpenTestDB() which includes the full schema. -func OpenBareDB(t *testing.T) sqlite.DB { - t.Helper() - - dbPath := filepath.Join(t.TempDir(), "test.sqlite") - db, err := sqlite.Open(context.Background(), dbPath) - if err != nil { - t.Fatalf("sqlite.Open() error = %v", err) - } - - t.Cleanup(func() { db.Close() }) - return db -} diff --git a/internal/sqlite/sqlitetest/mock_db.go b/internal/sqlite/sqlitetest/mock_db.go deleted file mode 100644 index 25ce6886..00000000 --- a/internal/sqlite/sqlitetest/mock_db.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package sqlitetest provides test doubles for the sqlite package. -package sqlitetest - -import ( - "context" - "database/sql" - - "github.com/usetero/cli/internal/sqlite" -) - -// MockDB is a test double for sqlite.DB. -type MockDB struct { - // MessagesImpl is the mock messages implementation. - MessagesImpl sqlite.Messages - - // ConversationsImpl is the mock conversations implementation. - ConversationsImpl sqlite.Conversations - - // DatadogAccountStatusesImpl is the mock Datadog account statuses implementation. - DatadogAccountStatusesImpl sqlite.DatadogAccountStatuses - - // ServiceStatusesImpl is the mock service statuses implementation. - ServiceStatusesImpl sqlite.ServiceStatuses - - // ServicesImpl is the mock services implementation. - ServicesImpl sqlite.Services - - // LogEventsImpl is the mock log events implementation. - LogEventsImpl sqlite.LogEvents - - // LogEventStatusesImpl is the mock log event statuses implementation. - LogEventStatusesImpl sqlite.LogEventStatuses - - // LogEventPoliciesImpl is the mock log event policies implementation. - LogEventPoliciesImpl sqlite.LogEventPolicies - - // LogEventPolicyStatusesImpl is the mock policy statuses implementation. - LogEventPolicyStatusesImpl sqlite.LogEventPolicyStatuses - - // LogEventPolicyCategoryStatusesImpl is the mock policy category statuses implementation. - LogEventPolicyCategoryStatusesImpl sqlite.LogEventPolicyCategoryStatuses - - // CompliancePoliciesImpl is the mock compliance policies implementation. - CompliancePoliciesImpl sqlite.CompliancePolicies - - // QueryFunc is called by Query. - QueryFunc func(ctx context.Context, sql string, args ...any) (*sql.Rows, error) - - // QueryRowFunc is called by QueryRow. - QueryRowFunc func(ctx context.Context, sql string, args ...any) *sql.Row - - // ExecFunc is called by Exec. - ExecFunc func(ctx context.Context, sql string, args ...any) (sql.Result, error) - - // Closed is set to true when Close is called. - Closed bool -} - -// Ensure MockDB implements sqlite.DB. -var _ sqlite.DB = (*MockDB)(nil) - -// NewMockDB creates a new MockDB with sensible defaults. -func NewMockDB() *MockDB { - return &MockDB{} -} - -// Messages implements sqlite.DB. -func (m *MockDB) Messages() sqlite.Messages { - return m.MessagesImpl -} - -// Conversations implements sqlite.DB. -func (m *MockDB) Conversations() sqlite.Conversations { - return m.ConversationsImpl -} - -// DatadogAccountStatuses implements sqlite.DB. -func (m *MockDB) DatadogAccountStatuses() sqlite.DatadogAccountStatuses { - return m.DatadogAccountStatusesImpl -} - -// ServiceStatuses implements sqlite.DB. -func (m *MockDB) ServiceStatuses() sqlite.ServiceStatuses { - return m.ServiceStatusesImpl -} - -// Query implements sqlite.DB. -func (m *MockDB) Query(ctx context.Context, sql string, args ...any) (*sql.Rows, error) { - if m.QueryFunc != nil { - return m.QueryFunc(ctx, sql, args...) - } - return nil, nil -} - -// QueryRow implements sqlite.DB. -func (m *MockDB) QueryRow(ctx context.Context, sql string, args ...any) *sql.Row { - if m.QueryRowFunc != nil { - return m.QueryRowFunc(ctx, sql, args...) - } - return nil -} - -// Exec implements sqlite.DB. -func (m *MockDB) Exec(ctx context.Context, sql string, args ...any) (sql.Result, error) { - if m.ExecFunc != nil { - return m.ExecFunc(ctx, sql, args...) - } - return nil, nil -} - -// WithTx implements sqlite.DB. -func (m *MockDB) WithTx(ctx context.Context, fn func(tx *sqlite.Tx) error) error { - return nil -} - -// Raw implements sqlite.DB. -func (m *MockDB) Raw() *sql.DB { - return nil -} - -// ReadRaw implements sqlite.DB. -func (m *MockDB) ReadRaw() *sql.DB { - return nil -} - -// Services implements sqlite.DB. -func (m *MockDB) Services() sqlite.Services { - return m.ServicesImpl -} - -// LogEvents implements sqlite.DB. -func (m *MockDB) LogEvents() sqlite.LogEvents { - return m.LogEventsImpl -} - -// LogEventStatuses implements sqlite.DB. -func (m *MockDB) LogEventStatuses() sqlite.LogEventStatuses { - return m.LogEventStatusesImpl -} - -// LogEventPolicies implements sqlite.DB. -func (m *MockDB) LogEventPolicies() sqlite.LogEventPolicies { - return m.LogEventPoliciesImpl -} - -// LogEventPolicyStatuses implements sqlite.DB. -func (m *MockDB) LogEventPolicyStatuses() sqlite.LogEventPolicyStatuses { - return m.LogEventPolicyStatusesImpl -} - -// LogEventPolicyCategoryStatuses implements sqlite.DB. -func (m *MockDB) LogEventPolicyCategoryStatuses() sqlite.LogEventPolicyCategoryStatuses { - return m.LogEventPolicyCategoryStatusesImpl -} - -// CompliancePolicies implements sqlite.DB. -func (m *MockDB) CompliancePolicies() sqlite.CompliancePolicies { - return m.CompliancePoliciesImpl -} - -// PendingUploadCounts implements sqlite.DB. -func (m *MockDB) PendingUploadCounts(_ context.Context) (map[sqlite.Table]int64, error) { - return nil, nil -} - -// Close implements sqlite.DB. -func (m *MockDB) Close() error { - m.Closed = true - return nil -} diff --git a/internal/sqlite/storage.go b/internal/sqlite/storage.go deleted file mode 100644 index 97183ae6..00000000 --- a/internal/sqlite/storage.go +++ /dev/null @@ -1,74 +0,0 @@ -package sqlite - -import ( - "os" - "path/filepath" - - "github.com/usetero/cli/internal/config" -) - -// Storage provides database file operations. -type Storage interface { - DatabasePath(accountID string) (string, error) - ClearDatabase(accountID string) error - Clear() error -} - -// StorageService implements Storage. -type StorageService struct { - config *config.Config -} - -// Ensure StorageService implements Storage. -var _ Storage = (*StorageService)(nil) - -// NewStorageService creates a new storage service. -func NewStorageService(cfg *config.Config) *StorageService { - return &StorageService{config: cfg} -} - -// dataDir returns the directory for storing database files. -func (s *StorageService) dataDir() (string, error) { - baseDir, err := s.config.BaseDir() - if err != nil { - return "", err - } - return filepath.Join(baseDir, "databases"), nil -} - -// DatabasePath returns the path to the SQLite database for a specific account. -func (s *StorageService) DatabasePath(accountID string) (string, error) { - dataDir, err := s.dataDir() - if err != nil { - return "", err - } - return filepath.Join(dataDir, accountID+".sqlite"), nil -} - -// ClearDatabase removes the SQLite database file for a specific account. -func (s *StorageService) ClearDatabase(accountID string) error { - dbPath, err := s.DatabasePath(accountID) - if err != nil { - return err - } - if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// Clear removes all SQLite database files. -func (s *StorageService) Clear() error { - dataDir, err := s.dataDir() - if err != nil { - return err - } - - files, _ := filepath.Glob(filepath.Join(dataDir, "*.sqlite")) - for _, f := range files { - if err := os.Remove(f); err != nil && !os.IsNotExist(err) { - return err - } - } - return nil -} diff --git a/internal/sqlite/storage_test.go b/internal/sqlite/storage_test.go deleted file mode 100644 index 798c2e75..00000000 --- a/internal/sqlite/storage_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package sqlite_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/usetero/cli/internal/config" - "github.com/usetero/cli/internal/sqlite" -) - -func TestStorageService_DatabasePath(t *testing.T) { - t.Parallel() - - t.Run("returns path in databases directory", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-storage-path", "") - storage := sqlite.NewStorageService(cfg) - - path, err := storage.DatabasePath("acc_123") - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) - } - - if !strings.Contains(path, "databases") { - t.Errorf("path should contain 'databases' directory: %s", path) - } - if !strings.HasSuffix(path, "acc_123.sqlite") { - t.Errorf("path should end with account ID and .sqlite extension: %s", path) - } - }) - - t.Run("different accounts have different paths", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-storage-diff", "") - storage := sqlite.NewStorageService(cfg) - - path1, _ := storage.DatabasePath("acc_1") - path2, _ := storage.DatabasePath("acc_2") - - if path1 == path2 { - t.Error("different accounts should have different paths") - } - }) -} - -func TestStorageService_ClearDatabase(t *testing.T) { - t.Parallel() - - t.Run("removes existing database file", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-db", "") - storage := sqlite.NewStorageService(cfg) - - // Create the file first - path, _ := storage.DatabasePath("acc_to_delete") - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte("test"), 0644); err != nil { - t.Fatalf("write file: %v", err) - } - - // Verify it exists - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Fatal("file should exist before clear") - } - - // Clear it - err := storage.ClearDatabase("acc_to_delete") - if err != nil { - t.Fatalf("ClearDatabase() error = %v", err) - } - - // Verify it's gone - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Error("file should not exist after clear") - } - - // Cleanup - t.Cleanup(func() { - baseDir, _ := cfg.BaseDir() - os.RemoveAll(baseDir) - }) - }) - - t.Run("succeeds when file does not exist", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-nonexistent", "") - storage := sqlite.NewStorageService(cfg) - - err := storage.ClearDatabase("nonexistent_account") - - if err != nil { - t.Errorf("ClearDatabase() should not error for nonexistent file: %v", err) - } - }) -} - -func TestStorageService_Clear(t *testing.T) { - t.Parallel() - - t.Run("removes all sqlite files", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-all", "") - storage := sqlite.NewStorageService(cfg) - - // Create multiple files - path1, _ := storage.DatabasePath("acc_1") - path2, _ := storage.DatabasePath("acc_2") - if err := os.MkdirAll(filepath.Dir(path1), 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path1, []byte("test1"), 0644); err != nil { - t.Fatalf("write file 1: %v", err) - } - if err := os.WriteFile(path2, []byte("test2"), 0644); err != nil { - t.Fatalf("write file 2: %v", err) - } - - // Clear all - err := storage.Clear() - if err != nil { - t.Fatalf("Clear() error = %v", err) - } - - // Verify both are gone - if _, err := os.Stat(path1); !os.IsNotExist(err) { - t.Error("file 1 should not exist after clear") - } - if _, err := os.Stat(path2); !os.IsNotExist(err) { - t.Error("file 2 should not exist after clear") - } - - // Cleanup - t.Cleanup(func() { - baseDir, _ := cfg.BaseDir() - os.RemoveAll(baseDir) - }) - }) - - t.Run("succeeds when directory is empty", func(t *testing.T) { - t.Parallel() - - cfg, _ := config.Load("test-clear-empty", "") - storage := sqlite.NewStorageService(cfg) - - err := storage.Clear() - - if err != nil { - t.Errorf("Clear() should not error for empty directory: %v", err) - } - }) -} diff --git a/internal/sqlite/tables.go b/internal/sqlite/tables.go deleted file mode 100644 index 689f2f07..00000000 --- a/internal/sqlite/tables.go +++ /dev/null @@ -1,13 +0,0 @@ -package sqlite - -// Table represents a watched table. -type Table string - -// Table names matching the schema. -const ( - TableConversations Table = "conversations" - TableLogEventPolicies Table = "log_event_policies" - TableLogEvents Table = "log_events" - TableMessages Table = "messages" - TableServices Table = "services" -) diff --git a/internal/sqlite/timeout.go b/internal/sqlite/timeout.go deleted file mode 100644 index b5488531..00000000 --- a/internal/sqlite/timeout.go +++ /dev/null @@ -1,68 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "time" - - "github.com/usetero/cli/internal/sqlite/gen" -) - -// defaultQueryTimeout is applied to any query whose context has no deadline. -// Callers can override by setting their own deadline on the context. -const defaultQueryTimeout = 3 * time.Second - -// Ensure timeoutDB implements gen.DBTX. -var _ gen.DBTX = (*timeoutDB)(nil) - -// timeoutDB wraps a *sql.DB and applies defaultQueryTimeout to any context -// that doesn't already have a deadline set by the caller. -type timeoutDB struct { - db *sql.DB -} - -func withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { - return WithTimeout(ctx, defaultQueryTimeout) -} - -// WithTimeout applies timeout unless ctx already has a deadline. -// If ctx is nil, context.Background() is used. -func WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if ctx == nil { - ctx = context.Background() - } - if _, ok := ctx.Deadline(); ok { - return ctx, func() {} - } - if timeout <= 0 { - timeout = defaultQueryTimeout - } - return context.WithTimeout(ctx, timeout) -} - -func (t *timeoutDB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return t.db.ExecContext(ctx, query, args...) -} - -func (t *timeoutDB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() - return t.db.PrepareContext(ctx, query) -} - -func (t *timeoutDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { - // Not canceled here — the caller iterates the returned *sql.Rows after - // this method returns. The timeout still fires after defaultQueryTimeout - // if the query is stuck; it just isn't eagerly cleaned up. - ctx, _ = withTimeout(ctx) //nolint:lostcancel // caller iterates rows after return; timeout still fires - return t.db.QueryContext(ctx, query, args...) -} - -func (t *timeoutDB) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { - // Not canceled here — the caller calls Scan() on the returned *sql.Row - // after this method returns, and Scan checks ctx.Err(). Same as QueryContext. - ctx, _ = withTimeout(ctx) //nolint:lostcancel // caller calls Scan after return; timeout still fires - return t.db.QueryRowContext(ctx, query, args...) -} From fd3ae32d0abfe686627c23ebc28ca98ec0e80977 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 12:15:42 -0700 Subject: [PATCH 12/20] refactor: remove the workspace concept, account is the working context Workspaces were removed from the control plane. Drop the concept end to end: - onboarding completes after datadog setup (the workspace-select gate is gone); EventDatadogReady / EventDatadogDiscoveryDone are terminal - bootstrap State/Completion/OnboardingComplete/PreflightState lose their Workspace fields; WorkspaceSelected event and GateWorkspaceSelect removed - delete the onboarding/workspaces step and the synthetic GraphQL WorkspaceService stub - chat, app, and the status bar key off the account; the org/workspace status segment shows the org only - remove domain.Workspace/WorkspaceID and the org-preference default_workspace_id accessors The account is now the sole post-org working context. --- internal/app/app.go | 2 - internal/app/app_onboarding_controls.go | 4 +- internal/app/chat/chat_test.go | 2 +- internal/app/chat/empty_state_poll_test.go | 1 - internal/app/chat/model.go | 17 ++-- internal/app/onboarding/datadog/check_test.go | 6 +- .../app/onboarding/datadog/discovery_test.go | 6 +- internal/app/onboarding/gate_definitions.go | 5 - .../app/onboarding/gate_definitions_test.go | 2 - .../app/onboarding/gate_requirements_test.go | 2 +- .../onboarding/preflight/preflight_model.go | 9 +- .../onboarding/preflight/preflight_update.go | 1 - internal/app/onboarding/transition_cmds.go | 13 +-- internal/app/onboarding/transition_policy.go | 1 - internal/app/onboarding/transitions_test.go | 71 +++++--------- .../onboarding/workspaces/select_effects.go | 34 ------- .../app/onboarding/workspaces/select_model.go | 94 ------------------- .../app/onboarding/workspaces/select_test.go | 29 ------ .../app/onboarding/workspaces/select_types.go | 16 ---- .../onboarding/workspaces/select_update.go | 63 ------------- .../app/onboarding/workspaces/select_view.go | 35 ------- internal/app/onboarding_orchestration.go | 2 - internal/app/statusbar/statusbar.go | 3 +- internal/app/statusbar/statusbar_data.go | 2 - .../boundary/graphql/apitest/factories.go | 13 --- .../boundary/graphql/apitest/mock_services.go | 4 - .../graphql/apitest/mock_workspaces.go | 28 ------ internal/boundary/graphql/services.go | 2 - internal/boundary/graphql/workspace.go | 42 --------- internal/cmd/debug.go | 1 - internal/core/bootstrap/completion.go | 19 ++-- internal/core/bootstrap/completion_test.go | 25 ++--- internal/core/bootstrap/event_adapter.go | 2 - internal/core/bootstrap/event_adapter_test.go | 2 +- internal/core/bootstrap/events.go | 31 +++--- internal/core/bootstrap/events_test.go | 27 ++++-- internal/core/bootstrap/gate_requirements.go | 2 +- .../core/bootstrap/gate_requirements_test.go | 1 - internal/core/bootstrap/gates.go | 1 - internal/core/bootstrap/messages.go | 32 +++---- internal/core/bootstrap/requirements.go | 4 - internal/core/bootstrap/requirements_test.go | 8 +- internal/core/bootstrap/state.go | 17 ---- internal/core/bootstrap/transitions_test.go | 16 +--- internal/domain/conversation.go | 9 +- internal/domain/workspace.go | 20 ---- internal/preferences/org.go | 23 +---- .../preferencestest/mock_preferences.go | 32 +------ 48 files changed, 136 insertions(+), 645 deletions(-) delete mode 100644 internal/app/onboarding/workspaces/select_effects.go delete mode 100644 internal/app/onboarding/workspaces/select_model.go delete mode 100644 internal/app/onboarding/workspaces/select_test.go delete mode 100644 internal/app/onboarding/workspaces/select_types.go delete mode 100644 internal/app/onboarding/workspaces/select_update.go delete mode 100644 internal/app/onboarding/workspaces/select_view.go delete mode 100644 internal/boundary/graphql/apitest/mock_workspaces.go delete mode 100644 internal/boundary/graphql/workspace.go delete mode 100644 internal/domain/workspace.go diff --git a/internal/app/app.go b/internal/app/app.go index a6f4779e..ce77a17a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -69,7 +69,6 @@ type Model struct { toolRegistry *chattools.Registry user *auth.User account domain.Account - workspace domain.Workspace // Components statusBar *statusbar.Model @@ -193,7 +192,6 @@ func (m *Model) newChat() *chat.Model { return chat.New( m.user, m.account, - m.workspace, m.theme, m.services, m.runtimeDeps, diff --git a/internal/app/app_onboarding_controls.go b/internal/app/app_onboarding_controls.go index bdab5868..f9ede360 100644 --- a/internal/app/app_onboarding_controls.go +++ b/internal/app/app_onboarding_controls.go @@ -60,8 +60,8 @@ func (m *Model) switchOrganization() tea.Cmd { return m.restartOnboarding() } -// switchAccount clears account preference (cascades to workspace) and -// re-enters onboarding. The saved org auto-selects, then prompts for account. +// switchAccount clears the account preference and re-enters onboarding. The +// saved org auto-selects, then prompts for account. func (m *Model) switchAccount() tea.Cmd { m.scope.Info("switching account") _ = m.orgPrefs.ClearDefaultAccountID() diff --git a/internal/app/chat/chat_test.go b/internal/app/chat/chat_test.go index 13fb1f9d..16c8621c 100644 --- a/internal/app/chat/chat_test.go +++ b/internal/app/chat/chat_test.go @@ -32,7 +32,7 @@ func newTestChat(t *testing.T, client chat.Client) *Model { services := graphql.NewServiceSetFromClient(apitest.NewMockClient(), scope) runtimeDeps := usecase.NewRuntimeDeps(client) - m := New(nil, domain.Account{ID: "acct-1"}, domain.Workspace{ID: "ws-1"}, theme, services, runtimeDeps, nil, scope) + m := New(nil, domain.Account{ID: "acct-1"}, theme, services, runtimeDeps, nil, scope) m.SetSize(80, 40) return m } diff --git a/internal/app/chat/empty_state_poll_test.go b/internal/app/chat/empty_state_poll_test.go index 04fe7147..099392d9 100644 --- a/internal/app/chat/empty_state_poll_test.go +++ b/internal/app/chat/empty_state_poll_test.go @@ -33,7 +33,6 @@ func newEmptyStateChat(t *testing.T, summary domain.AccountSummary) *Model { return New( nil, domain.Account{ID: "acct-1"}, - domain.Workspace{ID: "ws-1"}, styles.NewTheme(true), graphql.ServiceSet{Status: stubStatus{summary: summary}}, usecase.RuntimeDeps{EffectContext: context.Background()}, diff --git a/internal/app/chat/model.go b/internal/app/chat/model.go index c377c52e..44f231a1 100644 --- a/internal/app/chat/model.go +++ b/internal/app/chat/model.go @@ -57,14 +57,13 @@ type Model struct { conversationID domain.ConversationID session *corechat.Session - user *auth.User - account domain.Account - workspace domain.Workspace - theme styles.Theme - width int - height int - originX int - originY int + user *auth.User + account domain.Account + theme styles.Theme + width int + height int + originX int + originY int // Empty state policySummary *domain.AccountSummary @@ -88,7 +87,6 @@ type emptyStateSummaryLoadedMsg struct { func New( user *auth.User, account domain.Account, - workspace domain.Workspace, theme styles.Theme, services graphql.ServiceSet, runtimeDeps usecase.RuntimeDeps, @@ -103,7 +101,6 @@ func New( messageList: messagelist.New(theme, runtimeDeps, toolRegistry, scope), user: user, account: account, - workspace: workspace, theme: theme, services: services, runtimeDeps: runtimeDeps, diff --git a/internal/app/onboarding/datadog/check_test.go b/internal/app/onboarding/datadog/check_test.go index 28aac1f4..2eca94ce 100644 --- a/internal/app/onboarding/datadog/check_test.go +++ b/internal/app/onboarding/datadog/check_test.go @@ -19,7 +19,7 @@ func TestCheckHasDatadogEmitsReady(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return true, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) cmd := m.Update(datadogCheckCompletedMsg{hasDatadog: true}) @@ -38,7 +38,7 @@ func TestCheckNoDatadogEmitsNeeded(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return false, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) cmd := m.Update(datadogCheckCompletedMsg{hasDatadog: false}) @@ -57,7 +57,7 @@ func TestCheckErrorEnablesRetry(t *testing.T) { mockDatadog.HasAccountFunc = func(context.Context, domain.AccountID) (bool, error) { return false, errors.New("boom") } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewCheck(context.Background(), styles.NewTheme(true), domain.Account{ID: "acc-1"}, services, logtest.NewScope(t)) if cmd := m.Update(datadogCheckCompletedMsg{err: errors.New("boom")}); cmd == nil { diff --git a/internal/app/onboarding/datadog/discovery_test.go b/internal/app/onboarding/datadog/discovery_test.go index 23db780e..fb914eba 100644 --- a/internal/app/onboarding/datadog/discovery_test.go +++ b/internal/app/onboarding/datadog/discovery_test.go @@ -21,7 +21,7 @@ func TestDiscoveryPollTickSchedulesAsyncFetch(t *testing.T) { callCount++ return &graphql.DatadogAccountStatus{}, nil } - services := apitest.NewMockServiceSet(nil, nil, nil, mockDatadog) + services := apitest.NewMockServiceSet(nil, nil, mockDatadog) m := NewDiscovery(context.Background(), styles.NewTheme(true), "dd-1", services, logtest.NewScope(t)) cmd := m.Update(discoveryPollTickMsg{}) @@ -48,7 +48,7 @@ func TestDiscoveryStatusSchedulesTimerTick(t *testing.T) { context.Background(), styles.NewTheme(true), "dd-1", - apitest.NewMockServiceSet(nil, nil, nil, apitest.NewMockDatadogAccounts()), + apitest.NewMockServiceSet(nil, nil, apitest.NewMockDatadogAccounts()), logtest.NewScope(t), ) @@ -70,7 +70,7 @@ func TestDiscoveryStatusReadyCompletesStep(t *testing.T) { context.Background(), styles.NewTheme(true), "dd-1", - apitest.NewMockServiceSet(nil, nil, nil, apitest.NewMockDatadogAccounts()), + apitest.NewMockServiceSet(nil, nil, apitest.NewMockDatadogAccounts()), logtest.NewScope(t), ) diff --git a/internal/app/onboarding/gate_definitions.go b/internal/app/onboarding/gate_definitions.go index eac67a12..ce0bc7c3 100644 --- a/internal/app/onboarding/gate_definitions.go +++ b/internal/app/onboarding/gate_definitions.go @@ -10,7 +10,6 @@ import ( "github.com/usetero/cli/internal/app/onboarding/preflight" "github.com/usetero/cli/internal/app/onboarding/role" "github.com/usetero/cli/internal/app/onboarding/runtimeinit" - "github.com/usetero/cli/internal/app/onboarding/workspaces" "github.com/usetero/cli/internal/core/bootstrap" ) @@ -52,8 +51,6 @@ func (m *Model) newStepForGate(gate Gate) (Step, error) { return datadog.NewAppKey(m.ctx, m.theme, *m.state.Account, m.state.DDSite, m.state.DDAPIKey, m.services, m.scope), nil case bootstrap.GateDatadogDiscovery: return datadog.NewDiscovery(m.ctx, m.theme, m.state.DDAccount, m.services, m.scope), nil - case bootstrap.GateWorkspaceSelect: - return workspaces.NewSelect(m.ctx, m.theme, *m.state.Account, m.services, m.orgPrefs, m.scope), nil default: return nil, fmt.Errorf("unsupported gate %q", gate) } @@ -66,8 +63,6 @@ func (m *Model) validateGateState(gate Gate) error { return fmt.Errorf("gate %q requires org", gate) case req.NeedsAccount && m.state.Account == nil: return fmt.Errorf("gate %q requires account", gate) - case req.NeedsWorkspace && m.state.Workspace == nil: - return fmt.Errorf("gate %q requires workspace", gate) case req.NeedsDDSite && m.state.DDSite == "": return fmt.Errorf("gate %q requires datadog site", gate) case req.NeedsDDAPIKey && m.state.DDAPIKey == "": diff --git a/internal/app/onboarding/gate_definitions_test.go b/internal/app/onboarding/gate_definitions_test.go index e989c982..da2b776a 100644 --- a/internal/app/onboarding/gate_definitions_test.go +++ b/internal/app/onboarding/gate_definitions_test.go @@ -15,7 +15,6 @@ func TestNewStepForGateCoverage(t *testing.T) { m.state.DDSite = "US1" m.state.DDAPIKey = "api-key" m.state.DDAccount = "dd-1" - m.state.Workspace = ptrWorkspace("ws-1") expected := []Gate{ bootstrap.GatePreflight, @@ -31,7 +30,6 @@ func TestNewStepForGateCoverage(t *testing.T) { bootstrap.GateDatadogAPIKey, bootstrap.GateDatadogAppKey, bootstrap.GateDatadogDiscovery, - bootstrap.GateWorkspaceSelect, } for _, gate := range expected { diff --git a/internal/app/onboarding/gate_requirements_test.go b/internal/app/onboarding/gate_requirements_test.go index 38e34a6d..fdd50f5e 100644 --- a/internal/app/onboarding/gate_requirements_test.go +++ b/internal/app/onboarding/gate_requirements_test.go @@ -21,7 +21,7 @@ func TestRewindGateFor(t *testing.T) { {name: "datadog api rewinds to region when site missing", target: bootstrap.GateDatadogAPIKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogRegion}, {name: "datadog app rewinds to api key when api key missing", target: bootstrap.GateDatadogAppKey, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1"), DDSite: "US1"}, want: bootstrap.GateDatadogAPIKey}, {name: "discovery rewinds to datadog check without dd account", target: bootstrap.GateDatadogDiscovery, state: bootstrap.State{Org: ptrOrg("org-1"), Account: ptrAccount("acc-1")}, want: bootstrap.GateDatadogCheck}, - {name: "workspace select rewinds to account without account", target: bootstrap.GateWorkspaceSelect, state: bootstrap.State{Org: ptrOrg("org-1")}, want: bootstrap.GateAccountSelect}, + {name: "datadog check rewinds to account without account", target: bootstrap.GateDatadogCheck, state: bootstrap.State{Org: ptrOrg("org-1")}, want: bootstrap.GateAccountSelect}, } for _, tc := range tests { diff --git a/internal/app/onboarding/preflight/preflight_model.go b/internal/app/onboarding/preflight/preflight_model.go index 033b7e9d..cca9eb8d 100644 --- a/internal/app/onboarding/preflight/preflight_model.go +++ b/internal/app/onboarding/preflight/preflight_model.go @@ -75,11 +75,10 @@ func New( func (m *Model) Init() tea.Cmd { m.started = time.Now() m.state = bootstrap.PreflightState{ - Outcome: bootstrap.PreflightOutcomeResolved, - Role: m.userPref.GetRole(), - ActiveOrgID: m.userPref.GetActiveOrgID(), - DefaultAccountID: m.orgPref.GetDefaultAccountID(), - DefaultWorkspaceID: m.orgPref.GetDefaultWorkspaceID(), + Outcome: bootstrap.PreflightOutcomeResolved, + Role: m.userPref.GetRole(), + ActiveOrgID: m.userPref.GetActiveOrgID(), + DefaultAccountID: m.orgPref.GetDefaultAccountID(), } m.stage = stageAuth return tea.Batch(m.spinner.Tick, m.checkAuth()) diff --git a/internal/app/onboarding/preflight/preflight_update.go b/internal/app/onboarding/preflight/preflight_update.go index 9ad688bf..bc837d94 100644 --- a/internal/app/onboarding/preflight/preflight_update.go +++ b/internal/app/onboarding/preflight/preflight_update.go @@ -67,7 +67,6 @@ func (m *Model) handleResult(msg preflightResolutionCompletedMsg) tea.Cmd { "role", msg.state.Role, "active_org_id", msg.state.ActiveOrgID, "default_account_id", msg.state.DefaultAccountID, - "default_workspace_id", msg.state.DefaultWorkspaceID, "outcome", msg.state.Outcome, "org", msg.state.Org != nil, "account", msg.state.Account != nil, diff --git a/internal/app/onboarding/transition_cmds.go b/internal/app/onboarding/transition_cmds.go index b09ab645..511c2994 100644 --- a/internal/app/onboarding/transition_cmds.go +++ b/internal/app/onboarding/transition_cmds.go @@ -23,23 +23,20 @@ func (m *Model) commandForTransition(event bootstrap.Event, transition bootstrap m.scope.Info("onboarding complete", slog.String("org_id", transition.Completion.Org.ID.String()), slog.String("account_id", transition.Completion.Account.ID.String()), - slog.String("workspace_id", string(transition.Completion.Workspace.ID)), ) return func() tea.Msg { return bootstrap.OnboardingComplete{ - User: transition.Completion.User, - Org: transition.Completion.Org, - Account: transition.Completion.Account, - Workspace: transition.Completion.Workspace, + User: transition.Completion.User, + Org: transition.Completion.Org, + Account: transition.Completion.Account, } } case bootstrap.TransitionNoop: - if event.Kind == bootstrap.EventWorkspaceSelected { - m.scope.Error("workspace selected without required onboarding state", + if event.Kind == bootstrap.EventDatadogDiscoveryDone { + m.scope.Error("datadog discovery completed without required onboarding state", slog.Bool("has_user", m.state.User != nil), slog.Bool("has_org", m.state.Org != nil), slog.Bool("has_account", m.state.Account != nil), - slog.Bool("has_workspace", m.state.Workspace != nil), ) } return nil diff --git a/internal/app/onboarding/transition_policy.go b/internal/app/onboarding/transition_policy.go index 46e89412..8fa2a897 100644 --- a/internal/app/onboarding/transition_policy.go +++ b/internal/app/onboarding/transition_policy.go @@ -31,7 +31,6 @@ func (m *Model) logPreflightResolved(preflight bootstrap.PreflightResolved) { slog.String("role", preflight.State.Role), slog.String("active_org_id", preflight.State.ActiveOrgID.String()), slog.String("default_account_id", preflight.State.DefaultAccountID.String()), - slog.String("default_workspace_id", string(preflight.State.DefaultWorkspaceID)), slog.Bool("org_resolved", preflight.State.Org != nil), slog.Bool("account_resolved", preflight.State.Account != nil), slog.String("error", preflight.State.Error)) diff --git a/internal/app/onboarding/transitions_test.go b/internal/app/onboarding/transitions_test.go index 88659f95..76643bbe 100644 --- a/internal/app/onboarding/transitions_test.go +++ b/internal/app/onboarding/transitions_test.go @@ -98,53 +98,36 @@ func TestHandleTransitionPreflightRouting(t *testing.T) { } } -func TestHandleTransitionDatadogBranchRouting(t *testing.T) { +func TestHandleTransitionDatadogNeededRoutesToRegion(t *testing.T) { t.Parallel() - tests := []struct { - name string - msg any - wantGate Gate - }{ - {name: "datadog ready goes to workspace select", msg: bootstrap.DatadogReady{}, wantGate: bootstrap.GateWorkspaceSelect}, - {name: "datadog needed goes to region", msg: bootstrap.DatadogNeeded{}, wantGate: bootstrap.GateDatadogRegion}, - {name: "discovery complete goes to workspace select", msg: bootstrap.DatadogDiscoveryComplete{}, wantGate: bootstrap.GateWorkspaceSelect}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - m := newTestModel(t) - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - _ = m.handleTransition(tc.msg) - if m.gate != tc.wantGate { - t.Fatalf("gate = %s, want %s", m.gate, tc.wantGate) - } - }) + m := newTestModel(t) + m.state.Org = ptrOrg("org-1") + m.state.Account = ptrAccount("acc-1") + _ = m.handleTransition(bootstrap.DatadogNeeded{}) + if m.gate != bootstrap.GateDatadogRegion { + t.Fatalf("gate = %s, want %s", m.gate, bootstrap.GateDatadogRegion) } } -func TestHandleTransitionWorkspaceSelectedCompletesOnboarding(t *testing.T) { +func TestHandleTransitionDatadogCompletesOnboarding(t *testing.T) { t.Parallel() - m := newTestModel(t) - m.state.User = ptrUser("user-1") - m.state.Org = ptrOrg("org-1") - m.state.Account = ptrAccount("acc-1") - workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - - // Workspace selection is the terminal step: it completes onboarding rather - // than advancing to a sync gate. - cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: workspace}) - if cmd == nil { - t.Fatal("expected transition command") - } - if m.state.Workspace == nil || m.state.Workspace.ID != workspace.ID { - t.Fatalf("workspace state not set correctly: %+v", m.state.Workspace) - } - if _, ok := cmd().(bootstrap.OnboardingComplete); !ok { - t.Fatalf("expected OnboardingComplete command") + // Datadog ready and discovery-complete are both terminal: they complete + // onboarding rather than advancing to a workspace or sync gate. + for _, msg := range []any{bootstrap.DatadogReady{}, bootstrap.DatadogDiscoveryComplete{}} { + m := newTestModel(t) + m.state.User = ptrUser("user-1") + m.state.Org = ptrOrg("org-1") + m.state.Account = ptrAccount("acc-1") + + cmd := m.handleTransition(msg) + if cmd == nil { + t.Fatalf("expected transition command for %T", msg) + } + if _, ok := cmd().(bootstrap.OnboardingComplete); !ok { + t.Fatalf("expected OnboardingComplete command for %T", msg) + } } } @@ -229,7 +212,7 @@ func TestHandleTransitionOrgSelectedClearsServiceAccountScope(t *testing.T) { } } -func TestHandleTransitionWorkspaceSelectedMissingStateNoops(t *testing.T) { +func TestHandleTransitionDatadogCompleteMissingStateNoops(t *testing.T) { t.Parallel() m := newTestModel(t) @@ -237,7 +220,7 @@ func TestHandleTransitionWorkspaceSelectedMissingStateNoops(t *testing.T) { m.state.Org = ptrOrg("org-1") // Missing account should not panic or emit a completion payload. - cmd := m.handleTransition(bootstrap.WorkspaceSelected{Workspace: domain.Workspace{ID: "ws-1"}}) + cmd := m.handleTransition(bootstrap.DatadogDiscoveryComplete{}) if cmd != nil { t.Fatal("expected nil command when completion state is incomplete") } @@ -272,10 +255,6 @@ func ptrAccount(id string) *domain.Account { return &domain.Account{ID: domain.AccountID(id), Name: id} } -func ptrWorkspace(id string) *domain.Workspace { - return &domain.Workspace{ID: domain.WorkspaceID(id), Name: id} -} - func ptrUser(id string) *iauth.User { return &iauth.User{ID: id} } diff --git a/internal/app/onboarding/workspaces/select_effects.go b/internal/app/onboarding/workspaces/select_effects.go deleted file mode 100644 index 963ad089..00000000 --- a/internal/app/onboarding/workspaces/select_effects.go +++ /dev/null @@ -1,34 +0,0 @@ -package workspaces - -import ( - "log/slog" - - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/core/bootstrap" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -func (m *SelectModel) loadWorkspaces() tea.Cmd { - return func() tea.Msg { - workspaces, err := m.services.Workspaces.List(m.ctx, m.account.ID) - if err != nil { - m.scope.Error("failed to load workspaces", slog.Any("error", err)) - return remotelist.LoadResult{Err: err} - } - - m.scope.Debug("loaded workspaces", slog.Int("count", len(workspaces))) - items := make([]remotelist.Item, len(workspaces)) - for i, ws := range workspaces { - items[i] = ws // domain.Workspace implements FilterValue() - } - return remotelist.LoadResult{Items: items} - } -} - -func (m *SelectModel) emitSelected(ws domain.Workspace) tea.Cmd { - return func() tea.Msg { - return bootstrap.WorkspaceSelected{Workspace: ws} - } -} diff --git a/internal/app/onboarding/workspaces/select_model.go b/internal/app/onboarding/workspaces/select_model.go deleted file mode 100644 index 46639f04..00000000 --- a/internal/app/onboarding/workspaces/select_model.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package workspaces provides workspace selection steps. -package workspaces - -import ( - "context" - "log/slog" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - onbstatus "github.com/usetero/cli/internal/app/onboarding/status" - "github.com/usetero/cli/internal/app/onboarding/stepkit" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/preferences" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -// SelectModel handles workspace selection. -type SelectModel struct { - ctx context.Context - theme styles.Theme - services graphql.ServiceSet - prefs preferences.OrgPreferences - account domain.Account - scope log.Scope - - list *remotelist.Model - workspaces []domain.Workspace - width int - height int -} - -// NewSelect creates a new workspace select step. -func NewSelect( - ctx context.Context, - theme styles.Theme, - account domain.Account, - services graphql.ServiceSet, - prefs preferences.OrgPreferences, - scope log.Scope, -) *SelectModel { - if ctx == nil { - panic("ctx is nil") - } - if prefs == nil { - panic("prefs is nil") - } - - scope.Debug("initialized") - - return &SelectModel{ - ctx: ctx, - theme: theme, - services: services, - prefs: prefs, - account: account, - scope: scope, - list: remotelist.New(theme, "Loading workspaces"), - } -} - -// Init starts loading workspaces. -func (m *SelectModel) Init() tea.Cmd { - m.scope.Debug("loading workspaces", slog.String("account_id", m.account.ID.String())) - return m.list.InitWithLoader(m.loadWorkspaces()) -} - -// SetSize updates dimensions. -func (m *SelectModel) SetSize(width, height int) { - m.width = width - m.height = height - m.list.SetWidth(width) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *SelectModel) ShortHelp() []key.Binding { - return stepkit.RemoteListShortHelp(m.list, - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), - ) -} - -func (m *SelectModel) Hidden() bool { - return m.list.IsLoading() -} - -func (m *SelectModel) Status() onbstatus.StepStatus { - return onbstatus.StepStatus{ - Title: "Select workspace", - Details: "Loading workspaces...", - } -} diff --git a/internal/app/onboarding/workspaces/select_test.go b/internal/app/onboarding/workspaces/select_test.go deleted file mode 100644 index f95e2782..00000000 --- a/internal/app/onboarding/workspaces/select_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package workspaces - -import ( - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestFindWorkspaceByID(t *testing.T) { - t.Parallel() - - workspaces := []domain.Workspace{ - {ID: "ws-1", Name: "One"}, - {ID: "ws-2", Name: "Two"}, - } - - got := findWorkspaceByID(workspaces, "ws-2") - if got == nil || got.ID != "ws-2" { - t.Fatalf("expected ws-2, got %#v", got) - } - - if got := findWorkspaceByID(workspaces, "missing"); got != nil { - t.Fatalf("expected nil for missing workspace, got %#v", got) - } - - if got := findWorkspaceByID(workspaces, ""); got != nil { - t.Fatalf("expected nil for empty id, got %#v", got) - } -} diff --git a/internal/app/onboarding/workspaces/select_types.go b/internal/app/onboarding/workspaces/select_types.go deleted file mode 100644 index 73e5615a..00000000 --- a/internal/app/onboarding/workspaces/select_types.go +++ /dev/null @@ -1,16 +0,0 @@ -package workspaces - -import "github.com/usetero/cli/internal/domain" - -func findWorkspaceByID(workspaces []domain.Workspace, id domain.WorkspaceID) *domain.Workspace { - if id == "" { - return nil - } - for _, ws := range workspaces { - if ws.ID == id { - resolved := ws - return &resolved - } - } - return nil -} diff --git a/internal/app/onboarding/workspaces/select_update.go b/internal/app/onboarding/workspaces/select_update.go deleted file mode 100644 index 8dbcca60..00000000 --- a/internal/app/onboarding/workspaces/select_update.go +++ /dev/null @@ -1,63 +0,0 @@ -package workspaces - -import ( - "log/slog" - - tea "charm.land/bubbletea/v2" - - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/app/onboarding/stepkit" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/tea/components/remotelist" -) - -// Update handles messages. -func (m *SelectModel) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case remotelist.LoadResult: - return m.handleLoadResult(msg) - - case tea.KeyPressMsg: - if m.list.IsLoading() { - return nil - } - switch msg.String() { - case "enter": - if item := m.list.SelectedItem(); item != nil { - if ws, ok := item.(domain.Workspace); ok { - _ = m.prefs.SetDefaultWorkspaceID(ws.ID) - m.scope.Info("workspace selected", slog.String("workspace_id", string(ws.ID))) - return m.emitSelected(ws) - } - } - case "r": - if m.list.HasError() { - m.scope.Debug("retrying workspace load") - return m.list.Retry() - } - } - } - - return m.list.Update(msg) -} - -func (m *SelectModel) handleLoadResult(msg remotelist.LoadResult) tea.Cmd { - if msg.Err != nil { - return tea.Batch(m.list.Update(msg), appevents.PublishErrorToastCmd("Failed to load workspaces", msg.Err, false)) - } - m.workspaces = stepkit.CastItems[domain.Workspace](msg.Items) - - if prefWS := findWorkspaceByID(m.workspaces, m.prefs.GetDefaultWorkspaceID()); prefWS != nil { - m.scope.Info("workspace restored from preference", slog.String("workspace_id", string(prefWS.ID))) - return m.emitSelected(*prefWS) - } - - if len(m.workspaces) == 1 { - ws := m.workspaces[0] - _ = m.prefs.SetDefaultWorkspaceID(ws.ID) - m.scope.Info("workspace auto-selected", slog.String("workspace_id", string(ws.ID))) - return m.emitSelected(ws) - } - - return m.list.Update(msg) -} diff --git a/internal/app/onboarding/workspaces/select_view.go b/internal/app/onboarding/workspaces/select_view.go deleted file mode 100644 index c811f994..00000000 --- a/internal/app/onboarding/workspaces/select_view.go +++ /dev/null @@ -1,35 +0,0 @@ -package workspaces - -import ( - "charm.land/lipgloss/v2" - - "github.com/usetero/cli/internal/app/onboarding/errorfmt" -) - -// View renders the workspace selection UI. -func (m *SelectModel) View() string { - s := m.theme.Styles - - if m.list.IsLoading() { - return m.list.View() - } - - if m.list.HasError() { - return lipgloss.JoinVertical( - lipgloss.Left, - s.Error.Render(errorfmt.UserFacing(m.list.Error(), "Failed to load workspaces.")), - s.Help.Render("Press 'r' to retry."), - ) - } - - title := s.Title.Render("Select your workspace") - subtitle := s.Help.Render("Workspaces organize your conversations") - - return lipgloss.JoinVertical( - lipgloss.Left, - title, - subtitle, - "", - m.list.View(), - ) -} diff --git a/internal/app/onboarding_orchestration.go b/internal/app/onboarding_orchestration.go index 0b0d2f4e..3a336267 100644 --- a/internal/app/onboarding_orchestration.go +++ b/internal/app/onboarding_orchestration.go @@ -46,11 +46,9 @@ func (m *Model) handleOnboardingMessage(msg tea.Msg) (tea.Cmd, bool) { m.state = stateChat m.user = msg.User m.account = msg.Account - m.workspace = msg.Workspace m.scope.Info("onboarding complete", "org", msg.Org.Name, "account", msg.Account.Name, - "workspace", msg.Workspace.Name, ) // Create chat model (sizing happens via updateLayout) diff --git a/internal/app/statusbar/statusbar.go b/internal/app/statusbar/statusbar.go index b420ac1f..d304c013 100644 --- a/internal/app/statusbar/statusbar.go +++ b/internal/app/statusbar/statusbar.go @@ -37,8 +37,7 @@ type Model struct { width int // Account context - org string - workspace string + org string // Conversation title string diff --git a/internal/app/statusbar/statusbar_data.go b/internal/app/statusbar/statusbar_data.go index 0b4ad042..ea7eae4a 100644 --- a/internal/app/statusbar/statusbar_data.go +++ b/internal/app/statusbar/statusbar_data.go @@ -43,8 +43,6 @@ func (m *Model) ingestStatusMessages(msg tea.Msg) { m.org = msg.Org.Name case bootstrap.OrgCreated: m.org = msg.Org.Name - case bootstrap.WorkspaceSelected: - m.workspace = msg.Workspace.Name } } diff --git a/internal/boundary/graphql/apitest/factories.go b/internal/boundary/graphql/apitest/factories.go index 42699e43..cfbed1ef 100644 --- a/internal/boundary/graphql/apitest/factories.go +++ b/internal/boundary/graphql/apitest/factories.go @@ -29,16 +29,3 @@ func NewAccount(opts ...func(*domain.Account)) domain.Account { } return acc } - -// NewWorkspace creates a test workspace with sensible defaults. -// Use functional options to override specific fields. -func NewWorkspace(opts ...func(*domain.Workspace)) domain.Workspace { - ws := domain.Workspace{ - ID: domain.NewWorkspaceID(), - Name: "Test Workspace", - } - for _, opt := range opts { - opt(&ws) - } - return ws -} diff --git a/internal/boundary/graphql/apitest/mock_services.go b/internal/boundary/graphql/apitest/mock_services.go index a7281517..9fdd85f9 100644 --- a/internal/boundary/graphql/apitest/mock_services.go +++ b/internal/boundary/graphql/apitest/mock_services.go @@ -7,7 +7,6 @@ import graphql "github.com/usetero/cli/internal/boundary/graphql" func NewMockServiceSet( organizations *MockOrganizations, accounts *MockAccounts, - workspaces *MockWorkspaces, datadogAccounts *MockDatadogAccounts, ) graphql.ServiceSet { services := graphql.ServiceSet{} @@ -18,9 +17,6 @@ func NewMockServiceSet( if accounts != nil { services.Accounts = accounts } - if workspaces != nil { - services.Workspaces = workspaces - } if datadogAccounts != nil { services.DatadogAccounts = datadogAccounts } diff --git a/internal/boundary/graphql/apitest/mock_workspaces.go b/internal/boundary/graphql/apitest/mock_workspaces.go deleted file mode 100644 index 24857dc9..00000000 --- a/internal/boundary/graphql/apitest/mock_workspaces.go +++ /dev/null @@ -1,28 +0,0 @@ -package apitest - -import ( - "context" - - "github.com/usetero/cli/internal/domain" -) - -// MockWorkspaces implements graphql.Workspaces for testing. -type MockWorkspaces struct { - ListFunc func(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) -} - -// NewMockWorkspaces creates a MockWorkspaces with sensible defaults. -func NewMockWorkspaces() *MockWorkspaces { - return &MockWorkspaces{ - ListFunc: func(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - return []domain.Workspace{}, nil - }, - } -} - -func (m *MockWorkspaces) List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - if m.ListFunc != nil { - return m.ListFunc(ctx, accountID) - } - return nil, nil -} diff --git a/internal/boundary/graphql/services.go b/internal/boundary/graphql/services.go index a329fd56..1dca5779 100644 --- a/internal/boundary/graphql/services.go +++ b/internal/boundary/graphql/services.go @@ -15,7 +15,6 @@ type ServiceSet struct { scope log.Scope Organizations Organizations Accounts Accounts - Workspaces Workspaces DatadogAccounts DatadogAccounts Services Services Policies Policies @@ -48,7 +47,6 @@ func newServiceSetWithScope(client Client, scope log.Scope) ServiceSet { scope: scope, Organizations: NewOrganizationService(client, scope), Accounts: NewAccountService(client, scope), - Workspaces: NewWorkspaceService(client, scope), DatadogAccounts: NewDatadogAccountService(client, scope), Services: NewServiceService(client, scope), Policies: NewPolicyService(client, scope), diff --git a/internal/boundary/graphql/workspace.go b/internal/boundary/graphql/workspace.go deleted file mode 100644 index b1f6bc2f..00000000 --- a/internal/boundary/graphql/workspace.go +++ /dev/null @@ -1,42 +0,0 @@ -package graphql - -import ( - "context" - - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -// Workspaces provides access to workspaces. -type Workspaces interface { - List(ctx context.Context, accountID domain.AccountID) ([]domain.Workspace, error) -} - -// WorkspaceService handles workspace-related API operations. -type WorkspaceService struct { - client Client - scope log.Scope -} - -// Ensure WorkspaceService implements Workspaces. -var _ Workspaces = (*WorkspaceService)(nil) - -// NewWorkspaceService creates a new workspace service. -func NewWorkspaceService(client Client, scope log.Scope) *WorkspaceService { - return &WorkspaceService{ - client: client, - scope: scope.Child("workspaces"), - } -} - -// List returns the workspaces for an account. -// -// TODO(drop-powersync): workspaces were removed from the control plane — the -// account is the working context now. As an interim, this returns a single -// synthetic workspace mirroring the account so the onboarding selection step is -// a no-op auto-select. The full workspace→account rename is task #7. -func (s *WorkspaceService) List(_ context.Context, accountID domain.AccountID) ([]domain.Workspace, error) { - return []domain.Workspace{ - {ID: domain.WorkspaceID(accountID.String()), Name: "Default"}, - }, nil -} diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go index b65e5a07..f35a8488 100644 --- a/internal/cmd/debug.go +++ b/internal/cmd/debug.go @@ -161,7 +161,6 @@ func newDebugPrefsCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Comma fmt.Println(s.Title.Render("Org Preferences")) fmt.Println(s.Help.Render("Path: " + orgPrefsPath)) printPref("Account ID", orgPrefs.GetDefaultAccountID().String()) - printPref("Workspace ID", orgPrefs.GetDefaultWorkspaceID().String()) } return nil diff --git a/internal/core/bootstrap/completion.go b/internal/core/bootstrap/completion.go index 3498fd16..cee49b2c 100644 --- a/internal/core/bootstrap/completion.go +++ b/internal/core/bootstrap/completion.go @@ -5,24 +5,23 @@ import ( "github.com/usetero/cli/internal/domain" ) -// Completion is the required onboarding payload for entering chat. +// Completion is the required onboarding payload for entering chat. The account +// is the working context; there is no workspace. type Completion struct { - User *auth.User - Org domain.Organization - Account domain.Account - Workspace domain.Workspace + User *auth.User + Org domain.Organization + Account domain.Account } // CompleteOnboarding validates bootstrap state and returns completion payload. func CompleteOnboarding(state State) (Completion, bool) { - if state.User == nil || state.Org == nil || state.Account == nil || state.Workspace == nil { + if state.User == nil || state.Org == nil || state.Account == nil { return Completion{}, false } return Completion{ - User: state.User, - Org: *state.Org, - Account: *state.Account, - Workspace: *state.Workspace, + User: state.User, + Org: *state.Org, + Account: *state.Account, }, true } diff --git a/internal/core/bootstrap/completion_test.go b/internal/core/bootstrap/completion_test.go index 806b8f53..49b6e85a 100644 --- a/internal/core/bootstrap/completion_test.go +++ b/internal/core/bootstrap/completion_test.go @@ -13,13 +13,11 @@ func TestCompleteOnboarding(t *testing.T) { user := &auth.User{ID: "user-1"} org := &domain.Organization{ID: "org-1", Name: "Org 1"} account := &domain.Account{ID: "acc-1", Name: "Account 1"} - workspace := &domain.Workspace{ID: "ws-1", Name: "Workspace 1"} got, ok := CompleteOnboarding(State{ - User: user, - Org: org, - Account: account, - Workspace: workspace, + User: user, + Org: org, + Account: account, }) if !ok { t.Fatal("expected completion payload") @@ -33,29 +31,24 @@ func TestCompleteOnboarding(t *testing.T) { if got.Account.ID != "acc-1" { t.Fatalf("account = %#v, want acc-1", got.Account) } - if got.Workspace.ID != "ws-1" { - t.Fatalf("workspace = %#v, want ws-1", got.Workspace) - } } func TestCompleteOnboardingMissingRequirements(t *testing.T) { t.Parallel() base := State{ - User: &auth.User{ID: "user-1"}, - Org: &domain.Organization{ID: "org-1"}, - Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, } cases := []struct { name string state State }{ - {name: "missing user", state: State{Org: base.Org, Account: base.Account, Workspace: base.Workspace}}, - {name: "missing org", state: State{User: base.User, Account: base.Account, Workspace: base.Workspace}}, - {name: "missing account", state: State{User: base.User, Org: base.Org, Workspace: base.Workspace}}, - {name: "missing workspace", state: State{User: base.User, Org: base.Org, Account: base.Account}}, + {name: "missing user", state: State{Org: base.Org, Account: base.Account}}, + {name: "missing org", state: State{User: base.User, Account: base.Account}}, + {name: "missing account", state: State{User: base.User, Org: base.Org}}, } for _, tt := range cases { diff --git a/internal/core/bootstrap/event_adapter.go b/internal/core/bootstrap/event_adapter.go index a474ab5e..1a5e2ded 100644 --- a/internal/core/bootstrap/event_adapter.go +++ b/internal/core/bootstrap/event_adapter.go @@ -38,8 +38,6 @@ func EventFromMessage(msg Message) (Event, bool) { return Event{Kind: EventDatadogAccountCreated, DatadogAccountID: msg.DatadogAccountID}, true case DatadogDiscoveryComplete: return Event{Kind: EventDatadogDiscoveryDone}, true - case WorkspaceSelected: - return Event{Kind: EventWorkspaceSelected, Workspace: msg.Workspace}, true default: return Event{}, false } diff --git a/internal/core/bootstrap/event_adapter_test.go b/internal/core/bootstrap/event_adapter_test.go index af651024..4bb34722 100644 --- a/internal/core/bootstrap/event_adapter_test.go +++ b/internal/core/bootstrap/event_adapter_test.go @@ -17,7 +17,7 @@ func TestEventFromMessage(t *testing.T) { {name: "authenticated", msg: Authenticated{}, kind: EventAuthenticated}, {name: "org selected", msg: OrgSelected{}, kind: EventOrgSelected}, {name: "runtime ready", msg: RuntimeReady{}, kind: EventRuntimeReady}, - {name: "workspace selected", msg: WorkspaceSelected{}, kind: EventWorkspaceSelected}, + {name: "datadog discovery complete", msg: DatadogDiscoveryComplete{}, kind: EventDatadogDiscoveryDone}, } for _, tt := range tests { diff --git a/internal/core/bootstrap/events.go b/internal/core/bootstrap/events.go index 9fcc264f..3e7a726b 100644 --- a/internal/core/bootstrap/events.go +++ b/internal/core/bootstrap/events.go @@ -25,7 +25,6 @@ const ( EventDatadogAPIKeyEntered EventKind = "datadog_apikey_entered" EventDatadogAccountCreated EventKind = "datadog_account_created" EventDatadogDiscoveryDone EventKind = "datadog_discovery_done" - EventWorkspaceSelected EventKind = "workspace_selected" ) // Event is the canonical transition input consumed by the bootstrap engine. @@ -39,7 +38,6 @@ type Event struct { Site domain.DatadogSite APIKey string DatadogAccountID domain.DatadogAccountID - Workspace domain.Workspace } // TransitionKind is the deterministic output shape for ApplyEvent. @@ -93,8 +91,8 @@ func ApplyEvent(state State, event Event) Transition { nextState, next := ApplyRuntimeReady(state, event.Org, event.Account) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} case EventDatadogReady: - nextState, next := ApplyDatadogReady(state) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} + // Datadog already configured: onboarding is complete (no workspace step). + return completeOrNoop(state) case EventDatadogNeeded: nextState, next := ApplyDatadogNeeded(state) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} @@ -108,19 +106,20 @@ func ApplyEvent(state State, event Event) Transition { nextState, next := ApplyDatadogAccountCreated(state, event.DatadogAccountID) return Transition{Kind: TransitionAdvance, State: nextState, Next: next} case EventDatadogDiscoveryDone: - nextState, next := ApplyDatadogDiscoveryComplete(state) - return Transition{Kind: TransitionAdvance, State: nextState, Next: next} - case EventWorkspaceSelected: - // Workspace selection is the final onboarding step. The control plane - // has no sync to wait for, so completing here drops the agent straight - // into chat. - nextState := ApplyWorkspaceSelected(state, event.Workspace) - completion, ok := CompleteOnboarding(nextState) - if !ok { - return Transition{Kind: TransitionNoop, State: nextState} - } - return Transition{Kind: TransitionComplete, State: nextState, Completion: completion} + // Datadog discovery finished: onboarding is complete. The account is the + // working context; there is no workspace step. + return completeOrNoop(state) default: return Transition{Kind: TransitionNoop, State: state} } } + +// completeOrNoop completes onboarding when the required state is present, +// otherwise no-ops. +func completeOrNoop(state State) Transition { + completion, ok := CompleteOnboarding(state) + if !ok { + return Transition{Kind: TransitionNoop, State: state} + } + return Transition{Kind: TransitionComplete, State: state, Completion: completion} +} diff --git a/internal/core/bootstrap/events_test.go b/internal/core/bootstrap/events_test.go index 4542c149..3a4f514a 100644 --- a/internal/core/bootstrap/events_test.go +++ b/internal/core/bootstrap/events_test.go @@ -25,31 +25,46 @@ func TestApplyEventAuthenticated(t *testing.T) { } } -func TestApplyEventWorkspaceSelectedCompletes(t *testing.T) { +func TestApplyEventDatadogDiscoveryDoneCompletes(t *testing.T) { t.Parallel() - // Workspace selection is the terminal onboarding step (no sync gate). + // Datadog discovery is the terminal onboarding step; the account is the + // working context (no workspace). state := State{ User: &auth.User{ID: "user-1"}, Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}, } - got := ApplyEvent(state, Event{Kind: EventWorkspaceSelected, Workspace: domain.Workspace{ID: "ws-1"}}) + got := ApplyEvent(state, Event{Kind: EventDatadogDiscoveryDone}) if got.Kind != TransitionComplete { t.Fatalf("kind = %q, want %q", got.Kind, TransitionComplete) } if got.Completion.User == nil || got.Completion.User.ID != "user-1" { t.Fatalf("completion user = %#v", got.Completion.User) } - if got.Completion.Org.ID != "org-1" || got.Completion.Account.ID != "acc-1" || got.Completion.Workspace.ID != "ws-1" { + if got.Completion.Org.ID != "org-1" || got.Completion.Account.ID != "acc-1" { t.Fatalf("unexpected completion payload: %#v", got.Completion) } } -func TestApplyEventWorkspaceSelectedMissingStateNoops(t *testing.T) { +func TestApplyEventDatadogReadyCompletes(t *testing.T) { t.Parallel() - got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventWorkspaceSelected, Workspace: domain.Workspace{ID: "ws-1"}}) + state := State{ + User: &auth.User{ID: "user-1"}, + Org: &domain.Organization{ID: "org-1"}, + Account: &domain.Account{ID: "acc-1"}, + } + got := ApplyEvent(state, Event{Kind: EventDatadogReady}) + if got.Kind != TransitionComplete { + t.Fatalf("kind = %q, want %q", got.Kind, TransitionComplete) + } +} + +func TestApplyEventDatadogDiscoveryDoneMissingStateNoops(t *testing.T) { + t.Parallel() + + got := ApplyEvent(State{User: &auth.User{ID: "user-1"}}, Event{Kind: EventDatadogDiscoveryDone}) if got.Kind != TransitionNoop { t.Fatalf("kind = %q, want %q", got.Kind, TransitionNoop) } diff --git a/internal/core/bootstrap/gate_requirements.go b/internal/core/bootstrap/gate_requirements.go index cdacf9a9..ab327886 100644 --- a/internal/core/bootstrap/gate_requirements.go +++ b/internal/core/bootstrap/gate_requirements.go @@ -5,7 +5,7 @@ func RequirementForGate(gate Gate) GateRequirement { switch gate { case GateAccountSelect, GateAccountCreate: return GateRequirement{NeedsOrg: true} - case GateRuntimeInit, GateDatadogCheck, GateWorkspaceSelect: + case GateRuntimeInit, GateDatadogCheck: return GateRequirement{NeedsOrg: true, NeedsAccount: true} case GateDatadogAPIKey: return GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true} diff --git a/internal/core/bootstrap/gate_requirements_test.go b/internal/core/bootstrap/gate_requirements_test.go index b44b2c40..bb3bd1fa 100644 --- a/internal/core/bootstrap/gate_requirements_test.go +++ b/internal/core/bootstrap/gate_requirements_test.go @@ -15,7 +15,6 @@ func TestRequirementForGate(t *testing.T) { {gate: GateDatadogAPIKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true}}, {gate: GateDatadogAppKey, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDSite: true, NeedsDDAPIKey: true}}, {gate: GateDatadogDiscovery, want: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true}}, - {gate: GateWorkspaceSelect, want: GateRequirement{NeedsOrg: true, NeedsAccount: true}}, } for _, tc := range cases { diff --git a/internal/core/bootstrap/gates.go b/internal/core/bootstrap/gates.go index be10efa3..bf69b017 100644 --- a/internal/core/bootstrap/gates.go +++ b/internal/core/bootstrap/gates.go @@ -17,7 +17,6 @@ const ( GateDatadogAPIKey Gate = "datadog_api_key" GateDatadogAppKey Gate = "datadog_app_key" GateDatadogDiscovery Gate = "datadog_discovery" - GateWorkspaceSelect Gate = "workspace_select" ) func (g Gate) String() string { return string(g) } diff --git a/internal/core/bootstrap/messages.go b/internal/core/bootstrap/messages.go index 940f6ed5..8718d914 100644 --- a/internal/core/bootstrap/messages.go +++ b/internal/core/bootstrap/messages.go @@ -70,29 +70,22 @@ type DatadogAccountCreated struct { type DatadogDiscoveryComplete struct{} -type WorkspaceSelected struct { - Workspace domain.Workspace -} - type OnboardingComplete struct { - User *auth.User - Org domain.Organization - Account domain.Account - Workspace domain.Workspace + User *auth.User + Org domain.Organization + Account domain.Account } type PreflightState struct { - Outcome PreflightOutcome - HasValidAuth bool - Role string - ActiveOrgID domain.OrganizationID - DefaultAccountID domain.AccountID - DefaultWorkspaceID domain.WorkspaceID - Org *domain.Organization - Account *domain.Account - Workspace *domain.Workspace - HasDatadog bool - Error string + Outcome PreflightOutcome + HasValidAuth bool + Role string + ActiveOrgID domain.OrganizationID + DefaultAccountID domain.AccountID + Org *domain.Organization + Account *domain.Account + HasDatadog bool + Error string } type PreflightResolved struct { @@ -115,5 +108,4 @@ func (DatadogAPIKeyEntered) bootstrapMessage() {} func (DatadogAccountCreated) bootstrapMessage() {} func (DatadogDiscoveryComplete) bootstrapMessage() { } -func (WorkspaceSelected) bootstrapMessage() {} func (PreflightResolved) bootstrapMessage() {} diff --git a/internal/core/bootstrap/requirements.go b/internal/core/bootstrap/requirements.go index 6989d098..4bc612a6 100644 --- a/internal/core/bootstrap/requirements.go +++ b/internal/core/bootstrap/requirements.go @@ -7,7 +7,6 @@ type GateRequirement struct { NeedsDDSite bool NeedsDDAPIKey bool NeedsDDAccount bool - NeedsWorkspace bool } // RewindGate returns the earliest gate that satisfies the missing requirement. @@ -28,8 +27,5 @@ func RewindGate(target Gate, req GateRequirement, state State) Gate { if req.NeedsDDAccount && state.DDAccount == "" { return GateDatadogCheck } - if req.NeedsWorkspace && state.Workspace == nil { - return GateWorkspaceSelect - } return target } diff --git a/internal/core/bootstrap/requirements_test.go b/internal/core/bootstrap/requirements_test.go index df242e38..fa4a16b5 100644 --- a/internal/core/bootstrap/requirements_test.go +++ b/internal/core/bootstrap/requirements_test.go @@ -38,11 +38,11 @@ func TestRewindGate(t *testing.T) { want: GateDatadogAPIKey, }, { - name: "workspace requirement rewinds to workspace when workspace missing", - target: GateWorkspaceSelect, - req: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsWorkspace: true}, + name: "datadog discovery rewinds to datadog check when dd account missing", + target: GateDatadogDiscovery, + req: GateRequirement{NeedsOrg: true, NeedsAccount: true, NeedsDDAccount: true}, state: State{Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}}, - want: GateWorkspaceSelect, + want: GateDatadogCheck, }, } diff --git a/internal/core/bootstrap/state.go b/internal/core/bootstrap/state.go index 99969a30..26d188e5 100644 --- a/internal/core/bootstrap/state.go +++ b/internal/core/bootstrap/state.go @@ -10,7 +10,6 @@ type State struct { User *auth.User Org *domain.Organization Account *domain.Account - Workspace *domain.Workspace DDSite domain.DatadogSite DDAPIKey string DDAccount domain.DatadogAccountID @@ -89,10 +88,6 @@ func ApplyRuntimeReady(state State, org domain.Organization, account domain.Acco return state, GateDatadogCheck } -func ApplyDatadogReady(state State) (State, Gate) { - return state, GateWorkspaceSelect -} - func ApplyDatadogNeeded(state State) (State, Gate) { return state, GateDatadogRegion } @@ -112,20 +107,8 @@ func ApplyDatadogAccountCreated(state State, datadogAccountID domain.DatadogAcco return state, GateDatadogDiscovery } -func ApplyDatadogDiscoveryComplete(state State) (State, Gate) { - return state, GateWorkspaceSelect -} - -// ApplyWorkspaceSelected records the selected workspace. It is the terminal -// onboarding step (there is no sync gate), so it returns only the next state. -func ApplyWorkspaceSelected(state State, workspace domain.Workspace) State { - state.Workspace = &workspace - return state -} - func clearAccountScopedState(state State) State { state.Account = nil - state.Workspace = nil state.DDSite = "" state.DDAPIKey = "" state.DDAccount = "" diff --git a/internal/core/bootstrap/transitions_test.go b/internal/core/bootstrap/transitions_test.go index 4d8675a7..ad024840 100644 --- a/internal/core/bootstrap/transitions_test.go +++ b/internal/core/bootstrap/transitions_test.go @@ -48,16 +48,6 @@ func TestApplyDatadogTransitions(t *testing.T) { } } -func TestApplyWorkspaceSelected(t *testing.T) { - t.Parallel() - - workspace := domain.Workspace{ID: "ws-1", Name: "Workspace 1"} - state := ApplyWorkspaceSelected(State{}, workspace) - if state.Workspace == nil || state.Workspace.ID != workspace.ID { - t.Fatalf("workspace not applied: %#v", state.Workspace) - } -} - func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { t.Parallel() @@ -65,7 +55,6 @@ func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { state, next := ApplyOrgSelected(State{ Org: &domain.Organization{ID: "org-1"}, Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, DDSite: "US1", DDAPIKey: "api-key", DDAccount: "dd-1", @@ -76,7 +65,7 @@ func TestApplyOrgSelectedClearsAccountScopedState(t *testing.T) { if state.Org == nil || state.Org.ID != org.ID { t.Fatalf("org not applied: %#v", state.Org) } - if state.Account != nil || state.Workspace != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { + if state.Account != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { t.Fatalf("account-scoped state not cleared: %#v", state) } } @@ -88,7 +77,6 @@ func TestApplyAccountSelectedClearsScopedStateBeforeSettingAccount(t *testing.T) account := domain.Account{ID: "acc-2", Name: "Account 2"} state, next := ApplyAccountSelected(State{ Account: &domain.Account{ID: "acc-1"}, - Workspace: &domain.Workspace{ID: "ws-1"}, DDSite: "US1", DDAPIKey: "api-key", DDAccount: "dd-1", @@ -99,7 +87,7 @@ func TestApplyAccountSelectedClearsScopedStateBeforeSettingAccount(t *testing.T) if state.Account == nil || state.Account.ID != account.ID { t.Fatalf("account not applied: %#v", state.Account) } - if state.Workspace != nil || state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { + if state.DDSite != "" || state.DDAPIKey != "" || state.DDAccount != "" { t.Fatalf("account-scoped state not reset: %#v", state) } } diff --git a/internal/domain/conversation.go b/internal/domain/conversation.go index e6e86136..a42c3da3 100644 --- a/internal/domain/conversation.go +++ b/internal/domain/conversation.go @@ -21,11 +21,10 @@ func (id ConversationID) String() string { // Conversation represents a chat conversation. type Conversation struct { - ID ConversationID `json:"id"` - WorkspaceID WorkspaceID `json:"workspace_id"` - Title string `json:"title"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID ConversationID `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ContextSource indicates who added an entity to context. diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go deleted file mode 100644 index 2c84cae8..00000000 --- a/internal/domain/workspace.go +++ /dev/null @@ -1,20 +0,0 @@ -package domain - -import "github.com/google/uuid" - -// WorkspaceID is a unique identifier for a workspace. -type WorkspaceID string - -// NewWorkspaceID generates a new unique WorkspaceID. -func NewWorkspaceID() WorkspaceID { return WorkspaceID(uuid.New().String()) } - -func (id WorkspaceID) String() string { return string(id) } - -// Workspace represents a workspace within an account. -type Workspace struct { - ID WorkspaceID `json:"id"` - Name string `json:"name"` -} - -// FilterValue returns the string used for filtering/searching. -func (w Workspace) FilterValue() string { return w.Name } diff --git a/internal/preferences/org.go b/internal/preferences/org.go index c0e49bf5..9140200b 100644 --- a/internal/preferences/org.go +++ b/internal/preferences/org.go @@ -7,8 +7,7 @@ import ( // Store keys for org preferences. const ( - keyDefaultAccountID = "default_account_id" - keyDefaultWorkspaceID = "default_workspace_id" + keyDefaultAccountID = "default_account_id" ) // OrgPreferences provides access to org-scoped preferences. @@ -16,10 +15,7 @@ const ( type OrgPreferences interface { GetDefaultAccountID() domain.AccountID SetDefaultAccountID(accountID domain.AccountID) error - GetDefaultWorkspaceID() domain.WorkspaceID - SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error ClearDefaultAccountID() error - ClearDefaultWorkspaceID() error Clear() error } @@ -48,24 +44,9 @@ func (s *OrgService) SetDefaultAccountID(accountID domain.AccountID) error { return s.store.Save() } -func (s *OrgService) GetDefaultWorkspaceID() domain.WorkspaceID { - return domain.WorkspaceID(s.store.Get(keyDefaultWorkspaceID)) -} - -func (s *OrgService) SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error { - s.store.Set(keyDefaultWorkspaceID, workspaceID.String()) - return s.store.Save() -} - -// ClearDefaultAccountID clears the default account and cascades to workspace. +// ClearDefaultAccountID clears the default account. func (s *OrgService) ClearDefaultAccountID() error { s.store.Set(keyDefaultAccountID, "") - s.store.Set(keyDefaultWorkspaceID, "") - return s.store.Save() -} - -func (s *OrgService) ClearDefaultWorkspaceID() error { - s.store.Set(keyDefaultWorkspaceID, "") return s.store.Save() } diff --git a/internal/preferences/preferencestest/mock_preferences.go b/internal/preferences/preferencestest/mock_preferences.go index 762f91c1..b1ef3bfb 100644 --- a/internal/preferences/preferencestest/mock_preferences.go +++ b/internal/preferences/preferencestest/mock_preferences.go @@ -71,13 +71,10 @@ func (m *MockUserPreferences) Clear() error { // MockOrgPreferences implements preferences.OrgPreferences for testing. type MockOrgPreferences struct { - GetDefaultAccountIDFunc func() domain.AccountID - SetDefaultAccountIDFunc func(accountID domain.AccountID) error - GetDefaultWorkspaceIDFunc func() domain.WorkspaceID - SetDefaultWorkspaceIDFunc func(workspaceID domain.WorkspaceID) error - ClearDefaultAccountIDFunc func() error - ClearDefaultWorkspaceIDFunc func() error - ClearFunc func() error + GetDefaultAccountIDFunc func() domain.AccountID + SetDefaultAccountIDFunc func(accountID domain.AccountID) error + ClearDefaultAccountIDFunc func() error + ClearFunc func() error } func NewMockOrgPreferences() *MockOrgPreferences { @@ -98,20 +95,6 @@ func (m *MockOrgPreferences) SetDefaultAccountID(accountID domain.AccountID) err return nil } -func (m *MockOrgPreferences) GetDefaultWorkspaceID() domain.WorkspaceID { - if m.GetDefaultWorkspaceIDFunc != nil { - return m.GetDefaultWorkspaceIDFunc() - } - return "" -} - -func (m *MockOrgPreferences) SetDefaultWorkspaceID(workspaceID domain.WorkspaceID) error { - if m.SetDefaultWorkspaceIDFunc != nil { - return m.SetDefaultWorkspaceIDFunc(workspaceID) - } - return nil -} - func (m *MockOrgPreferences) ClearDefaultAccountID() error { if m.ClearDefaultAccountIDFunc != nil { return m.ClearDefaultAccountIDFunc() @@ -119,13 +102,6 @@ func (m *MockOrgPreferences) ClearDefaultAccountID() error { return nil } -func (m *MockOrgPreferences) ClearDefaultWorkspaceID() error { - if m.ClearDefaultWorkspaceIDFunc != nil { - return m.ClearDefaultWorkspaceIDFunc() - } - return nil -} - func (m *MockOrgPreferences) Clear() error { if m.ClearFunc != nil { return m.ClearFunc() From 2e5a74871099e24232e9875c4ccc6d94a331bbf6 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 12:34:55 -0700 Subject: [PATCH 13/20] fix: resolve the user during preflight so onboarding completes on resume Onboarding hung after the Datadog check on any resumed session. The user identity was only set by the auth gate, which preflight skips when the token is already valid, so completion (which requires a user) silently no-op'd and the flow never reached chat. Capture the user id during preflight's auth check and thread it through PreflightState into bootstrap state, mirroring how the auth gate populates it on a fresh login. Verified live: onboarding now advances datadog_check -> complete -> chat. --- .../app/onboarding/preflight/preflight_effects.go | 12 +++++++++++- internal/app/onboarding/preflight/preflight_types.go | 2 ++ .../app/onboarding/preflight/preflight_update.go | 1 + internal/core/bootstrap/messages.go | 1 + internal/core/bootstrap/state.go | 3 +++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/app/onboarding/preflight/preflight_effects.go b/internal/app/onboarding/preflight/preflight_effects.go index 56a7810a..8cf2edc3 100644 --- a/internal/app/onboarding/preflight/preflight_effects.go +++ b/internal/app/onboarding/preflight/preflight_effects.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/usetero/cli/internal/auth" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" ) @@ -13,14 +14,23 @@ import ( func (m *Model) checkAuth() tea.Cmd { return func() tea.Msg { hasValidAuth := false + var user *auth.User if m.auth.IsAuthenticated() { if _, err := m.auth.GetAccessToken(m.ctx); err == nil { hasValidAuth = true + // Capture the user identity here: when auth is already valid the + // onboarding auth gate is skipped, so this is the only place the + // resumed-session user is resolved for the completion payload. + if userID, idErr := m.auth.GetUserID(m.ctx); idErr == nil && userID != "" { + user = &auth.User{ID: userID} + } else if idErr != nil { + m.scope.Warn("preflight could not resolve user id", "error", idErr) + } } else { _ = m.auth.ClearTokens() } } - return preflightAuthCheckCompletedMsg{hasValidAuth: hasValidAuth} + return preflightAuthCheckCompletedMsg{hasValidAuth: hasValidAuth, user: user} } } diff --git a/internal/app/onboarding/preflight/preflight_types.go b/internal/app/onboarding/preflight/preflight_types.go index eef1e875..e0c57e35 100644 --- a/internal/app/onboarding/preflight/preflight_types.go +++ b/internal/app/onboarding/preflight/preflight_types.go @@ -1,6 +1,7 @@ package preflight import ( + "github.com/usetero/cli/internal/auth" "github.com/usetero/cli/internal/core/bootstrap" "github.com/usetero/cli/internal/domain" ) @@ -11,6 +12,7 @@ type preflightResolutionCompletedMsg struct { type preflightAuthCheckCompletedMsg struct { hasValidAuth bool + user *auth.User } type preflightOrganizationsLoadedMsg struct { diff --git a/internal/app/onboarding/preflight/preflight_update.go b/internal/app/onboarding/preflight/preflight_update.go index bc837d94..4a4a70ab 100644 --- a/internal/app/onboarding/preflight/preflight_update.go +++ b/internal/app/onboarding/preflight/preflight_update.go @@ -29,6 +29,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { func (m *Model) handleAuthChecked(msg preflightAuthCheckCompletedMsg) tea.Cmd { m.state.HasValidAuth = msg.hasValidAuth + m.state.User = msg.user if !m.state.HasValidAuth { return m.emitResult() } diff --git a/internal/core/bootstrap/messages.go b/internal/core/bootstrap/messages.go index 8718d914..a349c8fe 100644 --- a/internal/core/bootstrap/messages.go +++ b/internal/core/bootstrap/messages.go @@ -82,6 +82,7 @@ type PreflightState struct { Role string ActiveOrgID domain.OrganizationID DefaultAccountID domain.AccountID + User *auth.User Org *domain.Organization Account *domain.Account HasDatadog bool diff --git a/internal/core/bootstrap/state.go b/internal/core/bootstrap/state.go index 26d188e5..31706895 100644 --- a/internal/core/bootstrap/state.go +++ b/internal/core/bootstrap/state.go @@ -25,6 +25,9 @@ func ApplyPreflight(state State, resolved PreflightState) (State, Gate) { HasAccount: resolved.Account != nil, }) + if resolved.User != nil { + state.User = resolved.User + } if resolved.Org != nil { state.Org = resolved.Org } From 00e2bddd3925cabafd4b55c435feaad56c6fda77 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 12:46:22 -0700 Subject: [PATCH 14/20] feat: add CLI commands to view account surfaces directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add traditional, scriptable commands that read the control plane over GraphQL for the current account, so product data is reachable without the chat TUI: - tero status — account health, service/event counts, cost, open issues - tero issues — active issues (priority, id, service, title) - tero checks — product checks with findings and cost - tero services — enabled services with volume and cost - tero edge — registered edge instances Drop the cost field from the issues read: the deployed control-plane Issue type does not expose it (schema-mirror drift). Verified all five commands against live prd. --- internal/boundary/graphql/gen/generated.go | 25 +- .../graphql/gen/queries/issues.graphql | 5 +- internal/boundary/graphql/issue_service.go | 9 +- internal/cmd/root.go | 7 + internal/cmd/surfaces.go | 228 ++++++++++++++++++ 5 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 internal/cmd/surfaces.go diff --git a/internal/boundary/graphql/gen/generated.go b/internal/boundary/graphql/gen/generated.go index dc8b570f..857a020c 100644 --- a/internal/boundary/graphql/gen/generated.go +++ b/internal/boundary/graphql/gen/generated.go @@ -1328,9 +1328,8 @@ type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue struct { // How much attention a kept finding deserves. Values: low = Legitimate finding, // but low urgency or prominence.; medium = Legitimate finding with clear but not // top-tier urgency.; high = Legitimate finding that deserves strong user attention. - Priority IssuePriority `json:"priority"` - Service *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService `json:"service"` - Cost ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals `json:"cost"` + Priority IssuePriority `json:"priority"` + Service *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService `json:"service"` } // GetId returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Id, and is useful for accessing the field via an interface. @@ -1354,21 +1353,6 @@ func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetService() *L return v.Service } -// GetCost returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue.Cost, and is useful for accessing the field via an interface. -func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssue) GetCost() ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals { - return v.Cost -} - -// ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals includes the requested fields of the GraphQL type StatusMeasurementTotals. -type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals struct { - TotalUsdPerHour *float64 `json:"totalUsdPerHour"` -} - -// GetTotalUsdPerHour returns ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals.TotalUsdPerHour, and is useful for accessing the field via an interface. -func (v *ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueCostStatusMeasurementTotals) GetTotalUsdPerHour() *float64 { - return v.TotalUsdPerHour -} - // ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService includes the requested fields of the GraphQL type Service. type ListIssuesIssuesIssueConnectionEdgesIssueEdgeNodeIssueService struct { // Service identifier in telemetry (e.g., 'checkout-service') @@ -2600,16 +2584,13 @@ query ListIssues ($first: Int!) { service { name } - cost { - totalUsdPerHour - } } } } } ` -// Individual active issues with detail, for the chat agent's read tool. +// Individual active issues with detail, for the issues command and read tool. func ListIssues( ctx_ context.Context, client_ graphql.Client, diff --git a/internal/boundary/graphql/gen/queries/issues.graphql b/internal/boundary/graphql/gen/queries/issues.graphql index cf44603e..227146b0 100644 --- a/internal/boundary/graphql/gen/queries/issues.graphql +++ b/internal/boundary/graphql/gen/queries/issues.graphql @@ -18,7 +18,7 @@ query GetIssueSummary { } } -# Individual active issues with detail, for the chat agent's read tool. +# Individual active issues with detail, for the issues command and read tool. query ListIssues($first: Int!) { issues( first: $first @@ -35,9 +35,6 @@ query ListIssues($first: Int!) { service { name } - cost { - totalUsdPerHour - } } } } diff --git a/internal/boundary/graphql/issue_service.go b/internal/boundary/graphql/issue_service.go index 27059e43..1449a88f 100644 --- a/internal/boundary/graphql/issue_service.go +++ b/internal/boundary/graphql/issue_service.go @@ -76,11 +76,10 @@ func (s *IssueService) List(ctx context.Context) ([]domain.Issue, error) { for _, edge := range resp.Issues.Edges { node := edge.Node issue := domain.Issue{ - ID: node.Id, - DisplayID: node.DisplayID, - Title: node.Title, - Priority: domain.IssuePriority(node.Priority), - CostPerHour: node.Cost.TotalUsdPerHour, + ID: node.Id, + DisplayID: node.DisplayID, + Title: node.Title, + Priority: domain.IssuePriority(node.Priority), } if node.Service != nil { issue.ServiceName = node.Service.Name diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bda1ffc8..853e3a4d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -25,6 +25,13 @@ func NewRootCmd(scope log.Scope, version string) *cobra.Command { rootCmd.AddCommand(NewResetCmd(scope, cliConfig)) rootCmd.AddCommand(NewInternalCmd(scope, cliConfig)) + // Product surfaces — direct GraphQL reads for the current account. + rootCmd.AddCommand(NewStatusCmd(scope, cliConfig)) + rootCmd.AddCommand(NewIssuesCmd(scope, cliConfig)) + rootCmd.AddCommand(NewChecksCmd(scope, cliConfig)) + rootCmd.AddCommand(NewServicesCmd(scope, cliConfig)) + rootCmd.AddCommand(NewEdgeCmd(scope, cliConfig)) + return rootCmd } diff --git a/internal/cmd/surfaces.go b/internal/cmd/surfaces.go new file mode 100644 index 00000000..393e0085 --- /dev/null +++ b/internal/cmd/surfaces.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/config" + "github.com/usetero/cli/internal/format" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/preferences" + "github.com/usetero/cli/internal/styles" +) + +// accountServices resolves an authenticated, account-scoped service set from +// the active org's default account. Returns a helpful error when the user is +// not authenticated or has not finished onboarding. +func accountServices(ctx context.Context, cliConfig *config.CLIConfig, scope log.Scope) (graphql.ServiceSet, error) { + services, err := newAuthenticatedGraphQLServiceSet(ctx, cliConfig, scope) + if err != nil { + return graphql.ServiceSet{}, err + } + + env := cliConfig.Environment() + orgID := config.ActiveOrgID(env) + if orgID == "" { + return graphql.ServiceSet{}, fmt.Errorf("no organization selected — run 'tero' to complete onboarding") + } + orgCfg, err := config.LoadOrgPreferences(env, orgID) + if err != nil { + return graphql.ServiceSet{}, fmt.Errorf("load org preferences: %w", err) + } + accountID := preferences.NewOrgService(orgCfg, scope).GetDefaultAccountID() + if accountID == "" { + return graphql.ServiceSet{}, fmt.Errorf("no account configured — run 'tero' to complete onboarding") + } + return services.WithAccountID(accountID), nil +} + +// newTabWriter returns a tabwriter writing to stdout with padded columns. +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) +} + +func cost(p *float64) string { + if p == nil { + return "—" + } + return format.YearlyCost(*p) +} + +func rate(p *float64) string { + if p == nil { + return "—" + } + return format.Volume(*p) + "/hr" +} + +// NewIssuesCmd lists the account's active issues. +func NewIssuesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("issues") + return &cobra.Command{ + Use: "issues", + Short: "List active issues for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + issues, err := services.Issues.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list issues: %w", err) + } + s := styles.DetectTheme().Styles + if len(issues) == 0 { + fmt.Println(s.Help.Render("No active issues.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "PRIORITY\tID\tSERVICE\tCOST/YR\tTITLE") + for _, i := range issues { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + i.Priority, dashIfEmpty(i.DisplayID), dashIfEmpty(i.ServiceName), cost(i.CostPerHour), i.Title) + } + return w.Flush() + }, + } +} + +// NewServicesCmd lists enabled services and their status. +func NewServicesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("services") + return &cobra.Command{ + Use: "services", + Short: "List enabled services for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + statuses, err := services.Status.ListServiceStatuses(cmd.Context()) + if err != nil { + return fmt.Errorf("list services: %w", err) + } + s := styles.DetectTheme().Styles + if len(statuses) == 0 { + fmt.Println(s.Help.Render("No enabled services.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tHEALTH\tLOG EVENTS\tVOLUME\tCOST/YR") + for _, svc := range statuses { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", + svc.Name, svc.Health, svc.LogEventCount, rate(svc.ServiceVolumePerHour), cost(svc.ServiceCostPerHourVolumeUSD)) + } + return w.Flush() + }, + } +} + +// NewChecksCmd lists product checks and their posture. +func NewChecksCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("checks") + return &cobra.Command{ + Use: "checks", + Short: "List product checks and their posture for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + catalog, err := services.Checks.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list checks: %w", err) + } + s := styles.DetectTheme().Styles + if len(catalog.Checks) == 0 { + fmt.Println(s.Help.Render("No checks.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "CHECK\tDOMAIN\tOPEN FINDINGS\tACTIVE ISSUES\tCOST/YR") + for _, c := range catalog.Checks { + fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", + c.Name, c.Domain, c.OpenFindingCount, c.ActiveIssueCount, cost(c.CurrentCostPerHour)) + } + return w.Flush() + }, + } +} + +// NewStatusCmd prints the account-level status summary. +func NewStatusCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("status") + return &cobra.Command{ + Use: "status", + Short: "Show the current account's overall status", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + summary, err := services.Status.GetAccountSummary(cmd.Context()) + if err != nil { + return fmt.Errorf("account status: %w", err) + } + issues, err := services.Issues.GetSummary(cmd.Context()) + if err != nil { + return fmt.Errorf("issue summary: %w", err) + } + theme := styles.DetectTheme() + s := theme.Styles + fmt.Println(s.Title.Render("Account Status")) + w := newTabWriter() + fmt.Fprintf(w, "Health\t%s\n", summary.Health) + fmt.Fprintf(w, "Ready for use\t%t\n", summary.ReadyForUse) + fmt.Fprintf(w, "Services\t%d active / %d total\n", summary.ActiveServices, summary.ServiceCount) + fmt.Fprintf(w, "Log events\t%d (%d analyzed)\n", summary.EventCount, summary.AnalyzedCount) + fmt.Fprintf(w, "Volume\t%s\n", rate(summary.TotalVolumePerHour)) + fmt.Fprintf(w, "Cost\t%s\n", cost(summary.TotalCostPerHour)) + fmt.Fprintf(w, "Open issues\t%d (%d high, %d medium, %d low)\n", + issues.Open, issues.HighCount, issues.MediumCount, issues.LowCount) + return w.Flush() + }, + } +} + +// NewEdgeCmd lists the account's edge instances. +func NewEdgeCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { + scope = scope.Child("edge") + return &cobra.Command{ + Use: "edge", + Short: "List edge instances for the current account", + RunE: func(cmd *cobra.Command, _ []string) error { + services, err := accountServices(cmd.Context(), cliConfig, scope) + if err != nil { + return err + } + fleet, err := services.EdgeInstances.List(cmd.Context()) + if err != nil { + return fmt.Errorf("list edge instances: %w", err) + } + s := styles.DetectTheme().Styles + if len(fleet.Instances) == 0 { + fmt.Println(s.Help.Render("No edge instances registered.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tNAMESPACE\tINSTANCE\tLAST SYNC") + for _, inst := range fleet.Instances { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + inst.ServiceName, dashIfEmpty(inst.ServiceNamespace), inst.InstanceID, inst.LastSyncAt.Format("2006-01-02 15:04")) + } + return w.Flush() + }, + } +} + +func dashIfEmpty(s string) string { + if s == "" { + return "—" + } + return s +} From 74844ea873f4ef570d973bba77e23934d80a4ee0 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 12:56:53 -0700 Subject: [PATCH 15/20] feat: replace chat with a minimal issue explorer; remove chat entirely The chat backend is decommissioned, so the chat-first TUI is dead. After onboarding the CLI now opens a minimal, read-only issue explorer that lists the account's active issues (priority, id, service, title) with arrow navigation and refresh, backed by the GraphQL issue reads. Remove the chat subsystem wholesale: internal/app/chat, internal/app/chattools, internal/boundary/chat, internal/core/chat, the chat client/tool-registry/runtime-deps wiring in the app runtime, the ChatEndpoint config and its WorkOS token audience. The status drawer's 'ask Tero' prompt hook is now inert. Verified live: onboarding completes into the explorer and loads issues against prd. --- cmd/admin/main.go | 2 +- internal/app/app.go | 27 +- internal/app/app_layout_view.go | 20 +- internal/app/app_onboarding_controls.go | 6 +- internal/app/chat/AGENTS.md | 25 - internal/app/chat/chat.go | 1 - internal/app/chat/chat_test.go | 600 ------------- internal/app/chat/empty_state_poll_test.go | 71 -- internal/app/chat/events/input.go | 9 - internal/app/chat/events/stream.go | 48 - internal/app/chat/events/tools.go | 24 - internal/app/chat/input_flow.go | 106 --- internal/app/chat/inputbar/inputbar.go | 262 ------ internal/app/chat/inputbar/inputbar_test.go | 84 -- internal/app/chat/layout.go | 110 --- .../app/chat/messagelist/behavior_test.go | 342 ------- internal/app/chat/messagelist/block/block.go | 60 -- internal/app/chat/messagelist/interaction.go | 41 - .../app/chat/messagelist/interaction_test.go | 65 -- internal/app/chat/messagelist/messagelist.go | 164 ---- .../messagelisttest/messagelist.go | 27 - internal/app/chat/messagelist/mouse.go | 137 --- internal/app/chat/messagelist/mouse_target.go | 27 - .../app/chat/messagelist/mouse_target_test.go | 45 - internal/app/chat/messagelist/mouse_test.go | 183 ---- internal/app/chat/messagelist/projection.go | 81 -- .../app/chat/messagelist/projection_test.go | 63 -- internal/app/chat/messagelist/reducer.go | 38 - internal/app/chat/messagelist/reducer_test.go | 46 - internal/app/chat/messagelist/render.go | 228 ----- internal/app/chat/messagelist/round/round.go | 132 --- .../chat/messagelist/round/round_effects.go | 132 --- .../app/chat/messagelist/round/round_model.go | 97 -- .../chat/messagelist/round/round_reducer.go | 38 - .../messagelist/round/round_reducer_test.go | 35 - .../app/chat/messagelist/round/round_test.go | 430 --------- .../chat/messagelist/round/round_update.go | 85 -- .../round/turn/assistant/assistant.go | 167 ---- .../round/turn/assistant/assistant_test.go | 99 --- .../round/turn/assistant/blocks/block.go | 12 - .../round/turn/assistant/blocks/text.go | 123 --- .../round/turn/assistant/blocks/thinking.go | 152 ---- .../turn/assistant/blocks/thinking_test.go | 85 -- .../turn/assistant/blocks/thinkinganim.go | 55 -- .../assistant/blocks/tools/action/action.go | 165 ---- .../blocks/tools/action/action_test.go | 262 ------ .../round/turn/assistant/blocks/tools/tool.go | 336 ------- .../turn/assistant/blocks/tools/tool_test.go | 236 ----- .../chat/messagelist/round/turn/reducer.go | 28 - .../round/turn/tool_results_tracker.go | 66 -- .../app/chat/messagelist/round/turn/turn.go | 124 --- .../messagelist/round/turn/turn_effects.go | 67 -- .../chat/messagelist/round/turn/turn_model.go | 59 -- .../messagelist/round/turn/turn_stream.go | 184 ---- .../chat/messagelist/round/turn/turn_test.go | 457 ---------- .../messagelist/round/turn/turn_update.go | 94 -- .../chat/messagelist/round/turn/user/user.go | 97 -- .../messagelist/round/turn/user/user_test.go | 85 -- internal/app/chat/messagelist/rounds.go | 124 --- .../app/chat/messagelist/selection_reducer.go | 90 -- .../messagelist/selection_reducer_test.go | 142 --- internal/app/chat/messagelist/update.go | 40 - internal/app/chat/messagelist/update_key.go | 16 - .../app/chat/messagelist/update_lifecycle.go | 34 - internal/app/chat/messagelist/update_mouse.go | 89 -- internal/app/chat/messagelist/update_test.go | 167 ---- internal/app/chat/model.go | 123 --- internal/app/chat/update.go | 107 --- internal/app/chat/update_handlers.go | 114 --- .../app/chat/usecase/assistant_persistence.go | 30 - .../usecase/assistant_persistence_test.go | 51 -- internal/app/chat/usecase/chat_gateway.go | 12 - .../app/chat/usecase/chat_gateway_boundary.go | 29 - .../usecase/chat_gateway_boundary_test.go | 72 -- internal/app/chat/usecase/orphan_cleanup.go | 25 - .../app/chat/usecase/orphan_cleanup_test.go | 28 - internal/app/chat/usecase/runtime_deps.go | 41 - internal/app/chat/usecase/stream_errors.go | 20 - .../chat/usecase/stream_errors_boundary.go | 17 - .../usecase/stream_errors_boundary_test.go | 18 - .../app/chat/usecase/stream_errors_test.go | 36 - internal/app/chat/usecase/stream_request.go | 10 - internal/app/chat/usecase/stream_runner.go | 75 -- .../app/chat/usecase/stream_runner_test.go | 128 --- internal/app/chat/usecase/tool_loop.go | 62 -- internal/app/chat/usecase/tool_loop_test.go | 87 -- internal/app/chat/view.go | 132 --- internal/app/chattools/reads.go | 214 ----- internal/app/chattools/registry.go | 73 -- internal/app/chattools/runtime.go | 12 - internal/app/chattools/setserviceenabled.go | 102 --- internal/app/commands.go | 11 +- internal/app/explorer/explorer.go | 231 +++++ internal/app/onboarding_orchestration.go | 30 +- internal/app/perf_logging.go | 2 +- internal/app/runtime_sync.go | 33 +- internal/app/update_routing.go | 25 +- internal/architecture/dependencies_test.go | 15 - internal/boundary/chat/AGENTS.md | 23 - .../boundary/chat/chattest/mock_client.go | 63 -- internal/boundary/chat/client.go | 383 -------- internal/boundary/chat/client_test.go | 832 ------------------ internal/boundary/chat/protocol_mapping.go | 207 ----- .../boundary/chat/protocol_mapping_test.go | 268 ------ internal/boundary/chat/protocolv2/request.go | 325 ------- .../boundary/chat/protocolv2/request_test.go | 307 ------- internal/boundary/chat/request.go | 12 - internal/boundary/chat/stream.go | 45 - internal/boundary/chat/stream_errors.go | 93 -- internal/boundary/chat/stream_errors_test.go | 49 -- internal/boundary/chat/stream_test.go | 215 ----- internal/boundary/chat/tool.go | 53 -- internal/boundary/chat/tool_validation.go | 32 - .../boundary/chat/tool_validation_test.go | 37 - internal/cmd/dependencies.go | 1 - internal/config/cli.go | 11 +- internal/config/cli_test.go | 9 - internal/core/chat/accumulator.go | 207 ----- internal/core/chat/accumulator_test.go | 138 --- internal/core/chat/fuzz_test.go | 71 -- internal/core/chat/protocol.go | 98 --- internal/core/chat/reducer.go | 136 --- internal/core/chat/reducer_properties_test.go | 67 -- internal/core/chat/reducer_test.go | 129 --- internal/core/chat/session.go | 160 ---- internal/core/chat/session_test.go | 135 --- internal/core/chat/stream_decode.go | 82 -- internal/core/chat/stream_machine.go | 68 -- internal/core/chat/stream_result.go | 9 - internal/core/chat/test_helpers_test.go | 5 - 130 files changed, 267 insertions(+), 13514 deletions(-) delete mode 100644 internal/app/chat/AGENTS.md delete mode 100644 internal/app/chat/chat.go delete mode 100644 internal/app/chat/chat_test.go delete mode 100644 internal/app/chat/empty_state_poll_test.go delete mode 100644 internal/app/chat/events/input.go delete mode 100644 internal/app/chat/events/stream.go delete mode 100644 internal/app/chat/events/tools.go delete mode 100644 internal/app/chat/input_flow.go delete mode 100644 internal/app/chat/inputbar/inputbar.go delete mode 100644 internal/app/chat/inputbar/inputbar_test.go delete mode 100644 internal/app/chat/layout.go delete mode 100644 internal/app/chat/messagelist/behavior_test.go delete mode 100644 internal/app/chat/messagelist/block/block.go delete mode 100644 internal/app/chat/messagelist/interaction.go delete mode 100644 internal/app/chat/messagelist/interaction_test.go delete mode 100644 internal/app/chat/messagelist/messagelist.go delete mode 100644 internal/app/chat/messagelist/messagelisttest/messagelist.go delete mode 100644 internal/app/chat/messagelist/mouse.go delete mode 100644 internal/app/chat/messagelist/mouse_target.go delete mode 100644 internal/app/chat/messagelist/mouse_target_test.go delete mode 100644 internal/app/chat/messagelist/mouse_test.go delete mode 100644 internal/app/chat/messagelist/projection.go delete mode 100644 internal/app/chat/messagelist/projection_test.go delete mode 100644 internal/app/chat/messagelist/reducer.go delete mode 100644 internal/app/chat/messagelist/reducer_test.go delete mode 100644 internal/app/chat/messagelist/render.go delete mode 100644 internal/app/chat/messagelist/round/round.go delete mode 100644 internal/app/chat/messagelist/round/round_effects.go delete mode 100644 internal/app/chat/messagelist/round/round_model.go delete mode 100644 internal/app/chat/messagelist/round/round_reducer.go delete mode 100644 internal/app/chat/messagelist/round/round_reducer_test.go delete mode 100644 internal/app/chat/messagelist/round/round_test.go delete mode 100644 internal/app/chat/messagelist/round/round_update.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/assistant.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/assistant_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/block.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/text.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/reducer.go delete mode 100644 internal/app/chat/messagelist/round/turn/tool_results_tracker.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn_effects.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn_model.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn_stream.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn_test.go delete mode 100644 internal/app/chat/messagelist/round/turn/turn_update.go delete mode 100644 internal/app/chat/messagelist/round/turn/user/user.go delete mode 100644 internal/app/chat/messagelist/round/turn/user/user_test.go delete mode 100644 internal/app/chat/messagelist/rounds.go delete mode 100644 internal/app/chat/messagelist/selection_reducer.go delete mode 100644 internal/app/chat/messagelist/selection_reducer_test.go delete mode 100644 internal/app/chat/messagelist/update.go delete mode 100644 internal/app/chat/messagelist/update_key.go delete mode 100644 internal/app/chat/messagelist/update_lifecycle.go delete mode 100644 internal/app/chat/messagelist/update_mouse.go delete mode 100644 internal/app/chat/messagelist/update_test.go delete mode 100644 internal/app/chat/model.go delete mode 100644 internal/app/chat/update.go delete mode 100644 internal/app/chat/update_handlers.go delete mode 100644 internal/app/chat/usecase/assistant_persistence.go delete mode 100644 internal/app/chat/usecase/assistant_persistence_test.go delete mode 100644 internal/app/chat/usecase/chat_gateway.go delete mode 100644 internal/app/chat/usecase/chat_gateway_boundary.go delete mode 100644 internal/app/chat/usecase/chat_gateway_boundary_test.go delete mode 100644 internal/app/chat/usecase/orphan_cleanup.go delete mode 100644 internal/app/chat/usecase/orphan_cleanup_test.go delete mode 100644 internal/app/chat/usecase/runtime_deps.go delete mode 100644 internal/app/chat/usecase/stream_errors.go delete mode 100644 internal/app/chat/usecase/stream_errors_boundary.go delete mode 100644 internal/app/chat/usecase/stream_errors_boundary_test.go delete mode 100644 internal/app/chat/usecase/stream_errors_test.go delete mode 100644 internal/app/chat/usecase/stream_request.go delete mode 100644 internal/app/chat/usecase/stream_runner.go delete mode 100644 internal/app/chat/usecase/stream_runner_test.go delete mode 100644 internal/app/chat/usecase/tool_loop.go delete mode 100644 internal/app/chat/usecase/tool_loop_test.go delete mode 100644 internal/app/chat/view.go delete mode 100644 internal/app/chattools/reads.go delete mode 100644 internal/app/chattools/registry.go delete mode 100644 internal/app/chattools/runtime.go delete mode 100644 internal/app/chattools/setserviceenabled.go create mode 100644 internal/app/explorer/explorer.go delete mode 100644 internal/boundary/chat/AGENTS.md delete mode 100644 internal/boundary/chat/chattest/mock_client.go delete mode 100644 internal/boundary/chat/client.go delete mode 100644 internal/boundary/chat/client_test.go delete mode 100644 internal/boundary/chat/protocol_mapping.go delete mode 100644 internal/boundary/chat/protocol_mapping_test.go delete mode 100644 internal/boundary/chat/protocolv2/request.go delete mode 100644 internal/boundary/chat/protocolv2/request_test.go delete mode 100644 internal/boundary/chat/request.go delete mode 100644 internal/boundary/chat/stream.go delete mode 100644 internal/boundary/chat/stream_errors.go delete mode 100644 internal/boundary/chat/stream_errors_test.go delete mode 100644 internal/boundary/chat/stream_test.go delete mode 100644 internal/boundary/chat/tool.go delete mode 100644 internal/boundary/chat/tool_validation.go delete mode 100644 internal/boundary/chat/tool_validation_test.go delete mode 100644 internal/core/chat/accumulator.go delete mode 100644 internal/core/chat/accumulator_test.go delete mode 100644 internal/core/chat/fuzz_test.go delete mode 100644 internal/core/chat/protocol.go delete mode 100644 internal/core/chat/reducer.go delete mode 100644 internal/core/chat/reducer_properties_test.go delete mode 100644 internal/core/chat/reducer_test.go delete mode 100644 internal/core/chat/session.go delete mode 100644 internal/core/chat/session_test.go delete mode 100644 internal/core/chat/stream_decode.go delete mode 100644 internal/core/chat/stream_machine.go delete mode 100644 internal/core/chat/stream_result.go delete mode 100644 internal/core/chat/test_helpers_test.go diff --git a/cmd/admin/main.go b/cmd/admin/main.go index 28077224..0f5cdb76 100644 --- a/cmd/admin/main.go +++ b/cmd/admin/main.go @@ -97,7 +97,7 @@ func setup(cmd *cobra.Command) (*workosadmin.Client, string, error) { cliConfig := config.LoadCLIConfig() tokenStore := keyring.New(cliConfig.Environment()) - workosClient := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint, cliConfig.ChatEndpoint) + workosClient := workos.NewClient(cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint) authService := auth.NewService(workosClient, tokenStore, scope) userID, err := authService.GetUserID(cmd.Context()) diff --git a/internal/app/app.go b/internal/app/app.go index ce77a17a..34a343b6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,17 +8,14 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" appevents "github.com/usetero/cli/internal/app/events" + "github.com/usetero/cli/internal/app/explorer" "github.com/usetero/cli/internal/app/keybar" "github.com/usetero/cli/internal/app/onboarding" "github.com/usetero/cli/internal/app/palette" "github.com/usetero/cli/internal/app/statusbar" "github.com/usetero/cli/internal/app/toast" "github.com/usetero/cli/internal/auth" - chatboundary "github.com/usetero/cli/internal/boundary/chat" graphql "github.com/usetero/cli/internal/boundary/graphql" "github.com/usetero/cli/internal/config" "github.com/usetero/cli/internal/domain" @@ -33,7 +30,7 @@ type state int const ( stateOnboarding state = iota - stateChat + stateExplorer ) // Layout constants. @@ -64,9 +61,6 @@ type Model struct { // Runtime (created after account selection / onboarding) sessionCancel context.CancelFunc sessionCtx context.Context - chatClient chatboundary.Client - runtimeDeps usecase.RuntimeDeps - toolRegistry *chattools.Registry user *auth.User account domain.Account @@ -75,7 +69,7 @@ type Model struct { toast *toast.Model keyBar *keybar.Model onboarding *onboarding.Model - chat *chat.Model + explorer *explorer.Model quitDlg *quitDialog palette *palette.Model state state @@ -183,21 +177,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, handled := m.handleOnboardingMessage(msg); handled { return m, cmd } - m.handleStreamCompleted(msg) return m, m.updateChildren(msg) } -// newChat creates a fresh chat model with current dependencies. -func (m *Model) newChat() *chat.Model { - return chat.New( - m.user, - m.account, - m.theme, - m.services, - m.runtimeDeps, - m.toolRegistry, - m.scope, - ) +// newExplorer creates a fresh issue explorer scoped to the active account. +func (m *Model) newExplorer() *explorer.Model { + return explorer.New(m.services, m.theme, m.scope) } // openPalette creates and opens the command palette. diff --git a/internal/app/app_layout_view.go b/internal/app/app_layout_view.go index 94f36b36..539296d2 100644 --- a/internal/app/app_layout_view.go +++ b/internal/app/app_layout_view.go @@ -36,11 +36,11 @@ func (m *Model) updateLayout() { if m.onboarding != nil { m.onboarding.SetSize(contentWidth, pageHeight) } - case stateChat: - if m.chat != nil { - m.chat.SetSize(contentWidth, pageHeight) + case stateExplorer: + if m.explorer != nil { + m.explorer.SetSize(contentWidth, pageHeight) // Chat page origin: toast + statusbar + gap (no top padding) - m.chat.SetOrigin(horizontalPadding, toastHeight+statusBarHeight+gapAfterStatusBar) + m.explorer.SetOrigin(horizontalPadding, toastHeight+statusBarHeight+gapAfterStatusBar) } } } @@ -67,9 +67,9 @@ func (m *Model) updateKeyBar() { if m.onboarding != nil { bindings = m.onboarding.ShortHelp() } - case stateChat: - if m.chat != nil { - bindings = m.chat.ShortHelp() + case stateExplorer: + if m.explorer != nil { + bindings = m.explorer.ShortHelp() } } } @@ -138,9 +138,9 @@ func (m *Model) currentPageView() string { if m.onboarding != nil { return m.onboarding.View() } - case stateChat: - if m.chat != nil { - return m.chat.View() + case stateExplorer: + if m.explorer != nil { + return m.explorer.View() } } return "" diff --git a/internal/app/app_onboarding_controls.go b/internal/app/app_onboarding_controls.go index f9ede360..c88f2279 100644 --- a/internal/app/app_onboarding_controls.go +++ b/internal/app/app_onboarding_controls.go @@ -3,7 +3,6 @@ package app import ( tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/app/onboarding" "github.com/usetero/cli/internal/app/statusbar" @@ -74,10 +73,7 @@ func (m *Model) switchAccount() tea.Cmd { func (m *Model) restartOnboarding() tea.Cmd { m.shutdown() - m.chatClient = nil - m.runtimeDeps = usecase.RuntimeDeps{} - m.toolRegistry = nil - m.chat = nil + m.explorer = nil m.services = m.services.WithAccountID("") // clear stale account scope m.statusBar = statusbar.New(m.theme, m.scope, m.cfg.APIEndpoint, m.cfg.Env) diff --git a/internal/app/chat/AGENTS.md b/internal/app/chat/AGENTS.md deleted file mode 100644 index 10f8fa89..00000000 --- a/internal/app/chat/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# App Chat - -Bubble Tea orchestration for chat rounds, turns, and message list behavior. - -## Rules - -1. Scope stream/tool events by turn ID before applying updates. -2. Keep reducers pure and handlers side-effectful. -3. Message list projection math must be shared between viewport and rendering. -4. User-cancel semantics must stay non-error and non-persisted for committed assistant output. - -## Structure Expectations - -1. `messagelist/update.go` should remain a router. -2. Input-family handlers live in dedicated files (`update_key`, `update_mouse`, `update_lifecycle`). -3. Transition logic belongs in reducer files. - -## Testing Requirements - -For behavior changes in this tree: - -1. Add/adjust reducer unit tests. -2. Add/adjust behavior tests in `messagelist/behavior_test.go` for user-visible outcomes. -3. Run `go test ./internal/chat ./internal/app/chat/... -count=1`. - diff --git a/internal/app/chat/chat.go b/internal/app/chat/chat.go deleted file mode 100644 index 5c2cd9a8..00000000 --- a/internal/app/chat/chat.go +++ /dev/null @@ -1 +0,0 @@ -package chat diff --git a/internal/app/chat/chat_test.go b/internal/app/chat/chat_test.go deleted file mode 100644 index 16c8621c..00000000 --- a/internal/app/chat/chat_test.go +++ /dev/null @@ -1,600 +0,0 @@ -package chat - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/boundary/graphql/apitest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/teatest" -) - -// newTestChat creates an ephemeral chat model with a mock streaming client. -func newTestChat(t *testing.T, client chat.Client) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - services := graphql.NewServiceSetFromClient(apitest.NewMockClient(), scope) - runtimeDeps := usecase.NewRuntimeDeps(client) - - m := New(nil, domain.Account{ID: "acct-1"}, theme, services, runtimeDeps, nil, scope) - m.SetSize(80, 40) - return m -} - -// blockingClient returns a mock client whose stream calls onMessage once then -// blocks until cancelled. Suitable for testing cancel mid-stream. -func blockingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(ctx context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - onMessage(&domain.Message{ - ID: "asst-1", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - }) - <-ctx.Done() - return nil, ctx.Err() - }, - } -} - -// completingClient returns a mock client that immediately completes with a -// text response. -func completingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - onMessage(&domain.Message{ - ID: "asst-1", - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - }) - return &corechat.StreamResult{}, nil - }, - } -} - -// failingClient returns a mock client that returns an error immediately. -func failingClient() *chattest.MockClient { - return &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, _ func(*domain.Message)) (*corechat.StreamResult, error) { - return nil, errors.New("connection failed") - }, - } -} - -func abortedClient(reason string) *chattest.MockClient { - return &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, _ chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - msg := &domain.Message{ - ID: "asst-1", - Model: "test-model", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "partial"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusAborted, - AbortReason: reason, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } -} - -func recordingCompletingClient(requests *[]chat.Request) *chattest.MockClient { - var mu sync.Mutex - call := 0 - - return &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - *requests = append(*requests, req) - call++ - asstID := domain.MessageID(fmt.Sprintf("asst-%d", call)) - mu.Unlock() - - msg := &domain.Message{ - ID: asstID, - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}}, - } - - if onSnapshot != nil { - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusStreaming, - Message: msg, - }) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: "conv-1", - TurnID: "turn-1", - Seq: 2, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - } - - return &corechat.StreamResult{Message: msg}, nil - }, - } -} - -// submitAndDrain sends a UserSubmittedInput and drains the cmd loop. -func submitAndDrain(m *Model, text string, maxSteps int) { - cmd := m.Update(msgs.UserSubmittedInput{Text: text}) - teatest.DrainCmds(m.Update, cmd, maxSteps) -} - -func submitToolResultsAndDrain(m *Model, results []domaintools.Result, maxSteps int) { - cmd := m.Update(msgs.UserSubmittedInput{ToolResults: results}) - teatest.DrainCmds(m.Update, cmd, maxSteps) -} - -// listMessages returns the conversation history from the in-memory session. -// Chat is ephemeral: the session is the source of truth, not a database. -func listMessages(t *testing.T, m *Model) []domain.Message { - t.Helper() - if m.session == nil { - return nil - } - return m.session.Messages() -} - -func TestCancelActiveRound(t *testing.T) { - t.Parallel() - - t.Run("cleans up orphaned user message from DB", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, blockingClient()) - - // Submit triggers conversation creation + user message persistence. - submitAndDrain(m, "hello", 20) - - // User message should be in the DB. - messages := listMessages(t, m) - if len(messages) == 0 { - t.Fatal("expected user message in DB after submit") - } - - // Cancel the active round (simulates ESC). - cancelled, cmd := m.CancelActiveRound() - if !cancelled { - t.Fatal("expected active round to be cancelled") - } - teatest.DrainCmds(m.Update, cmd, 20) - - // The orphaned user message should be cleaned up. - messages = listMessages(t, m) - if len(messages) != 0 { - t.Errorf("expected 0 messages after cancel, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - }) - - t.Run("next submit after cancel produces valid history", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, completingClient()) - - // First submit + cancel. - submitAndDrain(m, "first", 20) - cancelled, cmd := m.CancelActiveRound() - if cancelled { - teatest.DrainCmds(m.Update, cmd, 20) - } - - // Second submit — should complete normally. - submitAndDrain(m, "second", 50) - - messages := listMessages(t, m) - if err := validateAlternation(messages); err != nil { - t.Errorf("invalid message history after cancel + resubmit: %v (roles: %v)", err, messageRoles(messages)) - } - }) -} - -func TestRequestHistoryUsesInMemorySession(t *testing.T) { - t.Parallel() - - var requests []chat.Request - m := newTestChat(t, recordingCompletingClient(&requests)) - - submitAndDrain(m, "first", 50) - - // The in-memory session is the sole source of request history; the second - // turn must carry the prior user+assistant exchange forward. - submitAndDrain(m, "second", 70) - - if len(requests) < 2 { - t.Fatalf("requests = %d, want at least 2", len(requests)) - } - second := requests[1].Messages - if len(second) < 3 { - t.Fatalf("second request message count = %d, want >= 3", len(second)) - } - if second[0].Role != domain.RoleUser { - t.Fatalf("second[0].role = %q, want user", second[0].Role) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant (session history)", second[1].Role) - } - if second[len(second)-1].Role != domain.RoleUser { - t.Fatalf("last role = %q, want user", second[len(second)-1].Role) - } -} - -func TestToolResultFollowupRequestContainsAssistantAndToolResult(t *testing.T) { - t.Parallel() - - var requests []chat.Request - m := newTestChat(t, recordingCompletingClient(&requests)) - - // Turn 1: user prompt -> assistant response. - submitAndDrain(m, "run a query", 50) - - // Turn 2: user tool_result follow-up should include prior assistant + tool_result. - submitToolResultsAndDrain(m, []domaintools.Result{{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{ - {"service_id": "svc-1", "weekly_volume": 12345}, - }, - }, - }}, 60) - - if len(requests) < 2 { - t.Fatalf("requests = %d, want >= 2", len(requests)) - } - second := requests[1].Messages - if len(second) != 3 { - t.Fatalf("second request message count = %d, want 3", len(second)) - } - if second[0].Role != domain.RoleUser { - t.Fatalf("second[0].role = %q, want user", second[0].Role) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant", second[1].Role) - } - if second[2].Role != domain.RoleUser { - t.Fatalf("second[2].role = %q, want user(tool_result)", second[2].Role) - } - if len(second[2].Content) != 1 || second[2].Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("second[2] content = %#v, want single tool_result block", second[2].Content) - } - if got := second[2].Content[0].ToolResult.ToolUseID; got != "toolu_1" { - t.Fatalf("tool_use_id = %q, want %q", got, "toolu_1") - } -} - -func TestToolResultFollowupKeepsAssistantWhenStreamMessageIDMissing(t *testing.T) { - t.Parallel() - - var requests []chat.Request - var mu sync.Mutex - call := 0 - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - requests = append(requests, req) - call++ - n := call - mu.Unlock() - - if n == 1 { - msg := &domain.Message{ - // Intentionally empty ID: mirrors stream payloads that do not carry message IDs. - ID: "", - Model: "test-model", - StopReason: "tool_use", - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - InputComplete: true, - }, - }}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusToolUse, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - } - - msg := &domain.Message{ - ID: "asst-2", - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "done"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-2", - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } - - m := newTestChat(t, client) - submitAndDrain(m, "run a query", 60) - submitToolResultsAndDrain(m, []domaintools.Result{{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "svc-1"}}, - }, - }}, 60) - - mu.Lock() - defer mu.Unlock() - if len(requests) < 2 { - t.Fatalf("requests = %d, want >= 2", len(requests)) - } - second := requests[1].Messages - if len(second) != 3 { - t.Fatalf("second request message count = %d, want 3", len(second)) - } - if second[1].Role != domain.RoleAssistant { - t.Fatalf("second[1].role = %q, want assistant", second[1].Role) - } - if len(second[1].Content) != 1 || second[1].Content[0].Type != domain.BlockTypeToolUse { - t.Fatalf("second[1].content = %#v, want single tool_use block", second[1].Content) - } - if got := second[1].Content[0].ToolUse.ID; got != "toolu_1" { - t.Fatalf("assistant tool_use.id = %q, want toolu_1", got) - } -} - -func TestInternalToolLoopKeepsTopLevelSessionAligned(t *testing.T) { - t.Parallel() - - var requests []chat.Request - var mu sync.Mutex - call := 0 - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - mu.Lock() - requests = append(requests, req) - call++ - n := call - mu.Unlock() - - if n == 1 { - msg := &domain.Message{ - ID: "asst-1", - Model: "test-model", - StopReason: "tool_use", - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - InputComplete: true, - }, - }}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusToolUse, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - } - - msg := &domain.Message{ - ID: domain.MessageID(fmt.Sprintf("asst-%d", n)), - Model: "test-model", - StopReason: "end_turn", - Content: []domain.Block{{Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "done"}}}, - } - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: fmt.Sprintf("turn-%d", n), - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: msg, - }) - return &corechat.StreamResult{Message: msg}, nil - }, - } - - m := newTestChat(t, client) - submitAndDrain(m, "run a query", 80) - - stored := listMessages(t, m) - if len(stored) == 0 { - t.Fatal("expected first user message") - } - firstTurnID := stored[0].ID - - cmd := m.Update(msgs.ToolCompleted{ - TurnID: firstTurnID, - ToolUseID: "toolu_1", - Result: domaintools.Result{ - ToolUseID: "toolu_1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "svc-1"}}, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd, 120) - - submitAndDrain(m, "what are my top disabled services?", 120) - - mu.Lock() - defer mu.Unlock() - if len(requests) < 3 { - t.Fatalf("requests = %d, want >= 3", len(requests)) - } - third := requests[2].Messages - if len(third) < 5 { - t.Fatalf("third request message count = %d, want >= 5", len(third)) - } - if third[1].Role != domain.RoleAssistant || third[1].StopReason != "tool_use" { - t.Fatalf("third[1] = role=%q stop_reason=%q, want assistant tool_use", third[1].Role, third[1].StopReason) - } - if third[2].Role != domain.RoleUser || len(third[2].Content) == 0 || third[2].Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("third[2] = %#v, want user tool_result message", third[2]) - } - if got := third[2].Content[0].ToolResult.ToolUseID; got != "toolu_1" { - t.Fatalf("third[2] tool_use_id = %q, want toolu_1", got) - } -} - -func TestStreamFailed(t *testing.T) { - t.Parallel() - - t.Run("cleans up orphaned user message from DB", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, failingClient()) - - submitAndDrain(m, "hello", 20) - - messages := listMessages(t, m) - if len(messages) != 0 { - t.Errorf("expected 0 messages after stream failure, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - }) - - t.Run("maps protocol errors to user-friendly toast", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, completingClient()) - cmd := m.Update(msgs.StreamFailed{TurnID: "turn-1", Err: errors.New("protocol error: unknown event type")}) - errMsg, ok := extractErrorToast(cmd) - if !ok { - t.Fatal("expected error toast command") - } - if errMsg.Message != "The chat service returned an unexpected stream format. Please retry." { - t.Fatalf("toast message = %q", errMsg.Message) - } - }) -} - -func TestStreamAborted(t *testing.T) { - t.Parallel() - - t.Run("non-user abort persists partial assistant message", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, abortedClient("context_canceled")) - - submitAndDrain(m, "hello", 40) - - messages := listMessages(t, m) - if len(messages) != 2 { - t.Fatalf("expected 2 messages, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - if messages[0].Role != domain.RoleUser { - t.Fatalf("message 0 role = %s, want user", messages[0].Role) - } - if messages[1].Role != domain.RoleAssistant { - t.Fatalf("message 1 role = %s, want assistant", messages[1].Role) - } - if messages[1].StopReason != "aborted" { - t.Fatalf("assistant stop_reason = %q, want %q", messages[1].StopReason, "aborted") - } - }) - - t.Run("user_cancelled abort does not persist assistant message", func(t *testing.T) { - t.Parallel() - m := newTestChat(t, abortedClient("user_cancelled")) - - submitAndDrain(m, "hello", 40) - - messages := listMessages(t, m) - if len(messages) != 1 { - t.Fatalf("expected 1 message, got %d (roles: %v)", len(messages), messageRoles(messages)) - } - if messages[0].Role != domain.RoleUser { - t.Fatalf("message role = %s, want user", messages[0].Role) - } - }) -} - -// validateAlternation checks that messages strictly alternate roles, -// starting with user. Returns an error describing the violation if any. -func validateAlternation(messages []domain.Message) error { - if len(messages) == 0 { - return nil - } - if messages[0].Role != domain.RoleUser { - return errors.New("first message must be user") - } - for i := 1; i < len(messages); i++ { - if messages[i].Role == messages[i-1].Role { - return errors.New("consecutive messages with same role: " + string(messages[i].Role)) - } - } - return nil -} - -// messageRoles returns a slice of role strings for debugging. -func messageRoles(messages []domain.Message) []string { - roles := make([]string, len(messages)) - for i, m := range messages { - roles[i] = string(m.Role) - } - return roles -} - -func extractErrorToast(cmd tea.Cmd) (appevents.ErrorToastPublished, bool) { - if cmd == nil { - return appevents.ErrorToastPublished{}, false - } - msg := cmd() - if msg == nil { - return appevents.ErrorToastPublished{}, false - } - if e, ok := msg.(appevents.ErrorToastPublished); ok { - return e, true - } - batch, ok := msg.(tea.BatchMsg) - if !ok { - return appevents.ErrorToastPublished{}, false - } - for _, sub := range batch { - if sub == nil { - continue - } - subMsg := sub() - if e, ok := subMsg.(appevents.ErrorToastPublished); ok { - return e, true - } - } - return appevents.ErrorToastPublished{}, false -} diff --git a/internal/app/chat/empty_state_poll_test.go b/internal/app/chat/empty_state_poll_test.go deleted file mode 100644 index 099392d9..00000000 --- a/internal/app/chat/empty_state_poll_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package chat - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/app/chat/usecase" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -// stubStatus implements graphql.Status for the empty-state tests. -type stubStatus struct { - summary domain.AccountSummary -} - -func (s stubStatus) GetAccountSummary(context.Context) (domain.AccountSummary, error) { - return s.summary, nil -} - -func (s stubStatus) ListServiceStatuses(context.Context) ([]domain.ServiceStatus, error) { - return nil, nil -} - -func (s stubStatus) ListServiceLogEvents(context.Context, string) ([]domain.LogEventStatus, error) { - return nil, nil -} - -func newEmptyStateChat(t *testing.T, summary domain.AccountSummary) *Model { - t.Helper() - return New( - nil, - domain.Account{ID: "acct-1"}, - styles.NewTheme(true), - graphql.ServiceSet{Status: stubStatus{summary: summary}}, - usecase.RuntimeDeps{EffectContext: context.Background()}, - nil, - logtest.NewScope(t), - ) -} - -func TestEmptyStatePollDoesNotMutateSynchronously(t *testing.T) { - m := newEmptyStateChat(t, domain.AccountSummary{ServiceCount: 7}) - - cmd := m.Update(emptyStatePollTickMsg{}) - if cmd == nil { - t.Fatalf("expected poll update to return command") - } - if m.policySummary != nil { - t.Fatalf("expected summary to remain unset until async result message") - } -} - -func TestEmptyStateSummaryMessageUpdatesState(t *testing.T) { - m := newEmptyStateChat(t, domain.AccountSummary{ServiceCount: 5, ActiveServices: 3}) - - msg := m.fetchEmptyStateSummary()() - if _, ok := msg.(emptyStateSummaryLoadedMsg); !ok { - t.Fatalf("expected emptyStateSummaryLoadedMsg, got %T", msg) - } - - m.Update(msg) - if m.policySummary == nil { - t.Fatalf("expected summary after handling async summary message") - } - if m.policySummary.ServiceCount != 5 { - t.Fatalf("unexpected service count: got %d want %d", m.policySummary.ServiceCount, 5) - } -} diff --git a/internal/app/chat/events/input.go b/internal/app/chat/events/input.go deleted file mode 100644 index 0da6dd6d..00000000 --- a/internal/app/chat/events/input.go +++ /dev/null @@ -1,9 +0,0 @@ -package events - -import domaintools "github.com/usetero/cli/internal/domain/tools" - -// UserSubmittedInput is fired when user input is ready (text from input bar or tool results). -type UserSubmittedInput struct { - Text string - ToolResults []domaintools.Result -} diff --git a/internal/app/chat/events/stream.go b/internal/app/chat/events/stream.go deleted file mode 100644 index c5cc0a6e..00000000 --- a/internal/app/chat/events/stream.go +++ /dev/null @@ -1,48 +0,0 @@ -package events - -import ( - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// TurnStarted is fired when a new turn begins within a round. -type TurnStarted struct { - UserMessageID domain.MessageID - ConversationID domain.ConversationID -} - -// AssistantContentUpdated is fired as the assistant message streams in. -type AssistantContentUpdated struct { - TurnID domain.MessageID // user message ID that started this turn - Message domain.Message -} - -// StreamCompleted is fired when the assistant stream finishes. -type StreamCompleted struct { - TurnID domain.MessageID // user message ID that started this turn - Message domain.Message - Title string // AI-generated conversation title, if provided - ContextWindow int // model's max token capacity - InputTokens int // tokens consumed by input this turn - OutputTokens int // tokens generated by output this turn -} - -// StreamFailed is fired when the assistant stream encounters an error. -type StreamFailed struct { - TurnID domain.MessageID // user message ID that started this turn - Err error -} - -// ToolResultsReady is fired by a turn when all tool results are collected. -// This is internal to round - it triggers the next turn in the tool loop. -type ToolResultsReady struct { - TurnID domain.MessageID // identifies which turn completed - Results []domaintools.Result // collected tool results -} - -// ToolResultMessagePersisted is fired when the round persists an internal -// user tool_result message during a tool loop. The top-level chat session uses -// this to keep in-memory request history aligned with round-local state. -type ToolResultMessagePersisted struct { - Message domain.Message -} diff --git a/internal/app/chat/events/tools.go b/internal/app/chat/events/tools.go deleted file mode 100644 index 57d3c153..00000000 --- a/internal/app/chat/events/tools.go +++ /dev/null @@ -1,24 +0,0 @@ -package events - -import ( - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// ToolCompleted is fired when any tool finishes executing. -type ToolCompleted struct { - TurnID domain.MessageID - ToolUseID string - Result domaintools.Result - Error error -} - -// ResultOrError returns the tool result, wrapping Error when present. -func (m ToolCompleted) ResultOrError() domaintools.Result { - r := m.Result - r.ToolUseID = m.ToolUseID - if m.Error != nil { - r.Error = &domaintools.ErrorResult{Message: m.Error.Error()} - } - return r -} diff --git a/internal/app/chat/input_flow.go b/internal/app/chat/input_flow.go deleted file mode 100644 index c91e0b82..00000000 --- a/internal/app/chat/input_flow.go +++ /dev/null @@ -1,106 +0,0 @@ -package chat - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// handleUserInput creates conversation if needed, then persists the user message. -func (m *Model) handleUserInput(input msgs.UserSubmittedInput) tea.Cmd { - if len(input.Text) > 0 { - m.scope.Info("user submitted text", "text_length", len(input.Text)) - } else { - m.scope.Info("user submitted tool results", "count", len(input.ToolResults)) - } - - // Cancel any in-flight round before starting a new one. - _, cancelCmd := m.CancelActiveRound() - - // If no conversation yet, create one first (only for text input). - if m.conversationID == "" { - return tea.Batch(cancelCmd, m.createConversation(input)) - } - - return tea.Batch(cancelCmd, m.persistUserMessage(input)) -} - -// createConversation starts a new ephemeral conversation. Chat is not -// persisted, so the conversation ID is minted locally for this session only. -func (m *Model) createConversation(input msgs.UserSubmittedInput) tea.Cmd { - return func() tea.Msg { - return conversationCreated{ - conversationID: domain.NewConversationID(), - input: input, - } - } -} - -// conversationCreated is fired after conversation is created. -type conversationCreated struct { - conversationID domain.ConversationID - input msgs.UserSubmittedInput -} - -// persistUserMessage appends the user message to the in-memory session. Chat -// is ephemeral, so the message ID is minted locally and nothing is stored. -func (m *Model) persistUserMessage(input msgs.UserSubmittedInput) tea.Cmd { - return func() tea.Msg { - var domainResults []domain.ToolResult - if len(input.ToolResults) > 0 { - // Convert typed results to domain format at the boundary. - domainResults = make([]domain.ToolResult, len(input.ToolResults)) - for i, r := range input.ToolResults { - domainResults[i] = domain.ToolResult{ - ToolUseID: r.ToolUseID, - IsError: r.IsError(), - Content: r.ToMap(), - } - if r.Error != nil { - domainResults[i].Error = r.Error.Message - } - } - } - - msgID := domain.NewMessageID() - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, nil) - } - if len(domainResults) > 0 { - m.session.AppendUserToolResultsMessage(msgID, domainResults) - } else { - m.session.AppendUserTextMessage(msgID, input.Text) - } - messages := m.session.Messages() - - return userMessagePersisted{ - conversationID: m.conversationID, - messageID: msgID, - input: input, - messages: messages, - } - } -} - -// userMessagePersisted is fired after the user message is appended to the session. -type userMessagePersisted struct { - conversationID domain.ConversationID - messageID domain.MessageID - input msgs.UserSubmittedInput - messages []domain.Message -} - -// handlePersistedMessage starts the turn after the user message is persisted. -func (m *Model) handlePersistedMessage(msg userMessagePersisted) tea.Cmd { - m.scope.Info("turn started", "conversation_id", msg.conversationID, "user_message_id", msg.messageID) - - return m.messageList.StartTurn( - msg.conversationID, - m.account.ID, - msg.messageID, - msg.input, - msg.messages, - nil, - ) -} diff --git a/internal/app/chat/inputbar/inputbar.go b/internal/app/chat/inputbar/inputbar.go deleted file mode 100644 index e468b9fa..00000000 --- a/internal/app/chat/inputbar/inputbar.go +++ /dev/null @@ -1,262 +0,0 @@ -package inputbar - -import ( - "fmt" - "math/rand/v2" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/auth" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/cursor" - "github.com/usetero/cli/internal/tea/keymap" -) - -const ( - textareaHeight = 3 // visible input lines - - // Layout: border(1) + innerPadL(2) + text + innerPadR(2) - borderWidth = 1 - innerPadX = 2 - innerPadY = 1 - chrome = borderWidth + innerPadX*2 - - inputBarHeight = textareaHeight + innerPadY*2 // textarea + top/bottom inner padding -) - -// Model handles user input via a textarea. -type Model struct { - theme styles.Theme - textarea textarea.Model - width int - scope log.Scope - activeTurn domain.MessageID - pendingText string // saved input text, restored on stream failure - placeholder string // rendered outside textarea to avoid bg issues -} - -// placeholder returns a random placeholder for the session. -func placeholder(user *auth.User) string { - name := "" - if user != nil && user.FirstName != "" { - name = user.FirstName - } - - pool := []string{ - "What should we get into?", - "What's on your mind?", - } - if name != "" { - pool = append(pool, - "Ready when you are, "+name, - "Let's get to work, "+name, - ) - } - return pool[rand.IntN(len(pool))] -} - -// New creates a new input bar. -func New(user *auth.User, theme styles.Theme, scope log.Scope) *Model { - scope = scope.Child("inputbar") - - // Input bar uses elevated background to match user message blocks. - elevated := theme.WithBg(theme.BgElevated) - - ta := textarea.New() - ta.ShowLineNumbers = false - ta.SetHeight(textareaHeight) - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - - base := lipgloss.NewStyle().Foreground(elevated.Text).Background(elevated.Bg) - ta.SetStyles(textarea.Styles{ - Focused: textarea.StyleState{ - Base: base, - Text: base, - Prompt: base, - }, - Blurred: textarea.StyleState{ - Base: base.Foreground(elevated.TextMuted), - Text: base.Foreground(elevated.TextMuted), - Prompt: base.Foreground(elevated.TextMuted), - }, - Cursor: textarea.CursorStyle{ - Color: elevated.Accent, - Shape: tea.CursorBar, - Blink: true, - }, - }) - - ta.SetPromptFunc(0, func(_ textarea.PromptInfo) string { - return "" - }) - - return &Model{ - theme: elevated, - textarea: ta, - scope: scope, - placeholder: placeholder(user), - } -} - -// Init initializes the input bar. -func (m *Model) Init() tea.Cmd { - return tea.Batch( - m.textarea.Focus(), - textarea.Blink, - ) -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case tea.KeyPressMsg: - // DEBUG: log all keys - m.scope.Debug("key received", "key", msg.String()) - - // "/" on empty input opens the command palette - if key.Matches(msg, keymap.Palette) && m.textarea.Value() == "" { - return func() tea.Msg { return appevents.PaletteOpenRequested{} } - } - - // Newline - consume, don't forward to textarea - if key.Matches(msg, keymap.Newline) { - m.textarea.InsertRune('\n') - return nil - } - // Enter to submit - consume, don't forward to textarea - if key.Matches(msg, keymap.Send) { - text := strings.TrimSpace(m.textarea.Value()) - if text != "" { - m.pendingText = text - m.textarea.Reset() - return func() tea.Msg { return msgs.UserSubmittedInput{Text: text} } - } - return nil - } - - case msgs.StreamFailed: - if msg.TurnID != m.activeTurn { - return nil - } - if m.pendingText != "" { - m.textarea.SetValue(m.pendingText) - m.pendingText = "" - } - return nil - - case msgs.TurnStarted: - m.activeTurn = msg.UserMessageID - return nil - - case msgs.StreamCompleted: - if msg.TurnID != m.activeTurn { - return nil - } - m.pendingText = "" - return nil - } - - // Forward to textarea - var cmd tea.Cmd - m.textarea, cmd = m.textarea.Update(msg) - return cmd -} - -// View renders the input bar styled like a user message block: -// grey background, green left border, matching padding. -func (m *Model) View() string { - if m.width == 0 { - return "" - } - - var view string - if m.textarea.Value() == "" { - // Render placeholder ourselves so background is correct. - view = lipgloss.NewStyle(). - Foreground(m.theme.TextMuted). - Background(m.theme.Bg). - Render(m.placeholder) - } else { - view = m.textarea.View() - - // The textarea emits SGR resets (\033[0m) that kill our background. - // Re-establish the theme background after every reset. - r, g, b, _ := m.theme.Bg.RGBA() - bgSeq := fmt.Sprintf("\033[48;2;%d;%d;%dm", r>>8, g>>8, b>>8) - view = strings.ReplaceAll(view, "\033[0m", "\033[0m"+bgSeq) - } - - // Insert cursor marker - cur := m.textarea.Cursor() - if cur != nil { - view = cursor.Insert(view, cur.X, cur.Y) - } - - // Pad textarea output to exactly textareaHeight lines - lines := strings.Split(view, "\n") - for len(lines) < textareaHeight { - lines = append(lines, "") - } - view = strings.Join(lines[:textareaHeight], "\n") - - // Inner box: elevated bg, padding, matches user message block styling - contentWidth := m.width - borderWidth - inner := lipgloss.NewStyle(). - Background(m.theme.Bg). - Foreground(m.theme.Text). - Padding(innerPadY, innerPadX). - Width(contentWidth). - Render(view) - - // Border: accent left border, matches renderBlock for user messages - bordered := lipgloss.NewStyle(). - Width(contentWidth + borderWidth). - BorderLeft(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(m.theme.Accent). - Render(inner) - - return bordered -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // Textarea gets the width inside all chrome layers - m.textarea.SetWidth(width - chrome) -} - -// Height returns the height of the input bar. -func (m *Model) Height() int { - return inputBarHeight -} - -// Focus returns a command to focus the textarea. -func (m *Model) Focus() tea.Cmd { - return m.textarea.Focus() -} - -// Blur removes focus from the textarea. -func (m *Model) Blur() { - m.textarea.Blur() -} - -// Focused returns whether the textarea is focused. -func (m *Model) Focused() bool { - return m.textarea.Focused() -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - return []key.Binding{keymap.Send, keymap.Newline, keymap.Palette} -} diff --git a/internal/app/chat/inputbar/inputbar_test.go b/internal/app/chat/inputbar/inputbar_test.go deleted file mode 100644 index 73a9adf4..00000000 --- a/internal/app/chat/inputbar/inputbar_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package inputbar - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - appevents "github.com/usetero/cli/internal/app/events" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestInputBar(t *testing.T) *Model { - t.Helper() - m := New(nil, styles.NewTheme(true), logtest.NewScope(t)) - m.SetWidth(80) - return m -} - -func TestUpdate_Submit(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.textarea.SetValue(" hello ") - - cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatal("expected submit cmd") - } - msg := cmd() - input, ok := msg.(msgs.UserSubmittedInput) - if !ok { - t.Fatalf("expected UserSubmittedInput, got %T", msg) - } - if input.Text != "hello" { - t.Fatalf("submitted text = %q, want hello", input.Text) - } - if m.textarea.Value() != "" { - t.Fatalf("textarea should reset, got %q", m.textarea.Value()) - } -} - -func TestUpdate_Newline(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.textarea.SetValue("a") - - cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}) - if cmd != nil { - t.Fatal("newline should not emit cmd") - } - if !strings.Contains(m.textarea.Value(), "\n") { - t.Fatalf("expected newline in textarea, got %q", m.textarea.Value()) - } -} - -func TestUpdate_Palette(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - - cmd := m.Update(tea.KeyPressMsg{Text: "/"}) - if cmd == nil { - t.Fatal("expected palette open cmd") - } - if _, ok := cmd().(appevents.PaletteOpenRequested); !ok { - t.Fatalf("expected appevents.PaletteOpenRequested, got %T", cmd()) - } -} - -func TestUpdate_PendingTextRestore(t *testing.T) { - t.Parallel() - m := newTestInputBar(t) - m.pendingText = "restored text" - - m.Update(msgs.StreamFailed{Err: nil}) - if m.textarea.Value() != "restored text" { - t.Fatalf("textarea = %q, want restored text", m.textarea.Value()) - } - - m.Update(msgs.StreamCompleted{}) - if m.pendingText != "" { - t.Fatalf("pendingText = %q, want empty", m.pendingText) - } -} diff --git a/internal/app/chat/layout.go b/internal/app/chat/layout.go deleted file mode 100644 index 21c5ace4..00000000 --- a/internal/app/chat/layout.go +++ /dev/null @@ -1,110 +0,0 @@ -package chat - -import ( - "context" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/domain" -) - -// SetSize updates the dimensions. This is a flexible component. -func (m *Model) SetSize(width, height int) { - m.width = width - m.height = height - m.updateLayout() -} - -// SetOrigin sets the terminal-absolute position of this component's top-left corner. -func (m *Model) SetOrigin(x, y int) { - m.originX = x - m.originY = y - m.updateLayout() -} - -// updateLayout calculates sizes for children based on current dimensions. -func (m *Model) updateLayout() { - // Input bar is fixed height. - m.inputBar.SetWidth(m.width) - inputBarHeight := m.inputBar.Height() - - // MessageList is flexible - gets remaining space (minus 1 for spacer between list and input bar). - spacer := 0 - if m.hasMessages() { - spacer = 1 - } - messageListHeight := m.height - inputBarHeight - spacer - if messageListHeight < 0 { - messageListHeight = 0 - } - m.messageList.SetSize(m.width, messageListHeight) - m.messageList.SetOrigin(m.originX, m.originY) -} - -// ShortHelp returns the key bindings for the short help view. -func (m *Model) ShortHelp() []key.Binding { - if m.focus == focusMessages { - return []key.Binding{scrollUp, focusInputBar} - } - if m.hasMessages() { - return append(m.inputBar.ShortHelp(), focusChat) - } - return m.inputBar.ShortHelp() -} - -// ConversationID returns the current conversation ID. -func (m *Model) ConversationID() domain.ConversationID { - return m.conversationID -} - -// CancelActiveRound cancels the active round if one exists. -// Returns true if a round was cancelled and a command for async cleanup. -func (m *Model) CancelActiveRound() (bool, tea.Cmd) { - if !m.messageList.HasActiveRound() { - return false, nil - } - - last := m.messageList.LastRound() - m.messageList.CancelActiveRound() - - var cleanupCmd tea.Cmd - if last != nil { - ids := last.LastTurnMessageIDs() - if m.session != nil { - m.session.RemoveMessagesByID(ids) - } - cleanupCmd = m.cleanupOrphanedMessages(ids) - - // Turn 1: remove round entirely (no assistant content to show). - if !last.HasAssistantContent() { - m.messageList.RemoveLastRound() - } - } - - return true, cleanupCmd -} - -// hasMessages returns true if there are messages to display. -func (m *Model) hasMessages() bool { - return m.messageList.Len() > 0 -} - -type orphanedMessagesCleanupCompleted struct { - ids []domain.MessageID - err error -} - -func (m *Model) cleanupOrphanedMessages(ids []domain.MessageID) tea.Cmd { - if len(ids) == 0 || m.runtimeDeps.OrphanCleaner == nil { - return nil - } - cleaner := m.runtimeDeps.OrphanCleaner - return func() tea.Msg { - ctx, cancel := context.WithTimeout(m.runtimeDeps.EffectContext, dbOpTimeout) - defer cancel() - return orphanedMessagesCleanupCompleted{ - ids: ids, - err: cleaner.CleanupMessages(ctx, ids), - } - } -} diff --git a/internal/app/chat/messagelist/behavior_test.go b/internal/app/chat/messagelist/behavior_test.go deleted file mode 100644 index 9593e5f4..00000000 --- a/internal/app/chat/messagelist/behavior_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package messagelist - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - chattools "github.com/usetero/cli/internal/app/chattools" - chat "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/tea/teatest" -) - -func addCompletedRound(t *testing.T, m *Model, turnID domain.MessageID, text string) { - t.Helper() - - m.StartTurn("conv-1", "acct-1", turnID, msgs.UserSubmittedInput{Text: "prompt " + string(turnID)}, nil, nil) - m.Update(msgs.StreamCompleted{ - TurnID: turnID, - Message: domain.Message{ - ID: "asst-" + turnID, - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: text}}, - }, - }, - }) -} - -func seedHistoryWithActiveRound(t *testing.T, height int) *Model { - t.Helper() - - m := newStreamingMessageList(t) - m.SetSize(80, height) - - for i := range 6 { - id := domain.MessageID(fmt.Sprintf("user-%d", i+1)) - addCompletedRound(t, m, id, fmt.Sprintf("history %d", i+1)) - } - - m.StartTurn("conv-1", "acct-1", "user-live", msgs.UserSubmittedInput{Text: "live"}, nil, nil) - return m -} - -type toggleSpyBlock struct { - text string - toggleCnt int -} - -func (b *toggleSpyBlock) View() string { return b.text } -func (b *toggleSpyBlock) Height() int { return 1 } -func (b *toggleSpyBlock) Update(tea.Msg) tea.Cmd { return nil } -func (b *toggleSpyBlock) SetWidth(int) {} -func (b *toggleSpyBlock) SetFocused(bool) {} -func (b *toggleSpyBlock) Focused() bool { return false } -func (b *toggleSpyBlock) Kind() block.Kind { return block.KindAssistantText } -func (b *toggleSpyBlock) Toggle(int) { b.toggleCnt++ } - -func TestBehavior_CancelledRoundIgnoresStaleAssistantUpdates(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.CancelActiveRound() - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-stale", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "stale text"}}, - }, - }, - }) - - if len(m.rounds) != 2 { - t.Fatalf("round count=%d, want 2", len(m.rounds)) - } - if m.rounds[0].State() != round.StateCancelled { - t.Fatalf("round[0] state=%v, want cancelled", m.rounds[0].State()) - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should still be active") - } - if strings.Contains(m.View(), "stale text") { - t.Fatalf("stale update should not be rendered in view") - } -} - -func TestBehavior_StreamUpdateScrollPolicy(t *testing.T) { - t.Parallel() - - t.Run("at bottom sticks to bottom on assistant updates", func(t *testing.T) { - t.Parallel() - m := seedHistoryWithActiveRound(t, 8) - m.vp.ScrollToBottom() - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-live", - Message: domain.Message{ - ID: "asst-live", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "live update"}}, - }, - }, - }) - - if !m.vp.AtBottom() { - t.Fatalf("expected to remain at bottom after update") - } - }) - - t.Run("scrolled up does not get yanked to bottom", func(t *testing.T) { - t.Parallel() - m := seedHistoryWithActiveRound(t, 8) - m.vp.ScrollToBottom() - m.vp.ScrollBy(-4) - m.vp.UpdateFocusFromScroll() - - if m.vp.AtBottom() { - t.Fatalf("precondition failed: expected scrolled-up viewport") - } - beforeIdx, beforeLine := m.vp.Offset() - - m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-live", - Message: domain.Message{ - ID: "asst-live", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "live update"}}, - }, - }, - }) - - if m.vp.AtBottom() { - t.Fatalf("viewport should stay scrolled up after update") - } - afterIdx, afterLine := m.vp.Offset() - if beforeIdx != afterIdx || beforeLine != afterLine { - t.Fatalf("expected offset stability while scrolled up: before=(%d,%d) after=(%d,%d)", beforeIdx, beforeLine, afterIdx, afterLine) - } - }) -} - -func TestBehavior_MouseReleaseActionPolicy(t *testing.T) { - t.Parallel() - - newToggleModel := func(t *testing.T) (*Model, *toggleSpyBlock, *toggleSpyBlock) { - t.Helper() - m := newStreamingMessageList(t) - m.SetSize(80, 8) - m.SetOrigin(0, 0) - addCompletedRound(t, m, "seed-round", "seed") - - b0 := &toggleSpyBlock{text: "alpha"} - b1 := &toggleSpyBlock{text: "beta"} - m.blocks = []blockEntry{ - {block: b0, roundIndex: 0}, - {block: b1, roundIndex: 0}, - } - // 2 blocks with 1-line heights and 1-line gap between them: - // block 0 at y=0, gap at y=1, block 1 at y=2. - m.vp.SetItems([]int{1, 1}, []int{0, 1}) - m.vp.SetTrailingHeight(0) - m.vp.ScrollToTop() - return m, b0, b1 - } - - t.Run("plain click triggers toggle", func(t *testing.T) { - t.Parallel() - m, b0, b1 := newToggleModel(t) - - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - - if b0.toggleCnt != 1 { - t.Fatalf("expected first block toggle once, got %d", b0.toggleCnt) - } - if b1.toggleCnt != 0 { - t.Fatalf("expected second block untouched, got %d", b1.toggleCnt) - } - }) - - t.Run("drag selection across blocks does not toggle", func(t *testing.T) { - t.Parallel() - m, b0, b1 := newToggleModel(t) - - m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 2, Y: 0}) - m.Update(tea.MouseMotionMsg{Button: tea.MouseLeft, X: 2, Y: 2}) - cmd := m.Update(tea.MouseReleaseMsg{Button: tea.MouseLeft, X: 2, Y: 2}) - - if cmd == nil { - t.Fatalf("expected copy command on drag highlight release") - } - if b0.toggleCnt != 0 || b1.toggleCnt != 0 { - t.Fatalf("expected no toggles on drag copy, got b0=%d b1=%d", b0.toggleCnt, b1.toggleCnt) - } - }) -} - -func TestBehavior_StaleToolCompletedIgnored(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.CancelActiveRound() - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - if len(m.rounds) != 2 { - t.Fatalf("round count=%d, want 2", len(m.rounds)) - } - if m.rounds[0].State() != round.StateCancelled { - t.Fatalf("round[0] state=%v, want cancelled", m.rounds[0].State()) - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should be active before stale tool completion") - } - - cmd := m.Update(msgs.ToolCompleted{ - TurnID: "user-1", - ToolUseID: "tool-old", - Result: domaintools.Result{ToolUseID: "tool-old"}, - }) - - if cmd != nil { - t.Fatalf("expected stale ToolCompleted to be ignored, got non-nil cmd") - } - if !m.rounds[1].IsActive() { - t.Fatalf("round[1] should remain active after stale tool completion") - } -} - -func TestBehavior_ToolResultsStayBoundToOriginalBlock(t *testing.T) { - t.Parallel() - - type toolInput struct { - Name string `json:"name"` - } - - actionTool := chattools.NewActionTool( - chat.Tool{Name: "set_service_enabled"}, - func(input json.RawMessage) (domaintools.Result, error) { - var in toolInput - if err := json.Unmarshal(input, &in); err != nil { - return domaintools.Result{}, err - } - return domaintools.Result{ - Content: map[string]any{ - "name": in.Name, - }, - }, nil - }, - action.Config{ - DisplayName: func(_ json.RawMessage) string { return "Enable Service" }, - Status: func(_ json.RawMessage) string { return "Enabling" }, - Result: func(result domaintools.Result) string { - name, _ := result.Content["name"].(string) - return name + " enabled" - }, - }, - ) - registry := chattools.NewRegistry(map[string]chattools.ActionTool{ - "set_service_enabled": actionTool, - }) - - m := newStreamingMessageList(t) - m.toolRegistry = registry - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "enable"}, nil, nil) - - cmd1 := m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-1", - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"alpha"}`), - InputComplete: true, - }, - }, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd1, 64) - - viewAfterFirst := m.View() - if !strings.Contains(viewAfterFirst, "alpha enabled") { - t.Fatalf("expected first result in view, got:\n%s", viewAfterFirst) - } - - cmd2 := m.Update(msgs.AssistantContentUpdated{ - TurnID: "user-1", - Message: domain.Message{ - ID: "asst-1", - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"alpha"}`), - InputComplete: true, - }, - }, - { - Index: 1, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-2", - Name: "set_service_enabled", - Input: json.RawMessage(`{"name":"beta"}`), - InputComplete: true, - }, - }, - }, - }, - }) - teatest.DrainCmds(m.Update, cmd2, 128) - - viewAfterSecond := m.View() - if strings.Count(viewAfterSecond, "alpha enabled") != 1 { - t.Fatalf("expected alpha result to remain exactly once, got:\n%s", viewAfterSecond) - } - if strings.Count(viewAfterSecond, "beta enabled") != 1 { - t.Fatalf("expected beta result exactly once, got:\n%s", viewAfterSecond) - } -} diff --git a/internal/app/chat/messagelist/block/block.go b/internal/app/chat/messagelist/block/block.go deleted file mode 100644 index d5e0b8ca..00000000 --- a/internal/app/chat/messagelist/block/block.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package block defines the shared interface for all visual blocks -// in the message list viewport. -package block - -import tea "charm.land/bubbletea/v2" - -// BorderWidth is the width of the left border on blocks. -// The messagelist applies a left border; blocks handle their own internal padding. -const BorderWidth = 1 - -// PaddingX is the horizontal padding inside each block. -const PaddingX = 2 - -// PaddingY is the vertical padding inside elevated blocks (text, tools, user). -const PaddingY = 1 - -// Kind identifies the type of a block for layout and styling decisions. -type Kind int - -const ( - KindUser Kind = iota - KindAssistantText - KindThinking - KindTool - KindThinkingAnimation -) - -// Block is the interface for all visual atoms in the message list. -// Each block is an independently focusable, renderable unit. -// Width is set via SetWidth before View is called. -type Block interface { - // View renders the block at the width set by SetWidth. - View() string - - // Height returns the number of lines this block renders. - Height() int - - // Update handles messages. - Update(tea.Msg) tea.Cmd - - // SetWidth sets the available width for rendering. - SetWidth(int) - - // SetFocused sets whether this block is the focused block in the viewport. - SetFocused(bool) - - // Focused returns whether this block is currently focused. - Focused() bool - - // Kind returns the block type. - Kind() Kind -} - -// Toggleable is an optional interface for blocks that can be toggled -// (e.g. expand/collapse thinking blocks, show/hide tool body). -// y is the click position relative to the block's top edge. -// Blocks should only toggle when the click is on the header line. -type Toggleable interface { - Toggle(y int) -} diff --git a/internal/app/chat/messagelist/interaction.go b/internal/app/chat/messagelist/interaction.go deleted file mode 100644 index 47e62e6a..00000000 --- a/internal/app/chat/messagelist/interaction.go +++ /dev/null @@ -1,41 +0,0 @@ -package messagelist - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -type keyDecision struct { - handle bool - focusDelta int - scrollDelta int -} - -func reduceKeyPress(msg tea.KeyPressMsg, focused bool) keyDecision { - if !focused { - return keyDecision{} - } - switch { - case key.Matches(msg, focusPrevKey): - return keyDecision{handle: true, focusDelta: -1} - case key.Matches(msg, focusNextKey): - return keyDecision{handle: true, focusDelta: 1} - case key.Matches(msg, scrollUpKey): - return keyDecision{handle: true, scrollDelta: -1} - case key.Matches(msg, scrollDownKey): - return keyDecision{handle: true, scrollDelta: 1} - default: - return keyDecision{} - } -} - -func reduceMouseWheel(button tea.MouseButton) int { - switch button { - case tea.MouseWheelUp: - return -5 - case tea.MouseWheelDown: - return 5 - default: - return 0 - } -} diff --git a/internal/app/chat/messagelist/interaction_test.go b/internal/app/chat/messagelist/interaction_test.go deleted file mode 100644 index 8d5dbcd0..00000000 --- a/internal/app/chat/messagelist/interaction_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package messagelist - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -func TestReduceKeyPress(t *testing.T) { - t.Parallel() - - t.Run("ignored when not focused", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp}, false) - if d.handle || d.focusDelta != 0 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("focus prev", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp, Mod: tea.ModShift}, true) - if !d.handle || d.focusDelta != -1 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("focus next", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModShift}, true) - if !d.handle || d.focusDelta != 1 || d.scrollDelta != 0 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("scroll up", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyUp}, true) - if !d.handle || d.focusDelta != 0 || d.scrollDelta != -1 { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("scroll down", func(t *testing.T) { - t.Parallel() - d := reduceKeyPress(tea.KeyPressMsg{Code: tea.KeyDown}, true) - if !d.handle || d.focusDelta != 0 || d.scrollDelta != 1 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceMouseWheel(t *testing.T) { - t.Parallel() - - if got := reduceMouseWheel(tea.MouseWheelUp); got != -5 { - t.Fatalf("MouseWheelUp=%d, want -5", got) - } - if got := reduceMouseWheel(tea.MouseWheelDown); got != 5 { - t.Fatalf("MouseWheelDown=%d, want 5", got) - } - if got := reduceMouseWheel(tea.MouseLeft); got != 0 { - t.Fatalf("MouseLeft=%d, want 0", got) - } -} diff --git a/internal/app/chat/messagelist/messagelist.go b/internal/app/chat/messagelist/messagelist.go deleted file mode 100644 index 74e3c4a7..00000000 --- a/internal/app/chat/messagelist/messagelist.go +++ /dev/null @@ -1,164 +0,0 @@ -package messagelist - -import ( - "charm.land/bubbles/v2/key" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/viewport" -) - -var ( - scrollUpKey = key.NewBinding(key.WithKeys("up")) - scrollDownKey = key.NewBinding(key.WithKeys("down")) - focusPrevKey = key.NewBinding(key.WithKeys("shift+up")) - focusNextKey = key.NewBinding(key.WithKeys("shift+down")) -) - -// Layout constants. -const ( - // outerBorderWidth is the left border on the entire message list (thick accent bar). - outerBorderWidth = 1 - - // blockGap is the number of blank lines between blocks within a round. - blockGap = 1 - - // roundGap is the number of blank lines between rounds. - roundGap = 2 - - // gapBeforeDivider is blank lines before the round completion divider. - gapBeforeDivider = 1 - - // dividerHeight is the number of lines the divider itself occupies. - dividerHeight = 1 -) - -// blockEntry pairs a block with its round metadata for layout decisions. -type blockEntry struct { - block block.Block - roundIndex int -} - -// Model displays the conversation history and manages rounds. -// Scroll, focus, and hit-test math is delegated to a viewport.Model. -// -// Methods are split across files by responsibility: -// - messagelist.go struct, constructor, accessors, layout helpers -// - update.go message dispatch -// - render.go View, block rendering, gaps, dividers -// - mouse.go click routing, text selection -// - rounds.go round lifecycle, block collection -type Model struct { - theme styles.Theme - scope log.Scope - - // Data hierarchy (owns the blocks, routes updates) - rounds []*round.Model - - // Flat block list for viewport rendering (rebuilt from rounds) - blocks []blockEntry - layout layoutProjection - - // Viewport: scroll, focus, hit testing (pure math) - vp viewport.Model - - // Viewport dimensions - width int - height int - - // Screen origin (top-left corner of this component in terminal coordinates). - // Set by the parent layout so mouse clicks can be translated. - originX int - originY int - - // Whether this component has keyboard focus (different from block focus) - focused bool - - // Mouse selection state - mouseDown bool - mouseDownBlock int // block index where mouse was pressed (-1 = none) - mouseDownX int // X within block content - mouseDownY int // Y within block (line offset) - mouseDragBlock int // current block during drag (-1 = none) - mouseDragX int // current X within block content - mouseDragY int // current Y within block - - // Dependencies - runtimeDeps usecase.RuntimeDeps - toolRegistry *tools.Registry -} - -// New creates a new message list. -func New( - theme styles.Theme, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *tools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("messagelist") - - return &Model{ - theme: theme, - scope: scope, - vp: viewport.New(), - mouseDownBlock: -1, - mouseDragBlock: -1, - runtimeDeps: runtimeDeps, - toolRegistry: toolRegistry, - } -} - -// --- Size and focus --- - -// SetSize sets the dimensions. -func (m *Model) SetSize(width, height int) { - atBottom := m.vp.AtBottom() - m.width = width - m.height = height - m.vp.SetHeight(height) - m.updateRoundWidths() - if atBottom { - m.vp.ScrollToBottom() - } -} - -// SetOrigin sets the terminal-absolute position of this component's top-left corner. -// Called by the parent during layout so mouse coordinates can be translated. -func (m *Model) SetOrigin(x, y int) { - m.originX = x - m.originY = y -} - -// SetFocused sets focus state. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused returns whether the message list is focused. -func (m *Model) Focused() bool { - return m.focused -} - -// Len returns the number of rounds. -func (m *Model) Len() int { - return len(m.rounds) -} - -// --- Layout helpers --- - -// contentWidth returns the width available for block content. -func (m *Model) contentWidth() int { - return m.width - outerBorderWidth -} - -// blockHeight returns the line count of block at idx without rendering. -func (m *Model) blockHeight(idx int) int { - h := m.blocks[idx].block.Height() - if h < 1 { - return 1 - } - return h -} diff --git a/internal/app/chat/messagelist/messagelisttest/messagelist.go b/internal/app/chat/messagelist/messagelisttest/messagelist.go deleted file mode 100644 index d4dee811..00000000 --- a/internal/app/chat/messagelist/messagelisttest/messagelist.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package messagelisttest provides test helpers for the messagelist package. -package messagelisttest - -import ( - "testing" - - "github.com/usetero/cli/internal/app/chat/messagelist" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/boundary/chat/chattest" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -// New creates a messagelist.Model wired with test dependencies. -// Uses ephemeral in-memory chat deps, mock chat client, real theme, and test logger. -func New(t *testing.T, width, height int) *messagelist.Model { - t.Helper() - - theme := styles.NewTheme(true) - client := &chattest.MockClient{} - scope := logtest.NewScope(t) - runtimeDeps := usecase.NewRuntimeDeps(client) - - m := messagelist.New(theme, runtimeDeps, nil, scope) - m.SetSize(width, height) - return m -} diff --git a/internal/app/chat/messagelist/mouse.go b/internal/app/chat/messagelist/mouse.go deleted file mode 100644 index e0241536..00000000 --- a/internal/app/chat/messagelist/mouse.go +++ /dev/null @@ -1,137 +0,0 @@ -package messagelist - -import ( - "image" - "strings" - - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/tea/highlight" -) - -func (m *Model) selectionState() selectionState { - return selectionState{ - mouseDown: m.mouseDown, - mouseDownBlock: m.mouseDownBlock, - mouseDownX: m.mouseDownX, - mouseDownY: m.mouseDownY, - mouseDragBlock: m.mouseDragBlock, - mouseDragX: m.mouseDragX, - mouseDragY: m.mouseDragY, - } -} - -func (m *Model) setSelectionState(state selectionState) { - m.mouseDown = state.mouseDown - m.mouseDownBlock = state.mouseDownBlock - m.mouseDownX = state.mouseDownX - m.mouseDownY = state.mouseDownY - m.mouseDragBlock = state.mouseDragBlock - m.mouseDragX = state.mouseDragX - m.mouseDragY = state.mouseDragY -} - -// --- Click handling --- - -// handleBlockClick handles a click on a specific block. -// If the block implements Toggleable, it toggles and we rebuild -// the viewport items (heights may have changed). -func (m *Model) handleBlockClick(idx, y int) { - if idx < 0 || idx >= len(m.blocks) { - return - } - if t, ok := m.blocks[idx].block.(block.Toggleable); ok { - t.Toggle(y) - m.syncViewportItems() - } -} - -// --- Highlight / selection --- - -// hasHighlight returns whether there is a current text selection. -func (m *Model) hasHighlight() bool { - state := m.selectionState() - if state.mouseDownBlock < 0 || state.mouseDragBlock < 0 { - return false - } - return state.mouseDownBlock != state.mouseDragBlock || - state.mouseDownY != state.mouseDragY || - state.mouseDownX != state.mouseDragX -} - -// getHighlightRange returns the normalized selection range (start <= end). -func (m *Model) getHighlightRange() (startBlock, startLine, startCol, endBlock, endLine, endCol int) { - if m.mouseDownBlock < 0 { - return -1, -1, -1, -1, -1, -1 - } - - draggingForward := m.mouseDragBlock > m.mouseDownBlock || - (m.mouseDragBlock == m.mouseDownBlock && m.mouseDragY > m.mouseDownY) || - (m.mouseDragBlock == m.mouseDownBlock && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX) - - if draggingForward { - return m.mouseDownBlock, m.mouseDownY, m.mouseDownX, - m.mouseDragBlock, m.mouseDragY, m.mouseDragX - } - return m.mouseDragBlock, m.mouseDragY, m.mouseDragX, - m.mouseDownBlock, m.mouseDownY, m.mouseDownX -} - -// blockHighlightRange returns the highlight coordinates for a single block, -// given its index and the overall selection range. Returns (-1,-1,-1,-1) if -// the block is not in the selection. -func blockHighlightRange(idx, startBlock, startLine, startCol, endBlock, endLine, endCol int) (sLine, sCol, eLine, eCol int) { - if idx < startBlock || idx > endBlock { - return -1, -1, -1, -1 - } - if idx == startBlock && idx == endBlock { - return startLine, startCol, endLine, endCol - } - if idx == startBlock { - return startLine, startCol, -1, -1 // to end of block - } - if idx == endBlock { - return 0, 0, endLine, endCol - } - return 0, 0, -1, -1 // fully highlighted -} - -// extractHighlight returns the plain text of the current selection. -func (m *Model) extractHighlight() string { - startBlock, startLine, startCol, endBlock, endLine, endCol := m.getHighlightRange() - if startBlock < 0 { - return "" - } - - var sb strings.Builder - for i := startBlock; i <= endBlock && i < len(m.blocks); i++ { - sLine, sCol, eLine, eCol := blockHighlightRange(i, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sLine < 0 { - continue - } - - // Extract from the block's own View() — the content before - // renderBlock wraps it with border/padding decorations. - content := m.blocks[i].block.View() - w := lipgloss.Width(content) - h := lipgloss.Height(content) - area := image.Rect(0, 0, w, h) - - text := highlight.Extract(content, area, sLine, sCol, eLine, eCol) - if text != "" { - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString(strings.TrimRight(text, "\n")) - } - } - - return sb.String() -} - -// clearSelection resets mouse selection state. -func (m *Model) clearSelection() { - m.mouseDown = false - m.mouseDownBlock = -1 - m.mouseDragBlock = -1 -} diff --git a/internal/app/chat/messagelist/mouse_target.go b/internal/app/chat/messagelist/mouse_target.go deleted file mode 100644 index 6c4dc9fa..00000000 --- a/internal/app/chat/messagelist/mouse_target.go +++ /dev/null @@ -1,27 +0,0 @@ -package messagelist - -type mouseTarget struct { - viewX int - viewY int - blockIdx int - blockY int - hit bool -} - -func projectMouseToView(msgX, msgY, originX, originY int) (viewX, viewY int) { - viewX = msgX - originX - outerBorderWidth - viewY = msgY - originY - return viewX, viewY -} - -func resolveMouseTarget(msgX, msgY, originX, originY int, itemAtY func(int) (int, int)) mouseTarget { - viewX, viewY := projectMouseToView(msgX, msgY, originX, originY) - blockIdx, blockY := itemAtY(viewY) - return mouseTarget{ - viewX: viewX, - viewY: viewY, - blockIdx: blockIdx, - blockY: blockY, - hit: blockIdx >= 0, - } -} diff --git a/internal/app/chat/messagelist/mouse_target_test.go b/internal/app/chat/messagelist/mouse_target_test.go deleted file mode 100644 index 48cdf2bd..00000000 --- a/internal/app/chat/messagelist/mouse_target_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package messagelist - -import "testing" - -func TestProjectMouseToView(t *testing.T) { - t.Parallel() - - viewX, viewY := projectMouseToView(25, 11, 10, 3) - if viewX != 14 || viewY != 8 { - t.Fatalf("got (%d,%d), want (14,8)", viewX, viewY) - } -} - -func TestResolveMouseTarget(t *testing.T) { - t.Parallel() - - t.Run("hit", func(t *testing.T) { - t.Parallel() - got := resolveMouseTarget(20, 9, 5, 2, func(viewY int) (int, int) { - if viewY == 7 { - return 3, 4 - } - return -1, -1 - }) - if !got.hit || got.blockIdx != 3 || got.blockY != 4 { - t.Fatalf("unexpected target: %+v", got) - } - if got.viewX != 14 || got.viewY != 7 { - t.Fatalf("unexpected view coords: %+v", got) - } - }) - - t.Run("miss", func(t *testing.T) { - t.Parallel() - got := resolveMouseTarget(20, 9, 5, 2, func(int) (int, int) { - return -1, -1 - }) - if got.hit { - t.Fatalf("expected miss: %+v", got) - } - if got.blockIdx != -1 || got.blockY != -1 { - t.Fatalf("unexpected block coords: %+v", got) - } - }) -} diff --git a/internal/app/chat/messagelist/mouse_test.go b/internal/app/chat/messagelist/mouse_test.go deleted file mode 100644 index b35b9b4d..00000000 --- a/internal/app/chat/messagelist/mouse_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package messagelist - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" -) - -// stubBlock is a minimal block.Block for testing selection math. -type stubBlock struct { - text string - kind block.Kind - width int - focused bool -} - -func newStubBlock(text string, kind block.Kind) *stubBlock { - return &stubBlock{text: text, kind: kind} -} - -func (b *stubBlock) View() string { return b.text } -func (b *stubBlock) Height() int { return len(strings.Split(b.text, "\n")) } -func (b *stubBlock) Update(tea.Msg) tea.Cmd { return nil } -func (b *stubBlock) SetWidth(w int) { b.width = w } -func (b *stubBlock) SetFocused(f bool) { b.focused = f } -func (b *stubBlock) Focused() bool { return b.focused } -func (b *stubBlock) Kind() block.Kind { return b.kind } - -func TestGetHighlightRange(t *testing.T) { - t.Parallel() - - t.Run("no selection", func(t *testing.T) { - t.Parallel() - m := &Model{mouseDownBlock: -1} - sb, _, _, eb, _, _ := m.getHighlightRange() - if sb != -1 || eb != -1 { - t.Errorf("expected (-1, -1), got (%d, %d)", sb, eb) - } - }) - - t.Run("forward drag", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 1, mouseDownX: 5, - mouseDragBlock: 2, mouseDragY: 3, mouseDragX: 10, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 0 || sl != 1 || sc != 5 || eb != 2 || el != 3 || ec != 10 { - t.Errorf("forward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) - - t.Run("backward drag normalizes", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 2, mouseDownY: 3, mouseDownX: 10, - mouseDragBlock: 0, mouseDragY: 1, mouseDragX: 5, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 0 || sl != 1 || sc != 5 || eb != 2 || el != 3 || ec != 10 { - t.Errorf("backward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) - - t.Run("same block backward drag normalizes by line", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 1, mouseDownY: 5, mouseDownX: 3, - mouseDragBlock: 1, mouseDragY: 2, mouseDragX: 8, - } - sb, sl, sc, eb, el, ec := m.getHighlightRange() - if sb != 1 || sl != 2 || sc != 8 || eb != 1 || el != 5 || ec != 3 { - t.Errorf("same-block-backward: got (%d,%d,%d) -> (%d,%d,%d)", sb, sl, sc, eb, el, ec) - } - }) -} - -func TestBlockHighlightRange(t *testing.T) { - t.Parallel() - - // Selection from block 1 (line 2, col 5) to block 3 (line 4, col 10) - startBlock, startLine, startCol := 1, 2, 5 - endBlock, endLine, endCol := 3, 4, 10 - - t.Run("before selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(0, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != -1 || sc != -1 || el != -1 || ec != -1 { - t.Errorf("expected not selected, got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("start block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(1, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 2 || sc != 5 || el != -1 || ec != -1 { - t.Errorf("start: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("middle block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(2, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 0 || sc != 0 || el != -1 || ec != -1 { - t.Errorf("middle: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("end block", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(3, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != 0 || sc != 0 || el != 4 || ec != 10 { - t.Errorf("end: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("after selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(4, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sl != -1 || sc != -1 || el != -1 || ec != -1 { - t.Errorf("expected not selected, got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) - - t.Run("single block selection", func(t *testing.T) { - t.Parallel() - sl, sc, el, ec := blockHighlightRange(2, 2, 1, 3, 2, 5, 8) - if sl != 1 || sc != 3 || el != 5 || ec != 8 { - t.Errorf("single: got (%d,%d,%d,%d)", sl, sc, el, ec) - } - }) -} - -func TestExtractHighlight(t *testing.T) { - t.Parallel() - - t.Run("extracts content without border", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 0, mouseDownX: 0, - mouseDragBlock: 0, mouseDragY: 0, mouseDragX: 5, - } - m.blocks = []blockEntry{ - {block: newStubBlock("hello world", block.KindAssistantText)}, - } - - text := m.extractHighlight() - if !strings.Contains(text, "hello") { - t.Errorf("expected extracted text to contain 'hello', got %q", text) - } - // The key invariant: no border character should appear - if strings.Contains(text, "│") { - t.Errorf("extracted text should not contain border character, got %q", text) - } - }) - - t.Run("no selection returns empty", func(t *testing.T) { - t.Parallel() - m := &Model{mouseDownBlock: -1} - if text := m.extractHighlight(); text != "" { - t.Errorf("expected empty, got %q", text) - } - }) - - t.Run("multi-block extraction", func(t *testing.T) { - t.Parallel() - m := &Model{ - mouseDownBlock: 0, mouseDownY: 0, mouseDownX: 0, - mouseDragBlock: 1, mouseDragY: 0, mouseDragX: 3, - } - m.blocks = []blockEntry{ - {block: newStubBlock("first", block.KindAssistantText)}, - {block: newStubBlock("second", block.KindAssistantText)}, - } - - text := m.extractHighlight() - if !strings.Contains(text, "first") || !strings.Contains(text, "sec") { - t.Errorf("expected both blocks in extraction, got %q", text) - } - }) -} diff --git a/internal/app/chat/messagelist/projection.go b/internal/app/chat/messagelist/projection.go deleted file mode 100644 index a2093c4e..00000000 --- a/internal/app/chat/messagelist/projection.go +++ /dev/null @@ -1,81 +0,0 @@ -package messagelist - -type projectedItem struct { - roundIndex int - height int -} - -type projectedGap struct { - height int - dividerRound int -} - -type layoutProjection struct { - heights []int - gaps []projectedGap - trailingDividerRound int -} - -func projectItems(entries []blockEntry, blockHeight func(int) int) []projectedItem { - items := make([]projectedItem, 0, len(entries)) - for i, e := range entries { - items = append(items, projectedItem{ - roundIndex: e.roundIndex, - height: blockHeight(i), - }) - } - return items -} - -func projectLayout(items []projectedItem, roundActive func(int) bool) layoutProjection { - n := len(items) - p := layoutProjection{ - heights: make([]int, n), - gaps: make([]projectedGap, n), - trailingDividerRound: -1, - } - for i := range p.gaps { - p.gaps[i].dividerRound = -1 - } - for i := range items { - p.heights[i] = items[i].height - if i == 0 { - continue - } - prev := items[i-1] - curr := items[i] - if prev.roundIndex == curr.roundIndex { - p.gaps[i].height = blockGap - continue - } - g := roundGap - if !roundActive(prev.roundIndex) { - g += gapBeforeDivider + dividerHeight - p.gaps[i].dividerRound = prev.roundIndex - } - p.gaps[i].height = g - } - if n == 0 { - return p - } - last := items[n-1] - if !roundActive(last.roundIndex) { - p.trailingDividerRound = last.roundIndex - } - return p -} - -func (p layoutProjection) gapHeights() []int { - g := make([]int, len(p.gaps)) - for i := range p.gaps { - g[i] = p.gaps[i].height - } - return g -} - -func (p layoutProjection) trailingHeight() int { - if p.trailingDividerRound < 0 { - return 0 - } - return gapBeforeDivider + dividerHeight -} diff --git a/internal/app/chat/messagelist/projection_test.go b/internal/app/chat/messagelist/projection_test.go deleted file mode 100644 index 9305503f..00000000 --- a/internal/app/chat/messagelist/projection_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package messagelist - -import "testing" - -func TestProjectLayout(t *testing.T) { - t.Parallel() - - t.Run("empty", func(t *testing.T) { - t.Parallel() - p := projectLayout(nil, func(int) bool { return true }) - if len(p.heights) != 0 || len(p.gaps) != 0 || p.trailingDividerRound >= 0 { - t.Fatalf("unexpected layout: %+v", p) - } - }) - - t.Run("same round uses block gap", func(t *testing.T) { - t.Parallel() - items := []projectedItem{ - {roundIndex: 0, height: 3}, - {roundIndex: 0, height: 4}, - } - p := projectLayout(items, func(int) bool { return true }) - if p.heights[0] != 3 || p.heights[1] != 4 { - t.Fatalf("unexpected heights: %v", p.heights) - } - if p.gaps[1].height != blockGap { - t.Fatalf("gap[1]=%d, want %d", p.gaps[1].height, blockGap) - } - if p.trailingDividerRound >= 0 { - t.Fatalf("trailingDividerRound=%d, want none", p.trailingDividerRound) - } - }) - - t.Run("round boundary with completed previous includes divider gap", func(t *testing.T) { - t.Parallel() - items := []projectedItem{ - {roundIndex: 0, height: 2}, - {roundIndex: 1, height: 2}, - } - p := projectLayout(items, func(round int) bool { - return round == 1 // round 0 completed, round 1 active - }) - want := roundGap + gapBeforeDivider + dividerHeight - if p.gaps[1].height != want { - t.Fatalf("gap[1]=%d, want %d", p.gaps[1].height, want) - } - if p.gaps[1].dividerRound != 0 { - t.Fatalf("dividerRound=%d, want 0", p.gaps[1].dividerRound) - } - if p.trailingDividerRound >= 0 { - t.Fatalf("trailingDividerRound=%d, want none", p.trailingDividerRound) - } - }) - - t.Run("trailing divider for completed last round", func(t *testing.T) { - t.Parallel() - items := []projectedItem{{roundIndex: 0, height: 2}} - p := projectLayout(items, func(int) bool { return false }) - if p.trailingDividerRound != 0 { - t.Fatalf("trailingDividerRound=%d, want 0", p.trailingDividerRound) - } - }) -} diff --git a/internal/app/chat/messagelist/reducer.go b/internal/app/chat/messagelist/reducer.go deleted file mode 100644 index 62e25a6a..00000000 --- a/internal/app/chat/messagelist/reducer.go +++ /dev/null @@ -1,38 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" -) - -type lifecycleDecision struct { - handle bool - forwardRounds bool - rebuild bool - clearSelection bool - scrollToBottom bool - focusLastAtBottom bool -} - -func reduceLifecycle(msg tea.Msg, wasAtBottom bool) lifecycleDecision { - switch msg.(type) { - case msgs.TurnStarted: - return lifecycleDecision{ - handle: true, - rebuild: true, - clearSelection: true, - scrollToBottom: true, - focusLastAtBottom: true, - } - case msgs.AssistantContentUpdated, msgs.StreamCompleted, msgs.StreamFailed: - return lifecycleDecision{ - handle: true, - forwardRounds: true, - rebuild: true, - scrollToBottom: wasAtBottom, - focusLastAtBottom: wasAtBottom, - } - default: - return lifecycleDecision{} - } -} diff --git a/internal/app/chat/messagelist/reducer_test.go b/internal/app/chat/messagelist/reducer_test.go deleted file mode 100644 index e853f618..00000000 --- a/internal/app/chat/messagelist/reducer_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package messagelist - -import ( - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" -) - -func TestReduceLifecycle(t *testing.T) { - t.Parallel() - - t.Run("turn started always rebuilds and scrolls", func(t *testing.T) { - t.Parallel() - d := reduceLifecycle(msgs.TurnStarted{}, false) - if !d.handle || !d.rebuild || !d.clearSelection || !d.scrollToBottom || !d.focusLastAtBottom { - t.Fatalf("unexpected decision: %+v", d) - } - if d.forwardRounds { - t.Fatalf("forwardRounds = true, want false: %+v", d) - } - }) - - t.Run("assistant update forwards and preserves bottom stickiness", func(t *testing.T) { - t.Parallel() - dTop := reduceLifecycle(msgs.AssistantContentUpdated{}, false) - if !dTop.handle || !dTop.forwardRounds || !dTop.rebuild { - t.Fatalf("unexpected top decision: %+v", dTop) - } - if dTop.scrollToBottom || dTop.focusLastAtBottom { - t.Fatalf("top decision should not force bottom: %+v", dTop) - } - - dBottom := reduceLifecycle(msgs.StreamCompleted{}, true) - if !dBottom.scrollToBottom || !dBottom.focusLastAtBottom { - t.Fatalf("bottom decision should stick bottom: %+v", dBottom) - } - }) - - t.Run("unrelated messages are ignored", func(t *testing.T) { - t.Parallel() - d := reduceLifecycle(struct{}{}, true) - if d.handle { - t.Fatalf("expected no-op decision, got %+v", d) - } - }) -} diff --git a/internal/app/chat/messagelist/render.go b/internal/app/chat/messagelist/render.go deleted file mode 100644 index 50662b67..00000000 --- a/internal/app/chat/messagelist/render.go +++ /dev/null @@ -1,228 +0,0 @@ -package messagelist - -import ( - "fmt" - "image" - "strings" - "time" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/tea/highlight" -) - -// View renders the visible portion of the message list. -func (m *Model) View() string { - if m.width == 0 || m.height == 0 { - return "" - } - - if len(m.blocks) == 0 { - return m.emptyView() - } - - lines := m.renderVisible() - - // Pad to viewport height - for len(lines) < m.height { - lines = append(lines, "") - } - - output := strings.Join(lines, "\n") - return output -} - -// renderVisible renders only the blocks visible in the viewport. -func (m *Model) renderVisible() []string { - offsetIdx, offsetLine := m.vp.Offset() - focusIdx := m.vp.FocusIdx() - - lines := make([]string, 0, m.height) - reachedEnd := false - - // Highlight state - startBlock, startLine, startCol, endBlock, endLine, endCol := m.getHighlightRange() - hasHL := m.hasHighlight() - highlighter := highlight.WithColors(m.theme.SelectionBg, m.theme.SelectionFg) - - for idx := offsetIdx; idx < len(m.blocks); idx++ { - // Insert gap/divider before this block (except the first visible block) - if idx > offsetIdx { - lines = append(lines, m.gapLines(idx)...) - } - - // Set focus state before rendering so the block can use it internally - m.blocks[idx].block.SetFocused(m.focused && idx == focusIdx) - - // Render the block - rendered := m.renderBlock(m.blocks[idx]) - - // Apply text highlight if this block is in the selection range - if hasHL { - sLine, sCol, eLine, eCol := blockHighlightRange(idx, startBlock, startLine, startCol, endBlock, endLine, endCol) - if sLine >= 0 { - cw := m.contentWidth() - h := lipgloss.Height(rendered) - area := image.Rect(block.BorderWidth, 0, cw, h) - rendered = highlight.Apply(rendered, area, sLine, sCol, eLine, eCol, highlighter) - } - } - - blockLines := strings.Split(rendered, "\n") - - // For the first block, skip offsetLine lines (partial scroll into it) - if idx == offsetIdx && offsetLine > 0 { - if offsetLine < len(blockLines) { - blockLines = blockLines[offsetLine:] - } else { - blockLines = nil - } - } - - lines = append(lines, blockLines...) - - if idx == len(m.blocks)-1 { - reachedEnd = true - } - - if len(lines) >= m.height { - break - } - } - - // Add trailing divider for the last round if we rendered to the end - if reachedEnd && len(m.blocks) > 0 { - if m.layout.trailingDividerRound >= 0 { - for range gapBeforeDivider { - lines = append(lines, "") - } - lines = append(lines, m.divider(m.rounds[m.layout.trailingDividerRound])) - } - } - - // Trim to viewport height - if len(lines) > m.height { - lines = lines[:m.height] - } - - return lines -} - -// renderBlock renders a single block with appropriate width and padding. -func (m *Model) renderBlock(entry blockEntry) string { - b := entry.block - cw := m.contentWidth() - - // Determine border color and style based on block type and focus state. - borderColor := m.theme.Bg - borderStyle := lipgloss.NormalBorder() - if b.Kind() == block.KindUser { - // User messages: accent border, thicker when focused. - borderColor = m.theme.Accent - if b.Focused() { - borderColor = m.theme.Warning - borderStyle = lipgloss.ThickBorder() - } - } else if b.Focused() { - // Assistant blocks: invisible border, thick orange when focused. - borderColor = m.theme.Warning - borderStyle = lipgloss.ThickBorder() - } - - return lipgloss.NewStyle(). - Width(cw). - BorderLeft(true). - BorderStyle(borderStyle). - BorderForeground(borderColor). - Render(b.View()) -} - -// gapLines returns the renderable lines to insert before block at idx. -// The gap projection is the source of truth for measurement. -func (m *Model) gapLines(idx int) []string { - if idx <= 0 || idx >= len(m.layout.gaps) { - return nil - } - - g := m.layout.gaps[idx] - if g.height == 0 { - return nil - } - - // No divider in this gap, just blank lines. - if g.dividerRound < 0 { - return make([]string, g.height) - } - - // Round boundary gap with a divider for the completed previous round. - lines := make([]string, 0, g.height) - for range gapBeforeDivider { - lines = append(lines, "") - } - lines = append(lines, m.divider(m.rounds[g.dividerRound])) - for range roundGap { - lines = append(lines, "") - } - return lines -} - -// divider renders " ◇ Tero 4s ─────────" for a completed round, -// or " ◇ Cancelled 1.2s ─────────" for a cancelled round. -func (m *Model) divider(r *round.Model) string { - cw := m.contentWidth() - - colors := m.theme - border := lipgloss.NewStyle().Foreground(colors.Border).Background(colors.Bg) - - duration := r.Duration() - var durationStr string - if duration < time.Minute { - durationStr = fmt.Sprintf("%.1fs", duration.Seconds()) - } else { - durationStr = fmt.Sprintf("%.1fm", duration.Minutes()) - } - - var prefix string - var prefixStyle lipgloss.Style - switch r.State() { - case round.StateCancelled: - prefix = fmt.Sprintf("◇ Cancelled %s ", durationStr) - prefixStyle = lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - case round.StateFailed: - base := fmt.Sprintf("◇ Error %s", durationStr) - errMsg := "" - if r.Err() != nil { - // Truncate error to fit: base + " — " + msg + " " + min 3 "─" - maxErr := cw - block.BorderWidth - lipgloss.Width(base) - len(" — ") - 1 - 3 - if maxErr > 0 { - errMsg = " — " + ansi.Truncate(r.Err().Error(), maxErr, "…") - } - } - prefix = base + errMsg + " " - prefixStyle = lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg) - default: - prefix = fmt.Sprintf("◇ Tero %s ", durationStr) - prefixStyle = lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - } - - indent := block.BorderWidth - prefixWidth := lipgloss.Width(prefix) - - lineWidth := cw - indent - prefixWidth - if lineWidth < 0 { - lineWidth = 0 - } - line := strings.Repeat("─", lineWidth) - - return strings.Repeat(" ", indent) + prefixStyle.Render(prefix) + border.Render(line) -} - -// emptyView renders an empty view padded to height. -func (m *Model) emptyView() string { - return lipgloss.NewStyle(). - Width(m.width). - Height(m.height). - Render("") -} diff --git a/internal/app/chat/messagelist/round/round.go b/internal/app/chat/messagelist/round/round.go deleted file mode 100644 index 25ba96aa..00000000 --- a/internal/app/chat/messagelist/round/round.go +++ /dev/null @@ -1,132 +0,0 @@ -package round - -import ( - "context" - "time" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// State represents the current state of a round. -type State int - -const ( - StateActive State = iota - StateAwaitingNextTurn // async DB work in progress before next turn - StateComplete - StateCancelled - StateFailed -) - -const dbOpTimeout = 2 * time.Second - -// IsActive returns true if the round is in-flight (active or awaiting next turn). -func (m *Model) IsActive() bool { - return m.state == StateActive || m.state == StateAwaitingNextTurn -} - -// Model represents a complete user→assistant exchange, potentially with multiple turns -// if tools are involved. A round starts with explicit user input and ends when the -// assistant stops (no more tool calls). -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - scope log.Scope - - id domain.MessageID // first user message ID, identifies the round - conversationID domain.ConversationID - accountID domain.AccountID - - turns []*turn.Model - session *corechat.Session // authoritative in-memory history for active tool loop - thinking *thinking.Model - state State - lastErr error - width int - - startTime time.Time - endTime time.Time - - streamRunner usecase.StreamRunner - streamErrorMapper usecase.StreamErrorMapper - assistantPersister usecase.AssistantPersister - toolLoop usecase.ToolLoop - toolRegistry *chattools.Registry - effectCtx context.Context -} - -// New creates a new round from explicit user input. -func New( - theme styles.Theme, - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - width int, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *chattools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("round") - streamRunner := runtimeDeps.StreamRunner - streamErrorMapper := runtimeDeps.StreamErrorMapper - assistantPersister := runtimeDeps.AssistantPersister - - // Create first turn with user's explicit input - firstTurn := turn.New( - theme, - conversationID, - accountID, - userMessageID, - input, - width, - streamRunner, - streamErrorMapper, - assistantPersister, - runtimeDeps.EffectContext, - toolRegistry, - scope, - ) - - return &Model{ - theme: theme, - scope: scope, - id: userMessageID, - conversationID: conversationID, - accountID: accountID, - turns: []*turn.Model{firstTurn}, - thinking: thinking.New(theme, thinking.Settings{Label: "Thinking"}), - state: StateActive, - width: width, - startTime: time.Now(), - streamRunner: runtimeDeps.StreamRunner, - streamErrorMapper: runtimeDeps.StreamErrorMapper, - assistantPersister: runtimeDeps.AssistantPersister, - toolLoop: runtimeDeps.ToolLoop, - toolRegistry: toolRegistry, - effectCtx: runtimeDeps.EffectContext, - } -} - -// Init starts the thinking animation. -func (m *Model) Init() tea.Cmd { - return m.thinking.Init() -} - -// StartStream begins streaming for the first turn. -func (m *Model) StartStream(messages []domain.Message, context []domain.ContextEntity) tea.Cmd { - if len(m.turns) == 0 { - return nil - } - m.session = corechat.NewSession(m.conversationID, messages) - return m.turns[0].StartStream(messages, context) -} diff --git a/internal/app/chat/messagelist/round/round_effects.go b/internal/app/chat/messagelist/round/round_effects.go deleted file mode 100644 index c512efdc..00000000 --- a/internal/app/chat/messagelist/round/round_effects.go +++ /dev/null @@ -1,132 +0,0 @@ -package round - -import ( - "context" - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn" - "github.com/usetero/cli/internal/app/chat/usecase" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// startNextTurn appends tool results and creates the next turn using in-memory history. -func (m *Model) startNextTurn(results []domaintools.Result) tea.Cmd { - m.scope.Info("starting next turn", "result_count", len(results)) - for _, summary := range summarizeToolResults(results) { - m.scope.Debug("next turn tool result", "summary", summary) - } - - return func() tea.Msg { - ctx, cancel := context.WithTimeout(m.effectCtx, dbOpTimeout) - defer cancel() - - prepared, err := m.toolLoop.PrepareNextTurn(ctx, usecase.PrepareNextTurnInput{ - AccountID: m.accountID, - ConversationID: m.conversationID, - Results: results, - Session: m.session, - }) - if err != nil { - // Durability failure should not block the active chat loop. - m.scope.Error("failed to create tool result message", "error", err) - } - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, prepared.Messages) - } - messages := prepared.Messages - for _, summary := range summarizeHistory(messages) { - m.scope.Debug("next turn history", "summary", summary) - } - - return nextTurnReady{ - roundID: m.id, - messageID: prepared.MessageID, - results: results, - messages: messages, - toolResultMessage: prepared.ToolResultMessage, - } - } -} - -func summarizeToolResults(results []domaintools.Result) []string { - out := make([]string, 0, len(results)) - for _, r := range results { - rows := -1 - if rawRows, ok := r.Content["rows"]; ok { - if list, ok := rawRows.([]map[string]any); ok { - rows = len(list) - } else if listAny, ok := rawRows.([]any); ok { - rows = len(listAny) - } - } - if rows >= 0 { - out = append(out, fmt.Sprintf("tool_use_id=%s is_error=%t rows=%d", r.ToolUseID, r.IsError(), rows)) - continue - } - out = append(out, fmt.Sprintf("tool_use_id=%s is_error=%t", r.ToolUseID, r.IsError())) - } - return out -} - -func summarizeHistory(messages []domain.Message) []string { - out := make([]string, 0, len(messages)) - for _, msg := range messages { - blockKinds := make([]string, 0, len(msg.Content)) - for _, b := range msg.Content { - blockKinds = append(blockKinds, string(b.Type)) - } - out = append(out, fmt.Sprintf( - "id=%s role=%s stop_reason=%s blocks=%d kinds=%s", - msg.ID, - msg.Role, - msg.StopReason, - len(msg.Content), - strings.Join(blockKinds, ","), - )) - } - return out -} - -// nextTurnReady is an internal message to create the next turn after persistence. -type nextTurnReady struct { - roundID domain.MessageID - messageID domain.MessageID - results []domaintools.Result - messages []domain.Message - toolResultMessage domain.Message -} - -// handleNextTurnReady creates and starts the next turn. -func (m *Model) handleNextTurnReady(msg nextTurnReady) tea.Cmd { - // Create input with tool results (empty text) - input := msgs.UserSubmittedInput{ - ToolResults: msg.results, - } - - nextTurn := turn.New( - m.theme, - m.conversationID, - m.accountID, - msg.messageID, - input, - m.width, - m.streamRunner, - m.streamErrorMapper, - m.assistantPersister, - m.effectCtx, - m.toolRegistry, - m.scope, - ) - - m.turns = append(m.turns, nextTurn) - startStream := nextTurn.StartStream(msg.messages, nil) - notifyPersist := func() tea.Msg { - return msgs.ToolResultMessagePersisted{Message: msg.toolResultMessage} - } - return tea.Batch(startStream, notifyPersist) -} diff --git a/internal/app/chat/messagelist/round/round_model.go b/internal/app/chat/messagelist/round/round_model.go deleted file mode 100644 index 2d0bb243..00000000 --- a/internal/app/chat/messagelist/round/round_model.go +++ /dev/null @@ -1,97 +0,0 @@ -package round - -import ( - "time" - - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" - "github.com/usetero/cli/internal/domain" -) - -// Blocks returns all visual blocks from all turns in this round. -// The thinking animation is appended at the end while the round is active. -func (m *Model) Blocks() []block.Block { - var result []block.Block - for _, t := range m.turns { - result = append(result, t.Blocks()...) - } - if m.IsActive() { - result = append(result, blocks.NewThinkingAnimBlock(m.thinking)) - } - return result -} - -// SetWidth sets the width for all turns. -func (m *Model) SetWidth(width int) { - m.width = width - for _, t := range m.turns { - t.SetWidth(width) - } -} - -// Cancel stops all in-flight turns and marks the round cancelled. -func (m *Model) Cancel() { - for _, t := range m.turns { - t.Cancel() - } - m.state = StateCancelled - m.endTime = time.Now() - m.scope.Info("round cancelled") -} - -// State returns the round's current state. -func (m *Model) State() State { - return m.state -} - -// ID returns the round's ID (first user message ID). -func (m *Model) ID() domain.MessageID { - return m.id -} - -// Err returns the error that caused the round to fail, or nil. -func (m *Model) Err() error { - return m.lastErr -} - -// HasAssistantContent returns true if any turn has assistant blocks. -func (m *Model) HasAssistantContent() bool { - for _, t := range m.turns { - if len(t.Blocks()) > 1 { // more than just the user message block - return true - } - } - return false -} - -// LastTurnMessageIDs returns the message IDs that should be deleted on failure. -// For turn 1: the user message ID. -// For turn 2+: the tool result message ID (current turn) + the previous turn's assistant message ID. -func (m *Model) LastTurnMessageIDs() []domain.MessageID { - if len(m.turns) == 0 { - return nil - } - - last := m.turns[len(m.turns)-1] - - if len(m.turns) == 1 { - // Turn 1: just the user message - return []domain.MessageID{last.UserMessageID()} - } - - // Turn 2+: tool result message + previous assistant message - prev := m.turns[len(m.turns)-2] - ids := []domain.MessageID{last.UserMessageID()} - if aid := prev.AssistantMessageID(); aid != "" { - ids = append(ids, aid) - } - return ids -} - -// Duration returns the elapsed time for this round. -func (m *Model) Duration() time.Duration { - if m.endTime.IsZero() { - return time.Since(m.startTime) - } - return m.endTime.Sub(m.startTime) -} diff --git a/internal/app/chat/messagelist/round/round_reducer.go b/internal/app/chat/messagelist/round/round_reducer.go deleted file mode 100644 index 7746c381..00000000 --- a/internal/app/chat/messagelist/round/round_reducer.go +++ /dev/null @@ -1,38 +0,0 @@ -package round - -func reduceOnStreamCompleted(current State, ownsTurn bool, stopReason string) (State, bool) { - if !ownsTurn { - return current, false - } - if stopReason == "tool_use" { - return current, false - } - if current == StateComplete { - return current, false - } - return StateComplete, true -} - -func reduceOnStreamFailed(current State, ownsTurn bool) (State, bool) { - if !ownsTurn { - return current, false - } - if current == StateFailed { - return current, false - } - return StateFailed, true -} - -func reduceOnToolResultsReady(current State, ownsTurn bool) (State, bool) { - if !ownsTurn || current != StateActive { - return current, false - } - return StateAwaitingNextTurn, true -} - -func reduceOnNextTurnReady(current State, roundMatches bool) (State, bool) { - if !roundMatches || current != StateAwaitingNextTurn { - return current, false - } - return StateActive, true -} diff --git a/internal/app/chat/messagelist/round/round_reducer_test.go b/internal/app/chat/messagelist/round/round_reducer_test.go deleted file mode 100644 index 86021479..00000000 --- a/internal/app/chat/messagelist/round/round_reducer_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package round - -import "testing" - -func TestRoundReducer(t *testing.T) { - t.Parallel() - - t.Run("stream completed tool_use stays active", func(t *testing.T) { - next, changed := reduceOnStreamCompleted(StateActive, true, "tool_use") - if next != StateActive || changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("stream completed end_turn completes", func(t *testing.T) { - next, changed := reduceOnStreamCompleted(StateActive, true, "end_turn") - if next != StateComplete || !changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("tool results ready only from active", func(t *testing.T) { - next, changed := reduceOnToolResultsReady(StateAwaitingNextTurn, true) - if next != StateAwaitingNextTurn || changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) - - t.Run("next turn ready only from awaiting", func(t *testing.T) { - next, changed := reduceOnNextTurnReady(StateAwaitingNextTurn, true) - if next != StateActive || !changed { - t.Fatalf("next=%v changed=%v", next, changed) - } - }) -} diff --git a/internal/app/chat/messagelist/round/round_test.go b/internal/app/chat/messagelist/round/round_test.go deleted file mode 100644 index e589020e..00000000 --- a/internal/app/chat/messagelist/round/round_test.go +++ /dev/null @@ -1,430 +0,0 @@ -package round - -import ( - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestRound(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - input := msgs.UserSubmittedInput{Text: "hello"} - return New(theme, "conv-1", "acct-1", "user-1", input, 80, usecase.RuntimeDeps{}, nil, scope) -} - -func hasBlockKind(blocks []block.Block, kind block.Kind) bool { - for _, b := range blocks { - if b.Kind() == kind { - return true - } - } - return false -} - -func TestNew(t *testing.T) { - t.Parallel() - - m := newTestRound(t) - - if m.State() != StateActive { - t.Errorf("expected StateActive, got %d", m.State()) - } - if m.ID() != "user-1" { - t.Errorf("expected ID user-1, got %s", m.ID()) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn, got %d", len(m.turns)) - } -} - -func TestBlocks(t *testing.T) { - t.Parallel() - - t.Run("includes thinking animation while active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - blocks := m.Blocks() - if !hasBlockKind(blocks, block.KindThinkingAnimation) { - t.Error("expected thinking animation block while active") - } - }) - - t.Run("excludes thinking animation when complete", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateComplete { - t.Fatalf("expected StateComplete, got %d", m.State()) - } - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after completion") - } - }) - - t.Run("excludes thinking animation when cancelled", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.State() != StateCancelled { - t.Fatalf("expected StateCancelled, got %d", m.State()) - } - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after cancel") - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets state to cancelled", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.State()) - } - }) - - t.Run("sets end time", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - if m.endTime.IsZero() { - t.Error("expected endTime to be set after cancel") - } - }) - - t.Run("propagates to turns", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - for i, turn := range m.turns { - if turn.State() != 3 { // turn.StateComplete - t.Errorf("turn %d: expected StateComplete, got %d", i, turn.State()) - } - } - }) -} - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("StreamCompleted with end_turn completes round", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateComplete { - t.Errorf("expected StateComplete, got %d", m.State()) - } - }) - - t.Run("StreamCompleted with tool_use stays active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "tool_use"}, - }) - - if m.State() != StateActive { - t.Errorf("expected StateActive after tool_use, got %d", m.State()) - } - }) - - t.Run("ignores messages for unknown turns", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamCompleted{ - TurnID: "unknown-turn", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateActive { - t.Errorf("expected StateActive (unchanged), got %d", m.State()) - } - }) - - t.Run("skips forwarding to turns after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - - // StreamCompleted for our turn should not change state back - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - // State should still be cancelled, not complete - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled (unchanged), got %d", m.State()) - } - }) -} - -func TestToolResultsReadyDoesNotDoubleFire(t *testing.T) { - t.Parallel() - - t.Run("transitions to StateAwaitingNextTurn", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - - if m.state != StateAwaitingNextTurn { - t.Fatalf("expected StateAwaitingNextTurn, got %d", m.state) - } - }) - - t.Run("second ToolResultsReady is ignored", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - m.Update(msgs.ToolResultsReady{ - TurnID: "user-1", - Results: []tools.Result{{ToolUseID: "tool-1"}}, - }) - - if m.state != StateAwaitingNextTurn { - t.Fatalf("expected StateAwaitingNextTurn (unchanged), got %d", m.state) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn (no duplicate), got %d", len(m.turns)) - } - }) -} - -func TestStateAwaitingNextTurn(t *testing.T) { - t.Parallel() - - t.Run("IsActive returns true", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - if !m.IsActive() { - t.Error("expected IsActive() true for StateAwaitingNextTurn") - } - }) - - t.Run("shows thinking animation", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - if !hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("expected thinking animation in StateAwaitingNextTurn") - } - }) - - t.Run("cancel works from awaiting state", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - m.Cancel() - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.State()) - } - }) - - t.Run("nextTurnReady ignored after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - m.state = StateAwaitingNextTurn - - m.Cancel() - - m.Update(nextTurnReady{ - roundID: "user-1", - messageID: "tool-result-1", - messages: []domain.Message{}, - }) - - if m.State() != StateCancelled { - t.Errorf("expected StateCancelled (unchanged), got %d", m.State()) - } - if len(m.turns) != 1 { - t.Errorf("expected 1 turn (nextTurnReady ignored), got %d", len(m.turns)) - } - }) -} - -func TestStreamFailed(t *testing.T) { - t.Parallel() - - t.Run("transitions to StateFailed", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("connection lost")}) - - if m.State() != StateFailed { - t.Errorf("expected StateFailed, got %d", m.State()) - } - }) - - t.Run("stores the error", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - err := errors.New("connection lost") - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: err}) - - if !errors.Is(m.Err(), err) { - t.Errorf("expected stored error %v, got %v", err, m.Err()) - } - }) - - t.Run("excludes thinking animation", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("fail")}) - - if hasBlockKind(m.Blocks(), block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after failure") - } - }) - - t.Run("ignores subsequent messages", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Update(msgs.StreamFailed{TurnID: "user-1", Err: errors.New("fail")}) - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - if m.State() != StateFailed { - t.Errorf("expected StateFailed (unchanged), got %d", m.State()) - } - }) -} - -func TestHasAssistantContent(t *testing.T) { - t.Parallel() - - t.Run("false for fresh round", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - if m.HasAssistantContent() { - t.Error("expected false for fresh round with no assistant content") - } - }) -} - -func TestLastTurnMessageIDs(t *testing.T) { - t.Parallel() - - t.Run("returns user message ID for turn 1", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - ids := m.LastTurnMessageIDs() - - if len(ids) != 1 { - t.Fatalf("expected 1 ID, got %d", len(ids)) - } - if ids[0] != "user-1" { - t.Errorf("expected user-1, got %s", ids[0]) - } - }) -} - -func TestDuration(t *testing.T) { - t.Parallel() - - t.Run("returns positive duration while active", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - if m.Duration() <= 0 { - t.Error("expected positive duration while active") - } - }) - - t.Run("returns fixed duration after cancel", func(t *testing.T) { - t.Parallel() - m := newTestRound(t) - - m.Cancel() - d1 := m.Duration() - d2 := m.Duration() - - if d1 != d2 { - t.Error("expected fixed duration after cancel") - } - }) -} - -func TestSummarizeToolResults(t *testing.T) { - t.Parallel() - - results := []tools.Result{ - { - ToolUseID: "tool-1", - Content: map[string]any{ - "rows": []map[string]any{{"service_id": "ad"}, {"service_id": "email"}}, - }, - }, - { - ToolUseID: "tool-2", - Error: &tools.ErrorResult{Message: "boom"}, - }, - } - - summaries := summarizeToolResults(results) - if len(summaries) != 2 { - t.Fatalf("len(summaries)=%d, want 2", len(summaries)) - } - if got := summaries[0]; got != "tool_use_id=tool-1 is_error=false rows=2" { - t.Fatalf("summaries[0]=%q", got) - } - if got := summaries[1]; got != "tool_use_id=tool-2 is_error=true" { - t.Fatalf("summaries[1]=%q", got) - } -} diff --git a/internal/app/chat/messagelist/round/round_update.go b/internal/app/chat/messagelist/round/round_update.go deleted file mode 100644 index d803f780..00000000 --- a/internal/app/chat/messagelist/round/round_update.go +++ /dev/null @@ -1,85 +0,0 @@ -package round - -import ( - "time" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - // Terminal states — no state transitions, no forwarding. - if m.state == StateCancelled || m.state == StateFailed { - return nil - } - - var cmds []tea.Cmd - - switch msg := msg.(type) { - case msgs.StreamCompleted: - next, changed := reduceOnStreamCompleted(m.state, m.isOurTurn(msg.TurnID), msg.Message.StopReason) - if changed { - if m.session != nil { - m.session.RecordAssistantMessage(msg.Message) - } - m.state = next - m.endTime = time.Now() - m.scope.Info("round complete", "stop_reason", msg.Message.StopReason) - } else if m.isOurTurn(msg.TurnID) && m.session != nil { - // tool_use path still records assistant for next-turn history - m.session.RecordAssistantMessage(msg.Message) - } - - case msgs.StreamFailed: - next, changed := reduceOnStreamFailed(m.state, m.isOurTurn(msg.TurnID)) - if changed { - m.state = next - m.lastErr = msg.Err - m.endTime = time.Now() - m.scope.Info("round failed", "error", msg.Err) - } - - case msgs.ToolResultsReady: - next, changed := reduceOnToolResultsReady(m.state, m.isOurTurn(msg.TurnID)) - if changed { - m.state = next - cmds = append(cmds, m.startNextTurn(msg.Results)) - } - - case nextTurnReady: - next, changed := reduceOnNextTurnReady(m.state, msg.roundID == m.id) - if changed { - m.state = next - cmds = append(cmds, m.handleNextTurnReady(msg)) - } - } - - // Forward thinking ticks while active - if m.IsActive() { - cmds = append(cmds, m.thinking.Update(msg)) - } - - // Forward to all turns - for _, t := range m.turns { - cmds = append(cmds, t.Update(msg)) - } - - return tea.Batch(cmds...) -} - -// isOurTurn checks if the given turn ID belongs to this round. -func (m *Model) isOurTurn(turnID domain.MessageID) bool { - for _, t := range m.turns { - if t.UserMessageID() == turnID { - return true - } - } - return false -} - -// HasTurn reports whether this round owns turnID. -func (m *Model) HasTurn(turnID domain.MessageID) bool { - return m.isOurTurn(turnID) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go deleted file mode 100644 index bd34e4d5..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ /dev/null @@ -1,167 +0,0 @@ -package assistant - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - chattools "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" -) - -// Model renders an assistant message and manages its content blocks. -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - blockTheme styles.Theme // theme with elevated bg for blocks - scope log.Scope - id domain.MessageID - turnID domain.MessageID - blocks []blocks.Block - width int - toolRegistry *chattools.Registry -} - -// New creates a new assistant message view. -func New(theme styles.Theme, turnID, id domain.MessageID, width int, toolRegistry *chattools.Registry, scope log.Scope) *Model { - scope = scope.Child("assistant") - return &Model{ - theme: theme, - blockTheme: theme.WithBg(theme.BgElevated), - scope: scope, - id: id, - turnID: turnID, - width: width, - toolRegistry: toolRegistry, - } -} - -// Update handles messages. Turn filters by TurnID before forwarding. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - cmds = append(cmds, m.ensureBlocks(msg.Message.Content)) - case msgs.StreamCompleted: - cmds = append(cmds, m.ensureBlocks(msg.Message.Content)) - } - - for _, b := range m.blocks { - cmds = append(cmds, b.Update(msg)) - } - - return tea.Batch(cmds...) -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // Blocks get the width inside the border; they handle their own internal padding. - contentWidth := width - block.BorderWidth - for _, b := range m.blocks { - b.SetWidth(contentWidth) - } -} - -// AddBlock adds a block directly (for testing). -func (m *Model) AddBlock(b blocks.Block) { - m.blocks = append(m.blocks, b) -} - -// SetContent populates blocks from content. Used for initial population to avoid empty render. -func (m *Model) SetContent(content []domain.Block) { - m.ensureBlocks(content) -} - -// ID returns the message ID. -func (m *Model) ID() domain.MessageID { - return m.id -} - -// SetID sets the message ID. -func (m *Model) SetID(id domain.MessageID) { - m.id = id -} - -// Cancel stops all in-progress tool animations. -func (m *Model) Cancel() { - for _, b := range m.blocks { - if t, ok := b.(*tools.Model); ok { - t.Cancel() - } - } -} - -// Blocks returns all visual blocks for the viewport. -func (m *Model) Blocks() []block.Block { - var result []block.Block - for _, b := range m.blocks { - result = append(result, b) - } - return result -} - -// ensureBlocks creates block models as needed. Blocks handle their own updates via messages. -// Returns a command to initialize any new tool animations. -func (m *Model) ensureBlocks(content []domain.Block) tea.Cmd { - var cmds []tea.Cmd - contentWidth := m.width - block.BorderWidth - for _, b := range content { - if m.hasBlock(b.Index) { - continue // Block exists, handles its own updates - } - - // Create new block with content width (minus padding) - switch b.Type { - case domain.BlockTypeText: - if b.Text != nil { - m.blocks = append(m.blocks, blocks.NewTextBlock(m.theme, b.Index, b.Text.Content, contentWidth)) - } - case domain.BlockTypeThinking: - if b.Thinking != nil { - m.blocks = append(m.blocks, blocks.NewThinkingBlock(m.theme, b.Index, b.Thinking.Content, contentWidth)) - } - case domain.BlockTypeToolUse: - if b.ToolUse != nil { - tool := m.newToolBlock(b.Index, b.ToolUse, contentWidth) - m.blocks = append(m.blocks, tool) - cmds = append(cmds, tool.Init()) - } - case domain.BlockTypeToolResult: - // Tool results are handled separately, not rendered as blocks - } - } - return tea.Batch(cmds...) -} - -// hasBlock checks if a block with the given index already exists. -func (m *Model) hasBlock(index int) bool { - for _, b := range m.blocks { - if b.Index() == index { - return true - } - } - return false -} - -// newToolBlock creates the appropriate tool model wrapped in chrome. -func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *tools.Model { - if m.toolRegistry == nil { - entry := chattools.UnknownTool(toolUse.Name) - child := action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) - return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) - } - - entry, ok := m.toolRegistry.Lookup(toolUse.Name) - if !ok { - m.scope.Warn("unknown tool, using generic action", "name", toolUse.Name) - entry = chattools.UnknownTool(toolUse.Name) - } - child := action.New(index, m.turnID, toolUse.ID, width, entry.Config, entry.Exec, m.scope) - return tools.New(m.blockTheme, index, m.turnID, toolUse.ID, width, child) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go b/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go deleted file mode 100644 index f3d30db2..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package assistant - -import ( - "encoding/json" - "strings" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -// actionToolBlock builds a generic action tool block for rendering tests. -func actionToolBlock(t *testing.T, width int, displayName string) *tools.Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - cfg := action.Config{ - DisplayName: func(json.RawMessage) string { return displayName }, - Status: func(json.RawMessage) string { return "Running" }, - Result: func(domaintools.Result) string { return "Done" }, - } - exec := func(json.RawMessage) (domaintools.Result, error) { return domaintools.Result{}, nil } - child := action.New(0, "turn-1", "tool-1", width, cfg, exec, scope) - return tools.New(theme, 0, "turn-1", "tool-1", width, child) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("cancels tool blocks", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - - // Add a tool block in pending state. - tool := actionToolBlock(t, 78, "List Services") - m.AddBlock(tool) - - m.Cancel() - - // Tool should render as cancelled — padding + icon/name + padding (3 lines). - view := tool.View() - if lines := strings.Count(view, "\n"); lines != 2 { - t.Errorf("expected 3-line render for cancelled tool, got %d lines", lines+1) - } - if !strings.Contains(view, "List Services") { - t.Error("expected tool name in cancelled view") - } - }) - - t.Run("no-op with no tool blocks", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - m.Cancel() // should not panic - }) -} - -func TestNilRegistryToolUseDoesNotPanic(t *testing.T) { - t.Parallel() - - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - m := New(theme, "turn-1", "test-msg", 80, nil, scope) - - cmd := m.Update(msgs.AssistantContentUpdated{ - TurnID: "turn-1", - Message: domain.Message{ - Content: []domain.Block{ - { - Index: 0, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "unknown_tool", - Input: []byte(`{}`), - InputComplete: true, - }, - }, - }, - }, - }) - - if cmd == nil { - t.Fatal("expected non-nil cmd to initialize tool block") - } - if len(m.Blocks()) != 1 { - t.Fatalf("expected 1 block, got %d", len(m.Blocks())) - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go deleted file mode 100644 index 10e2d19d..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/block.go +++ /dev/null @@ -1,12 +0,0 @@ -package blocks - -import "github.com/usetero/cli/internal/app/chat/messagelist/block" - -// Block is the interface for all content blocks in an assistant message. -// Embeds block.Block for viewport integration and adds Index for content tracking. -type Block interface { - block.Block - - // Index returns the block's position in the content array. - Index() int -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go deleted file mode 100644 index 6f2db94c..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/text.go +++ /dev/null @@ -1,123 +0,0 @@ -package blocks - -import ( - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// TextBlock renders a text content block. -// It is a fixed-height component - height is determined by content. -// Implements block.Block. -type TextBlock struct { - theme styles.Theme - index int - text string - width int - rendered string // cached rendered output - focused bool -} - -// NewTextBlock creates a new text block with the given content. -func NewTextBlock(theme styles.Theme, index int, text string, width int) *TextBlock { - b := &TextBlock{ - theme: theme, - index: index, - text: text, - width: width, - } - b.render() - return b -} - -// Update handles messages. -func (m *TextBlock) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - m.updateFromContent(msg.Message.Content) - case msgs.StreamCompleted: - m.updateFromContent(msg.Message.Content) - } - return nil -} - -// updateFromContent finds this block's content by index and updates. -func (m *TextBlock) updateFromContent(content []domain.Block) { - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeText && b.Text != nil { - m.SetText(b.Text.Content) - return - } - } -} - -// Index returns the block index. -func (m *TextBlock) Index() int { - return m.index -} - -// View renders the text block. -func (m *TextBlock) View() string { - return m.rendered -} - -// Height returns the number of lines this block renders. -func (m *TextBlock) Height() int { - if m.rendered == "" { - return 0 - } - return lipgloss.Height(m.rendered) -} - -// SetText sets the text and re-renders. -func (m *TextBlock) SetText(text string) { - if m.text == text { - return - } - m.text = text - m.render() -} - -// SetWidth sets the width and re-renders. -func (m *TextBlock) SetWidth(width int) { - if m.width == width { - return - } - m.width = width - m.render() -} - -// Kind implements block.Block. -func (m *TextBlock) Kind() block.Kind { - return block.KindAssistantText -} - -// SetFocused implements block.Block. -func (m *TextBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *TextBlock) Focused() bool { - return m.focused -} - -func (m *TextBlock) render() { - if m.text == "" { - m.rendered = "" - return - } - rendered := styles.RenderMarkdown(m.theme, m.text, m.width-block.PaddingX*2) - rendered = strings.TrimRight(rendered, "\n") - m.rendered = lipgloss.NewStyle(). - Background(m.theme.Bg). - Foreground(m.theme.Text). - Padding(block.PaddingY, block.PaddingX). - Width(m.width). - Render(rendered) -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go deleted file mode 100644 index 59f556b6..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking.go +++ /dev/null @@ -1,152 +0,0 @@ -package blocks - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// Body padding matches the tool block. -const ( - thinkingBodyPaddingLeft = 2 - thinkingBodyPaddingRight = 1 - thinkingBodyPaddingH = thinkingBodyPaddingLeft + thinkingBodyPaddingRight -) - -// ThinkingBlock renders a thinking content block. -// Can be expanded or collapsed. It is a fixed-height component. -// Implements block.Block. -type ThinkingBlock struct { - theme styles.Theme - index int - text string - expanded bool - width int - focused bool -} - -// NewThinkingBlock creates a new thinking block with the given content. -func NewThinkingBlock(theme styles.Theme, index int, text string, width int) *ThinkingBlock { - return &ThinkingBlock{ - theme: theme, - index: index, - text: text, - expanded: false, - width: width, - } -} - -// Update handles messages. -func (m *ThinkingBlock) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - m.updateFromContent(msg.Message.Content) - case msgs.StreamCompleted: - m.updateFromContent(msg.Message.Content) - } - return nil -} - -// updateFromContent finds this block's content by index and updates. -func (m *ThinkingBlock) updateFromContent(content []domain.Block) { - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeThinking && b.Thinking != nil { - m.SetText(b.Thinking.Content) - return - } - } -} - -// Index returns the block index. -func (m *ThinkingBlock) Index() int { - return m.index -} - -// View renders the thinking block. -func (m *ThinkingBlock) View() string { - colors := m.theme - mutedStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - nameStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg) - - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - - header := fmt.Sprintf("%s %s", chevron, nameStyle.Render("Thinking")) - - var content string - if !m.expanded { - content = header - } else { - // Render body with markdown styling, wrapped to available width - bodyWidth := m.width - thinkingBodyPaddingH - if bodyWidth < 1 { - bodyWidth = 1 - } - rendered := styles.RenderMarkdown(m.theme, m.text, bodyWidth) - rendered = strings.TrimRight(rendered, "\n") - - body := lipgloss.NewStyle(). - Padding(1, thinkingBodyPaddingRight, 1, thinkingBodyPaddingLeft). - Render(rendered) - - content = header + "\n\n" + body - } - - return lipgloss.NewStyle(). - Background(colors.Bg). - Padding(0, block.PaddingX). - Width(m.width). - Render(content) -} - -// Height returns the number of lines this block renders. -func (m *ThinkingBlock) Height() int { - return lipgloss.Height(m.View()) -} - -// SetText sets the text. -func (m *ThinkingBlock) SetText(text string) { - m.text = text -} - -// SetWidth sets the width. -func (m *ThinkingBlock) SetWidth(width int) { - m.width = width -} - -// SetExpanded sets the expanded state. -func (m *ThinkingBlock) SetExpanded(expanded bool) { - m.expanded = expanded -} - -// Toggle toggles the expanded state. -// Only toggles when clicking the header line (y == 0, no top padding). -func (m *ThinkingBlock) Toggle(y int) { - if m.expanded && y != 0 { - return - } - m.expanded = !m.expanded -} - -// Kind implements block.Block. -func (m *ThinkingBlock) Kind() block.Kind { - return block.KindThinking -} - -// SetFocused implements block.Block. -func (m *ThinkingBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *ThinkingBlock) Focused() bool { - return m.focused -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go deleted file mode 100644 index c0c42b58..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinking_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package blocks - -import ( - "strings" - "testing" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/usetero/cli/internal/styles" -) - -func newTestThinking(t *testing.T, text string) *ThinkingBlock { - t.Helper() - theme := styles.NewTheme(true) - return NewThinkingBlock(theme, 0, text, 80) -} - -func TestThinkingCollapsedByDefault(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "some internal reasoning") - view := m.View() - plain := ansi.Strip(view) - - if strings.Contains(plain, "reasoning") { - t.Error("expected collapsed view to hide content") - } - if !strings.Contains(plain, "▶") { - t.Error("expected collapsed chevron") - } - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label") - } -} - -func TestThinkingExpanded(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "some internal reasoning") - m.Toggle(0) - view := m.View() - plain := ansi.Strip(view) - - if !strings.Contains(plain, "reasoning") { - t.Errorf("expected expanded view to show content, got:\n%s", plain) - } - if !strings.Contains(plain, "▼") { - t.Error("expected expanded chevron") - } - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label") - } -} - -func TestThinkingToggle(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "reasoning text here") - - collapsedView := m.View() - m.Toggle(0) - expandedView := m.View() - - collapsedLines := lipgloss.Height(collapsedView) - expandedLines := lipgloss.Height(expandedView) - if collapsedLines >= expandedLines { - t.Errorf("collapsed (%d lines) should be shorter than expanded (%d lines)", collapsedLines, expandedLines) - } - - // Toggle back to collapsed - m.Toggle(0) - plain := ansi.Strip(m.View()) - if strings.Contains(plain, "reasoning") { - t.Error("expected content hidden after toggling back") - } -} - -func TestThinkingEmptyText(t *testing.T) { - t.Parallel() - m := newTestThinking(t, "") - m.Toggle(0) - plain := ansi.Strip(m.View()) - - // Should still render header even with empty text - if !strings.Contains(plain, "Thinking") { - t.Error("expected Thinking label even with empty text") - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go deleted file mode 100644 index 915e6054..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/thinkinganim.go +++ /dev/null @@ -1,55 +0,0 @@ -package blocks - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// ThinkingAnimBlock wraps a thinking animation as a block. -// This is the streaming indicator shown while the assistant is generating. -type ThinkingAnimBlock struct { - thinking *thinking.Model - focused bool -} - -// NewThinkingAnimBlock creates a new thinking animation block. -func NewThinkingAnimBlock(t *thinking.Model) *ThinkingAnimBlock { - return &ThinkingAnimBlock{thinking: t} -} - -// View implements block.Block. -func (m *ThinkingAnimBlock) View() string { - return lipgloss.NewStyle(). - Padding(0, block.PaddingX). - Render(m.thinking.View()) -} - -// Height implements block.Block. -func (m *ThinkingAnimBlock) Height() int { - return lipgloss.Height(m.View()) -} - -// Update implements block.Block. -func (m *ThinkingAnimBlock) Update(msg tea.Msg) tea.Cmd { - return m.thinking.Update(msg) -} - -// SetWidth implements block.Block. -func (m *ThinkingAnimBlock) SetWidth(_ int) {} - -// SetFocused implements block.Block. -func (m *ThinkingAnimBlock) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *ThinkingAnimBlock) Focused() bool { - return m.focused -} - -// Kind implements block.Block. -func (m *ThinkingAnimBlock) Kind() block.Kind { - return block.KindThinkingAnimation -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go deleted file mode 100644 index b2ee8810..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action.go +++ /dev/null @@ -1,165 +0,0 @@ -// Package action provides a generic tool UI model for simple tools -// that accumulate input, execute, and show status — with no custom body. -package action - -import ( - "encoding/json" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log" -) - -// Executor runs a tool and returns a result. -type Executor func(input json.RawMessage) (domaintools.Result, error) - -// Config provides display strings for the chrome wrapper. -type Config struct { - DisplayName func(input json.RawMessage) string - Status func(input json.RawMessage) string - Result func(result domaintools.Result) string -} - -// Model is a generic tool UI model implementing tools.Child. -type Model struct { - scope log.Scope - index int - turnID domain.MessageID - toolID string - state tools.State - config Config - executor Executor - width int - - input json.RawMessage - result domaintools.Result - err error -} - -type actionExecutionCompletedMsg struct { - toolID string - result domaintools.Result - err error -} - -// New creates a new generic action tool model. -func New(index int, turnID domain.MessageID, toolID string, width int, config Config, executor Executor, scope log.Scope) *Model { - return &Model{ - scope: scope, - index: index, - turnID: turnID, - toolID: toolID, - state: tools.StateAccumulating, - config: config, - executor: executor, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - switch msg := msg.(type) { - case msgs.AssistantContentUpdated: - return m.handleContent(msg.Message.Content) - case msgs.StreamCompleted: - return m.handleContent(msg.Message.Content) - case actionExecutionCompletedMsg: - if msg.toolID != m.toolID { - return nil - } - if msg.err != nil { - m.err = msg.err - m.state = tools.StateComplete - m.scope.Error("action failed", "name", m.config.DisplayName(m.input), "error", msg.err) - return m.fireCompleted() - } - - m.result = msg.result - m.state = tools.StateComplete - m.scope.Info("action completed", "name", m.config.DisplayName(m.input)) - return m.fireCompleted() - } - return nil -} - -func (m *Model) handleContent(content []domain.Block) tea.Cmd { - if m.state != tools.StateAccumulating { - return nil - } - - for _, b := range content { - if b.Index == m.index && b.Type == domain.BlockTypeToolUse && b.ToolUse != nil { - m.input = b.ToolUse.Input - if b.ToolUse.InputComplete { - return m.execute() - } - return nil - } - } - return nil -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - m.scope.Info("executing action", "name", m.config.DisplayName(m.input), "input", string(m.input)) - input := append(json.RawMessage(nil), m.input...) - executor := m.executor - return func() tea.Msg { - result, err := executor(input) - return actionExecutionCompletedMsg{toolID: m.toolID, result: result, err: err} - } -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - return msgs.ToolCompleted{ - TurnID: m.turnID, - ToolUseID: m.toolID, - Result: m.result, - Error: m.err, - } - } -} - -// Name returns the display name. -func (m *Model) Name() string { - return m.config.DisplayName(m.input) -} - -// Status returns the status message shown while executing. -func (m *Model) Status() string { - return m.config.Status(m.input) -} - -// Result returns the result message shown when complete. -func (m *Model) Result() string { - return m.config.Result(m.result) -} - -// View returns empty — simple tools have no body content. -func (m *Model) View() string { - return "" -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -// State returns the current state. -func (m *Model) State() tools.State { - return m.state -} - -// ToolID returns the tool use ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// Err returns any error from execution. -func (m *Model) Err() error { - return m.err -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go deleted file mode 100644 index 10be2fd4..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action/action_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package action - -import ( - "encoding/json" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" -) - -func testConfig() Config { - return Config{ - DisplayName: func(_ json.RawMessage) string { return "Test Tool" }, - Status: func(_ json.RawMessage) string { return "Running test" }, - Result: func(_ domaintools.Result) string { return "Done" }, - } -} - -func TestUpdate(t *testing.T) { - t.Parallel() - - t.Run("accumulates input from content blocks", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - - m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"key":"val`), - InputComplete: false, - }}, - }, - }, - }) - - if m.state != tools.StateAccumulating { - t.Errorf("expected StateAccumulating, got %d", m.state) - } - }) - - t.Run("ignores blocks with wrong index", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - - m.Update(msgs.AssistantContentUpdated{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 5, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{"key":"value"}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateAccumulating { - t.Errorf("expected StateAccumulating (wrong index ignored), got %d", m.state) - } - }) - - t.Run("executes on InputComplete and fires ToolCompleted", func(t *testing.T) { - t.Parallel() - - executor := func(input json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{Content: map[string]any{"ok": true}}, nil - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateExecuting { - t.Fatalf("expected StateExecuting, got %d", m.state) - } - if cmd == nil { - t.Fatal("expected execution cmd") - } - - completeCmd := m.Update(cmd()) - if m.state != tools.StateComplete { - t.Fatalf("expected StateComplete after execution, got %d", m.state) - } - if m.err != nil { - t.Fatalf("unexpected error: %v", m.err) - } - if completeCmd == nil { - t.Fatal("expected completion cmd") - } - - // Execute the command and check the message - msg := completeCmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected msgs.ToolCompleted, got %T", msg) - } - if completed.ToolUseID != "tool-1" { - t.Errorf("ToolUseID = %q, want %q", completed.ToolUseID, "tool-1") - } - if completed.TurnID != "turn-1" { - t.Errorf("TurnID = %q, want %q", completed.TurnID, "turn-1") - } - if completed.Error != nil { - t.Errorf("unexpected error in completed: %v", completed.Error) - } - }) - - t.Run("fires ToolCompleted with error on failure", func(t *testing.T) { - t.Parallel() - - executor := func(input json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{}, errors.New("exec failed") - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - cmd := m.Update(msgs.StreamCompleted{ - Message: domain.Message{ - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - }, - }, - }) - - if m.state != tools.StateExecuting { - t.Fatalf("expected StateExecuting, got %d", m.state) - } - if cmd == nil { - t.Fatal("expected execution cmd") - } - - completeCmd := m.Update(cmd()) - if m.state != tools.StateComplete { - t.Fatalf("expected StateComplete after execution, got %d", m.state) - } - if m.err == nil { - t.Fatal("expected error, got nil") - } - if completeCmd == nil { - t.Fatal("expected completion cmd") - } - - msg := completeCmd() - completed, ok := msg.(msgs.ToolCompleted) - if !ok { - t.Fatalf("expected msgs.ToolCompleted, got %T", msg) - } - if completed.Error == nil { - t.Error("expected error in completed message") - } - }) - - t.Run("does not re-execute after completion", func(t *testing.T) { - t.Parallel() - - callCount := 0 - executor := func(input json.RawMessage) (domaintools.Result, error) { - callCount++ - return domaintools.Result{Content: map[string]any{"ok": true}}, nil - } - - m := New(0, "turn-1", "tool-1", 80, testConfig(), executor, logtest.NewScope(t)) - - content := []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ - ID: "tool-1", - Input: json.RawMessage(`{}`), - InputComplete: true, - }}, - } - - cmd := m.Update(msgs.StreamCompleted{Message: domain.Message{Content: content}}) - if cmd == nil { - t.Fatal("expected execution cmd") - } - m.Update(cmd()) - m.Update(msgs.StreamCompleted{Message: domain.Message{Content: content}}) - - if callCount != 1 { - t.Errorf("executor called %d times, want 1", callCount) - } - }) - - t.Run("ignores execution completion from different tool instance", func(t *testing.T) { - t.Parallel() - - m := New(0, "turn-1", "tool-1", 80, testConfig(), nil, logtest.NewScope(t)) - foreign := actionExecutionCompletedMsg{ - toolID: "tool-2", - result: domaintools.Result{Content: map[string]any{"ok": true}}, - } - - cmd := m.Update(foreign) - if cmd != nil { - t.Fatal("expected nil cmd for foreign completion") - } - if m.state != tools.StateAccumulating { - t.Fatalf("state = %d, want StateAccumulating", m.state) - } - if m.result.Content != nil { - t.Fatal("result should remain untouched") - } - }) -} - -func TestConfigDelegation(t *testing.T) { - t.Parallel() - - config := Config{ - DisplayName: func(input json.RawMessage) string { - var m map[string]any - if err := json.Unmarshal(input, &m); err != nil { - return "Enable" - } - if m["action"] == "disable" { - return "Disable" - } - return "Enable" - }, - Status: func(_ json.RawMessage) string { return "Working" }, - Result: func(r domaintools.Result) string { - name, _ := r.Content["name"].(string) - return name + " done" - }, - } - - m := New(0, "turn-1", "tool-1", 80, config, nil, logtest.NewScope(t)) - m.input = json.RawMessage(`{"action":"disable"}`) - m.result = domaintools.Result{Content: map[string]any{"name": "svc"}} - - if got := m.Name(); got != "Disable" { - t.Errorf("Name() = %q, want %q", got, "Disable") - } - if got := m.Status(); got != "Working" { - t.Errorf("Status() = %q, want %q", got, "Working") - } - if got := m.Result(); got != "svc done" { - t.Errorf("Result() = %q, want %q", got, "svc done") - } -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go deleted file mode 100644 index 1532bad9..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool.go +++ /dev/null @@ -1,336 +0,0 @@ -package tools - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" - "github.com/usetero/cli/internal/tea/components/thinking" -) - -// State represents the current state of tool execution. -type State int - -const ( - StateAccumulating State = iota - StateExecuting - StateComplete -) - -// Status represents the outcome of a completed tool. -type Status int - -const ( - StatusPending Status = iota - StatusRunning - StatusSuccess - StatusError - StatusCancelled -) - -// Icons for different statuses. -const ( - IconPending = "●" - IconSuccess = "✓" - IconError = "×" - IconCancelled = "○" -) - -// Body padding: left=bodyPaddingLeft, right=bodyPaddingRight, top/bottom=1. -const ( - bodyPaddingLeft = 2 - bodyPaddingRight = 1 - bodyPaddingH = bodyPaddingLeft + bodyPaddingRight -) - -// Child is the interface that specific tool models must implement. -type Child interface { - Update(tea.Msg) tea.Cmd - View() string - SetWidth(int) - Name() string - Status() string // Message shown while executing (e.g., "Checking service status") - Result() string // Message shown when complete (e.g., "Found 14 services") - State() State - ToolID() string - Err() error -} - -// AutoExpander is an optional interface for tools that should show their body -// immediately on completion instead of starting collapsed. -type AutoExpander interface { - AutoExpand() bool -} - -// Model is the chrome wrapper for tool blocks. -// It handles icon rendering, name display, animation, and content indentation. -// The actual tool logic lives in the embedded child. -// It is a fixed-height component. Implements block.Block. -type Model struct { - theme styles.Theme - index int - turnID domain.MessageID - toolID string - width int - status Status - - child Child - thinking *thinking.Model - focused bool - expanded bool // whether the body content is visible (only applies to completed tools) - autoExpanded bool // true once auto-expand has fired, prevents re-expanding on every tick -} - -// New creates a new tool model wrapping the given child. -func New(theme styles.Theme, index int, turnID domain.MessageID, toolID string, width int, child Child) *Model { - // Child gets width minus outer padding and body padding - child.SetWidth(width - block.PaddingX*2 - bodyPaddingH) - return &Model{ - theme: theme, - index: index, - turnID: turnID, - toolID: toolID, - width: width, - status: StatusPending, - child: child, - thinking: thinking.New(theme, thinking.Settings{Size: 10}), - expanded: false, - } -} - -// Init starts the thinking animation. -func (m *Model) Init() tea.Cmd { - return m.thinking.Init() -} - -// Cancel stops the tool's thinking animation and marks it cancelled. -func (m *Model) Cancel() { - m.status = StatusCancelled -} - -// Update handles messages - updates status and forwards to child. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - // Only tick the thinking animation while still pending/running - if m.status == StatusPending || m.status == StatusRunning { - cmds = append(cmds, m.thinking.Update(msg)) - } - - // Update status based on child state changes - m.updateStatus() - - // Listen for completion messages to update status - if completed, ok := msg.(msgs.ToolCompleted); ok { - if completed.TurnID == m.turnID && completed.ToolUseID == m.toolID { - if completed.Error != nil { - m.status = StatusError - } else { - m.status = StatusSuccess - } - } - } - - // Forward to child - cmds = append(cmds, m.child.Update(msg)) - - return tea.Batch(cmds...) -} - -// updateStatus syncs status with child state. -func (m *Model) updateStatus() { - switch m.child.State() { - case StateAccumulating: - m.status = StatusPending - case StateExecuting: - m.status = StatusRunning - // Update thinking label to show status message with reveal animation - m.thinking.SetLabel(m.child.Status()) - case StateComplete: - if m.child.Err() != nil { - m.status = StatusError - } else { - m.status = StatusSuccess - if !m.autoExpanded { - if ae, ok := m.child.(AutoExpander); ok && ae.AutoExpand() { - m.expanded = true - m.autoExpanded = true - } - } - } - } -} - -// View renders the tool with chrome. -func (m *Model) View() string { - colors := m.theme - icon := m.renderIcon() - nameStyle := lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg) - mutedStyle := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - sp := mutedStyle.Render(" ") - - var content string - - switch m.status { - case StatusPending: - // ● Query ████████████ - content = icon + sp + nameStyle.Render(m.child.Name()) + sp + m.thinking.View() - - case StatusRunning: - // ● Query · Checking service status - // - // ████████████ - header := icon + sp + nameStyle.Render(m.child.Name()) - if status := m.child.Status(); status != "" { - header = icon + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+status) - } - body := lipgloss.NewStyle(). - PaddingLeft(bodyPaddingLeft). - Render(m.thinking.View()) - content = header + "\n\n" + body - - case StatusSuccess: - // ✓ ▶ Query · Found 14 services (collapsed) - // ✓ ▼ Query · Found 14 services (expanded) - // - // - result := m.child.Result() - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - header := icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) - if result != "" { - header = icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+result) - } - if !m.expanded || m.child.View() == "" { - content = header - } else { - body := m.bodyStyle().Render(m.child.View()) - content = header + "\n\n" + body - } - - case StatusError: - // × ▶ Query · Query rejected (collapsed) - // × ▼ Query · Query rejected (expanded) - // - // ERROR full table scan detected on a JOINed table... - result := m.child.Result() - chevron := mutedStyle.Render("▶") - if m.expanded { - chevron = mutedStyle.Render("▼") - } - header := icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) - if result != "" { - header = icon + sp + chevron + sp + nameStyle.Render(m.child.Name()) + sp + mutedStyle.Render("· "+result) - } - if !m.expanded { - content = header - } else { - errTag := lipgloss.NewStyle(). - Background(colors.Error). - Foreground(colors.OnError). - Padding(0, 1). - Render("ERROR") - errMsg := m.child.Err().Error() - body := m.bodyStyle().Render(errTag + sp + mutedStyle.Render(errMsg)) - content = header + "\n\n" + body - } - - case StatusCancelled: - // ○ Query - content = icon + sp + nameStyle.Render(m.child.Name()) - } - - return lipgloss.NewStyle(). - Background(colors.Bg). - Padding(block.PaddingY, block.PaddingX). - Width(m.width). - Render(content) -} - -// Height returns the number of lines this block renders. -func (m *Model) Height() int { - return lipgloss.Height(m.View()) -} - -// bodyStyle returns the style for the tool's content area. -func (m *Model) bodyStyle() lipgloss.Style { - return lipgloss.NewStyle(). - Padding(0, bodyPaddingRight, 0, bodyPaddingLeft). - Background(m.theme.Bg) -} - -// renderIcon returns the colored status icon. -func (m *Model) renderIcon() string { - colors := m.theme - bg := colors.Bg - - switch m.status { - case StatusSuccess: - return lipgloss.NewStyle().Foreground(colors.Success).Background(bg).Render(IconSuccess) - case StatusError: - return lipgloss.NewStyle().Foreground(colors.Error).Background(bg).Render(IconError) - case StatusCancelled: - return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(bg).Render(IconCancelled) - default: - return lipgloss.NewStyle().Foreground(colors.TextMuted).Background(bg).Render(IconPending) - } -} - -// ForceStatus sets the status directly (for testing). -func (m *Model) ForceStatus(status Status) { - m.status = status -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - m.child.SetWidth(width - block.PaddingX*2 - bodyPaddingH) -} - -// Index returns the block index. -func (m *Model) Index() int { - return m.index -} - -// ToolID returns the tool ID. -func (m *Model) ToolID() string { - return m.toolID -} - -// Name returns the tool name. -func (m *Model) Name() string { - return m.child.Name() -} - -// State returns the child's state. -func (m *Model) State() State { - return m.child.State() -} - -// Kind implements block.Block. -func (m *Model) Kind() block.Kind { - return block.KindTool -} - -// SetFocused implements block.Block. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *Model) Focused() bool { - return m.focused -} - -// Toggle implements block.Toggleable — expands/collapses the tool body. -// Only toggles when clicking the header line (y == PaddingY). -func (m *Model) Toggle(y int) { - if m.expanded && y != block.PaddingY { - return - } - m.expanded = !m.expanded -} diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go deleted file mode 100644 index f3c08c85..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/tool_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package tools - -import ( - "errors" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/styles" -) - -// stubChild implements Child with fixed return values. -type stubChild struct { - name string - status string - result string - state State - toolID string - err error - width int - view string -} - -func (s *stubChild) Update(tea.Msg) tea.Cmd { return nil } -func (s *stubChild) View() string { return s.view } -func (s *stubChild) SetWidth(w int) { s.width = w } -func (s *stubChild) Name() string { return s.name } -func (s *stubChild) Status() string { return s.status } -func (s *stubChild) Result() string { return s.result } -func (s *stubChild) State() State { return s.state } -func (s *stubChild) ToolID() string { return s.toolID } -func (s *stubChild) Err() error { return s.err } - -func newTestTool(t *testing.T, child *stubChild) *Model { - t.Helper() - theme := styles.NewTheme(true) - return New(theme, 0, "turn-1", "tool-1", 80, child) -} - -func TestStatusRendering(t *testing.T) { - t.Parallel() - - t.Run("pending shows icon and name", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconPending) { - t.Error("expected pending icon") - } - if !strings.Contains(view, "Query") { - t.Error("expected tool name") - } - }) - - t.Run("running shows status message", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateExecuting, status: "Checking services"} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconPending) { - t.Error("expected pending icon for running state") - } - if !strings.Contains(view, "Checking services") { - t.Error("expected status message in view") - } - }) - - t.Run("success shows result with chevron", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateComplete, result: "Found 14 services"} - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconSuccess) { - t.Error("expected success icon") - } - if !strings.Contains(view, "Found 14 services") { - t.Error("expected result message in view") - } - if !strings.Contains(view, "▶") { - t.Error("expected collapsed chevron when default collapsed") - } - }) - - t.Run("error collapsed shows icon and result", func(t *testing.T) { - t.Parallel() - child := &stubChild{ - name: "Query", - result: "Query failed", - state: StateComplete, - err: errors.New("connection timeout"), - } - m := newTestTool(t, child) - m.updateStatus() - view := m.View() - - if !strings.Contains(view, IconError) { - t.Error("expected error icon") - } - if !strings.Contains(view, "Query failed") { - t.Error("expected result in collapsed error view") - } - if strings.Contains(view, "ERROR") { - t.Error("ERROR tag should be hidden when collapsed") - } - }) - - t.Run("error expanded shows error tag and message", func(t *testing.T) { - t.Parallel() - child := &stubChild{ - name: "Query", - result: "Query failed", - state: StateComplete, - err: errors.New("connection timeout"), - } - m := newTestTool(t, child) - m.updateStatus() - m.Toggle(block.PaddingY) - view := m.View() - - if !strings.Contains(view, "ERROR") { - t.Error("expected ERROR tag in expanded view") - } - if !strings.Contains(view, "connection timeout") { - t.Error("expected error message in expanded view") - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets status to cancelled", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - - m.Cancel() - - if m.status != StatusCancelled { - t.Errorf("expected StatusCancelled, got %d", m.status) - } - }) - - t.Run("renders name without spinner", func(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateExecuting, status: "Checking"} - m := newTestTool(t, child) - m.updateStatus() - - m.Cancel() - view := m.View() - - if !strings.Contains(view, IconCancelled) { - t.Error("expected cancelled icon") - } - if !strings.Contains(view, "Query") { - t.Error("expected tool name") - } - // Should be 3 lines: top padding + content + bottom padding (no body, no spinner) - if lines := strings.Count(view, "\n"); lines != 2 { - t.Errorf("expected 3-line render (padding + content + padding) for cancelled tool, got %d lines", lines+1) - } - }) -} - -func TestToggle(t *testing.T) { - t.Parallel() - - child := &stubChild{ - name: "Query", - state: StateComplete, - result: "Found items", - view: "detailed output here", - } - m := newTestTool(t, child) - m.updateStatus() - - // Default is collapsed - collapsedView := m.View() - if strings.Contains(collapsedView, "detailed output here") { - t.Error("expected no body content when collapsed by default") - } - if !strings.Contains(collapsedView, "▶") { - t.Error("expected collapsed chevron") - } - - // Toggle to expand - m.Toggle(block.PaddingY) - expandedView := m.View() - if !strings.Contains(expandedView, "detailed output here") { - t.Error("expected body content when expanded") - } - if !strings.Contains(expandedView, "▼") { - t.Error("expected expanded chevron") - } - - // Expanded should be taller - expandedLines := strings.Count(expandedView, "\n") - collapsedLines := strings.Count(collapsedView, "\n") - if collapsedLines >= expandedLines { - t.Errorf("collapsed (%d lines) should be shorter than expanded (%d lines)", collapsedLines, expandedLines) - } -} - -func TestKind(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - if m.Kind() != block.KindTool { - t.Errorf("expected KindTool, got %d", m.Kind()) - } -} - -func TestUpdate_IgnoresToolCompletedFromDifferentTurn(t *testing.T) { - t.Parallel() - child := &stubChild{name: "Query", state: StateAccumulating} - m := newTestTool(t, child) - - m.Update(msgs.ToolCompleted{ - TurnID: "turn-2", - ToolUseID: "tool-1", - }) - - if m.status != StatusPending { - t.Fatalf("status = %d, want StatusPending", m.status) - } -} diff --git a/internal/app/chat/messagelist/round/turn/reducer.go b/internal/app/chat/messagelist/round/turn/reducer.go deleted file mode 100644 index b93776ac..00000000 --- a/internal/app/chat/messagelist/round/turn/reducer.go +++ /dev/null @@ -1,28 +0,0 @@ -package turn - -// reduceOnStreamDone returns the turn state after the stream finishes. -func reduceOnStreamDone(stopReason string, collected, pending int) State { - if stopReason != "tool_use" { - return StateComplete - } - if collected >= pending { - return StateComplete - } - return StateAwaitingToolResults -} - -// reduceOnToolCompleted returns the turn state after collecting one tool result. -func reduceOnToolCompleted(current State, collected, pending int) State { - if current != StateAwaitingToolResults { - return current - } - if collected >= pending { - return StateComplete - } - return StateAwaitingToolResults -} - -// shouldFireToolResults returns true when results can be emitted to the round. -func shouldFireToolResults(state State, persisted bool, collected, pending int) bool { - return state == StateComplete && persisted && pending > 0 && collected >= pending -} diff --git a/internal/app/chat/messagelist/round/turn/tool_results_tracker.go b/internal/app/chat/messagelist/round/turn/tool_results_tracker.go deleted file mode 100644 index d47c9f98..00000000 --- a/internal/app/chat/messagelist/round/turn/tool_results_tracker.go +++ /dev/null @@ -1,66 +0,0 @@ -package turn - -import ( - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" -) - -type toolResultTracker struct { - pendingTools int - pendingToolIDs map[string]bool - results []tools.Result - persisted bool - fired bool -} - -func (t *toolResultTracker) accepts(toolUseID string) bool { - // Before pendingToolIDs is set (during streaming), accept all tools. - if t.pendingToolIDs == nil { - return true - } - return t.pendingToolIDs[toolUseID] -} - -func (t *toolResultTracker) collect(result tools.Result) { - t.results = append(t.results, result) -} - -func (t *toolResultTracker) collectedCount() int { - return len(t.results) -} - -func (t *toolResultTracker) pendingCount() int { - return t.pendingTools -} - -func (t *toolResultTracker) setPendingFromContent(content []domain.Block) { - t.pendingTools, t.pendingToolIDs = collectToolUseIDs(content) -} - -func (t *toolResultTracker) markPersisted() { - t.persisted = true -} - -func (t *toolResultTracker) shouldFire(state State) bool { - return shouldFireToolResults(state, t.persisted, len(t.results), t.pendingTools) -} - -func (t *toolResultTracker) fire(turnID domain.MessageID, scopeReporter func(msg string)) tea.Cmd { - if t.fired { - if scopeReporter != nil { - scopeReporter("fireToolResults called twice, ignoring") - } - return nil - } - t.fired = true - results := t.results - return func() tea.Msg { - return msgs.ToolResultsReady{ - TurnID: turnID, - Results: results, - } - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn.go b/internal/app/chat/messagelist/round/turn/turn.go deleted file mode 100644 index 5956f9aa..00000000 --- a/internal/app/chat/messagelist/round/turn/turn.go +++ /dev/null @@ -1,124 +0,0 @@ -package turn - -import ( - "context" - "errors" - "time" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/user" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" -) - -// State represents the current state of a turn. -type State int - -const ( - StateIdle State = iota - StateStreaming - StateAwaitingToolResults - StateComplete -) - -const dbOpTimeout = 2 * time.Second - -// Model represents a single user→assistant exchange. -// It is a fixed-height component - height is determined by content. -type Model struct { - theme styles.Theme - scope log.Scope - - conversationID domain.ConversationID - accountID domain.AccountID - - userMessage *user.Model - assistantMessage *assistant.Model - - state State - width int - stream *streamState - - // Tool result lifecycle (pending IDs/results/persisted/fired gate). - toolTracker toolResultTracker - - // Protocol guard telemetry (incremented on dropped/malformed lifecycle events). - protocolViolationCount int - - streamRunner usecase.StreamRunner - streamErrorMapper usecase.StreamErrorMapper - assistantPersister usecase.AssistantPersister - effectCtx context.Context - toolRegistry *chattools.Registry -} - -// streamState holds the channel for receiving stream updates. -type streamState struct { - updates chan streamUpdate - cancel context.CancelCauseFunc - done bool -} - -// streamUpdate is sent through the channel as the stream progresses. -type streamUpdate struct { - message *domain.Message - status corechat.StreamStatus - abort string - result *corechat.StreamResult // final result, only set on done - err error - done bool -} - -var errUserCancelled = errors.New("user_cancelled") - -// turnStreamUpdateMsg is the internal message for stream handling. -type turnStreamUpdateMsg struct { - turnID domain.MessageID - update streamUpdate -} - -// assistantPersisted is fired after the assistant message is written to the DB. -// It gates fireToolResults to prevent racing with persistence. -type assistantPersisted struct { - turnID domain.MessageID - messageID domain.MessageID -} - -// New creates a new turn from a user submission. -func New( - theme styles.Theme, - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - width int, - streamRunner usecase.StreamRunner, - streamErrorMapper usecase.StreamErrorMapper, - assistantPersister usecase.AssistantPersister, - effectCtx context.Context, - toolRegistry *chattools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("turn") - return &Model{ - theme: theme, - scope: scope, - conversationID: conversationID, - accountID: accountID, - userMessage: user.New(theme.WithBg(theme.BgElevated), userMessageID, input, width-block.BorderWidth), - assistantMessage: assistant.New(theme, userMessageID, "", width, toolRegistry, scope), - state: StateIdle, - width: width, - streamRunner: streamRunner, - streamErrorMapper: streamErrorMapper, - assistantPersister: assistantPersister, - effectCtx: effectCtx, - toolRegistry: toolRegistry, - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn_effects.go b/internal/app/chat/messagelist/round/turn/turn_effects.go deleted file mode 100644 index 7a0a3efc..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_effects.go +++ /dev/null @@ -1,67 +0,0 @@ -package turn - -import ( - "context" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/domain" -) - -// persistAssistantMessage records the assistant message for the session. Chat -// is ephemeral, so this mints a local message ID rather than storing content. -func (m *Model) persistAssistantMessage(msg *domain.Message) tea.Cmd { - if msg == nil { - return nil - } - - return func() tea.Msg { - ctx, cancel := context.WithTimeout(m.effectCtx, dbOpTimeout) - defer cancel() - - msgID, err := m.assistantPersister.PersistAssistant(ctx, usecase.PersistAssistantInput{ - AccountID: m.accountID, - ConversationID: m.conversationID, - Message: *msg, - }) - if err != nil { - m.scope.Error("failed to persist assistant message", "error", err) - return nil - } - - m.scope.Info("assistant message persisted", "message_id", msgID) - return assistantPersisted{ - turnID: m.userMessage.ID(), - messageID: msgID, - } - } -} - -// fireToolResults fires ToolResultsReady for the round to handle. -// Guarded by firedToolResults to ensure it can only fire once per turn. -func (m *Model) fireToolResults() tea.Cmd { - return m.toolTracker.fire(m.userMessage.ID(), func(message string) { - m.scope.Warn(message) - }) -} - -// collectToolUseIDs returns the count and set of tool_use IDs in content. -func collectToolUseIDs(content []domain.Block) (int, map[string]bool) { - ids := make(map[string]bool) - for _, b := range content { - if b.Type == domain.BlockTypeToolUse && b.ToolUse != nil && b.ToolUse.ID != "" { - ids[b.ToolUse.ID] = true - } - } - return len(ids), ids -} - -func (m *Model) reportProtocolViolation(reason string, kv ...any) { - m.protocolViolationCount++ - fields := []any{ - "reason", reason, - "count", m.protocolViolationCount, - } - fields = append(fields, kv...) - m.scope.Warn("protocol violation", fields...) -} diff --git a/internal/app/chat/messagelist/round/turn/turn_model.go b/internal/app/chat/messagelist/round/turn/turn_model.go deleted file mode 100644 index 43eed1f2..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_model.go +++ /dev/null @@ -1,59 +0,0 @@ -package turn - -import ( - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" -) - -// Blocks returns all visual blocks for the viewport. -// Includes user message (if visible) followed by assistant blocks. -func (m *Model) Blocks() []block.Block { - var result []block.Block - if m.userMessage.IsVisible() { - result = append(result, m.userMessage) - } - result = append(result, m.assistantMessage.Blocks()...) - return result -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width - // User block gets content width minus border+padding, same as assistant blocks. - // The border decoration is applied by renderBlock in the message list. - m.userMessage.SetWidth(width - block.BorderWidth) - m.assistantMessage.SetWidth(width) -} - -// State returns the turn's current state. -func (m *Model) State() State { - return m.state -} - -// UserMessageID returns the user message ID. -func (m *Model) UserMessageID() domain.MessageID { - return m.userMessage.ID() -} - -// UserInput returns the input that created this turn's user message. -func (m *Model) UserInput() msgs.UserSubmittedInput { - return m.userMessage.Input() -} - -// AssistantMessageID returns the persisted assistant message ID. -// Returns empty string if the assistant message was never persisted. -func (m *Model) AssistantMessageID() domain.MessageID { - return m.assistantMessage.ID() -} - -// Cancel stops the in-flight stream and marks the turn complete. -// The partial content remains rendered but nothing is persisted. -func (m *Model) Cancel() { - if m.stream != nil && !m.stream.done { - m.stream.cancel(errUserCancelled) - m.stream.done = true - } - m.assistantMessage.Cancel() - m.state = StateComplete -} diff --git a/internal/app/chat/messagelist/round/turn/turn_stream.go b/internal/app/chat/messagelist/round/turn/turn_stream.go deleted file mode 100644 index d179140c..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_stream.go +++ /dev/null @@ -1,184 +0,0 @@ -package turn - -import ( - "context" - - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// StartStream begins streaming the assistant response. -func (m *Model) StartStream(messages []domain.Message, chatContext []domain.ContextEntity) tea.Cmd { - m.scope.Debug("starting stream", "message_count", len(messages)) - m.state = StateStreaming - - ctx, cancel := context.WithCancelCause(context.Background()) - updates := make(chan streamUpdate, 10) - m.stream = &streamState{updates: updates, cancel: cancel} - - req := usecase.StreamRequest{ - ConversationID: m.conversationID, - Messages: messages, - ContextEntities: chatContext, - } - - runUpdates := m.streamRunner.Start(ctx, req) - go func() { - defer close(updates) - for u := range runUpdates { - updates <- streamUpdate{ - message: u.Message, - status: u.Status, - abort: u.AbortReason, - result: u.Result, - err: u.Err, - done: u.Done, - } - } - }() - - return m.nextStreamUpdate() -} - -// handleStreamUpdate processes a stream update and fires messages. -func (m *Model) handleStreamUpdate(update streamUpdate) tea.Cmd { - if update.err != nil { - // context.Canceled is expected when Cancel() was called — don't show an error. - if m.stream != nil && m.stream.done { - return nil - } - m.scope.Error("stream error", "class", usecase.ClassifyStreamError(m.streamErrorMapper, update.err), "error", update.err) - m.assistantMessage.Cancel() - m.state = StateComplete - turnID := m.userMessage.ID() - return func() tea.Msg { - return msgs.StreamFailed{TurnID: turnID, Err: update.err} - } - } - - if update.message == nil { - return m.nextStreamUpdate() - } - - // Set assistant message ID once we have it from the stream - if m.assistantMessage.ID() == "" && update.message.ID != "" { - m.assistantMessage.SetID(update.message.ID) - // Populate content immediately to avoid empty render before message round-trips - m.assistantMessage.SetContent(update.message.Content) - } - - if update.done { - if update.status == corechat.StreamStatusAborted { - reason := update.abort - if reason == "" { - reason = "context_canceled" - } - m.scope.Info("stream aborted", "reason", reason) - m.assistantMessage.Cancel() - m.state = StateComplete - - // User-cancelled stream: keep current behavior (do not persist). - if reason == errUserCancelled.Error() { - return nil - } - - msg := update.message - if msg == nil { - msg = &domain.Message{} - } - if msg.ID == "" { - msg.ID = domain.NewMessageID() - } - msg.StopReason = "aborted" - - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.StreamCompleted{ - TurnID: turnID, - Message: *msg, - } - }, - m.persistAssistantMessage(msg), - ) - } - - if update.message.ID == "" { - update.message.ID = domain.NewMessageID() - } - m.scope.Info("stream completed", "stop_reason", update.message.StopReason) - - // Extract metadata from stream result if present - var title string - var contextWindow, inputTokens, outputTokens int - if update.result != nil && update.result.Metadata != nil { - title = update.result.Metadata.Title - contextWindow = update.result.Metadata.ContextWindow - inputTokens = update.result.Metadata.InputTokens - outputTokens = update.result.Metadata.OutputTokens - } - - if update.message.StopReason == "tool_use" { - m.toolTracker.setPendingFromContent(update.message.Content) - m.scope.Info("awaiting tool results", "pending", m.toolTracker.pendingCount(), "already_collected", m.toolTracker.collectedCount()) - m.state = reduceOnStreamDone(update.message.StopReason, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - if m.state == StateComplete { - m.scope.Info("all tools already completed") - } - } else { - m.state = reduceOnStreamDone(update.message.StopReason, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - } - - // Fire StreamCompleted and persist - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.StreamCompleted{ - TurnID: turnID, - Message: *update.message, - Title: title, - ContextWindow: contextWindow, - InputTokens: inputTokens, - OutputTokens: outputTokens, - } - }, - m.persistAssistantMessage(update.message), - ) - } - - // Fire AssistantContentUpdated and continue - turnID := m.userMessage.ID() - return tea.Batch( - func() tea.Msg { - return msgs.AssistantContentUpdated{TurnID: turnID, Message: *update.message} - }, - m.nextStreamUpdate(), - ) -} - -// nextStreamUpdate returns a command that waits for the next stream update. -func (m *Model) nextStreamUpdate() tea.Cmd { - if m.stream == nil || m.stream.done { - return nil - } - - userMsgID := m.userMessage.ID() - updates := m.stream.updates - - return func() tea.Msg { - update, ok := <-updates - if !ok { - return turnStreamUpdateMsg{ - turnID: userMsgID, - update: streamUpdate{done: true}, - } - } - return turnStreamUpdateMsg{ - turnID: userMsgID, - update: update, - } - } -} diff --git a/internal/app/chat/messagelist/round/turn/turn_test.go b/internal/app/chat/messagelist/round/turn/turn_test.go deleted file mode 100644 index 31142d90..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package turn - -import ( - "context" - "errors" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func newTestTurn(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - // Stream runner and persister are nil — these tests exercise the state machine, - // not persistence or streaming. The returned tea.Cmds are never executed. - return New(theme, "conv-1", "acct-1", "user-1", - msgs.UserSubmittedInput{Text: "hi"}, 80, nil, nil, nil, context.Background(), nil, scope) -} - -func TestHandleStreamUpdate(t *testing.T) { - t.Parallel() - - t.Run("end_turn completes", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") // skip SetContent path - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "end_turn", - }, - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - }) - - t.Run("tool_use transitions to awaiting", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-1", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateAwaitingToolResults { - t.Errorf("expected StateAwaitingToolResults, got %d", m.state) - } - if m.toolTracker.pendingTools != 1 { - t.Errorf("expected 1 pending tool, got %d", m.toolTracker.pendingTools) - } - }) - - t.Run("tools already completed skips awaiting", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - // Tool completed during streaming, before stream finished - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-1", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete (tools already done), got %d", m.state) - } - }) - - t.Run("stream error completes", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.handleStreamUpdate(streamUpdate{ - err: errors.New("connection failed"), - done: true, - }) - - if m.state != StateComplete { - t.Errorf("expected StateComplete on error, got %d", m.state) - } - }) - - t.Run("intermediate update stays streaming", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - }, - done: false, - }) - - if m.state != StateStreaming { - t.Errorf("expected StateStreaming during intermediate update, got %d", m.state) - } - }) -} - -func TestCancel(t *testing.T) { - t.Parallel() - - t.Run("sets state to complete", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.Cancel() - - if m.state != StateComplete { - t.Errorf("expected StateComplete after Cancel, got %d", m.state) - } - }) - - t.Run("suppresses stream error after cancel", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - m.stream = &streamState{ - updates: make(chan streamUpdate), - cancel: func(error) {}, - done: false, - } - - m.Cancel() - - // Simulate the error that arrives after context cancellation - cmd := m.handleStreamUpdate(streamUpdate{ - err: errors.New("context canceled"), - done: true, - }) - - // Should return nil (no error toast), not an error command - if cmd != nil { - t.Error("expected nil command after cancel, got non-nil (error was not suppressed)") - } - }) - - t.Run("idempotent on idle turn", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Cancel() // no stream, no panic - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - }) - - t.Run("aborted user_cancelled does not emit completion cmd", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - cmd := m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ID: "asst-1"}, - status: corechat.StreamStatusAborted, - abort: "user_cancelled", - done: true, - }) - - if cmd != nil { - t.Error("expected nil cmd for user_cancelled abort") - } - }) - - t.Run("aborted non-user emits completion cmd", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - cmd := m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ID: "asst-1"}, - status: corechat.StreamStatusAborted, - abort: "context_canceled", - done: true, - }) - - if cmd == nil { - t.Error("expected non-nil cmd for non-user abort") - } - }) -} - -func TestHandleToolCompleted(t *testing.T) { - t.Parallel() - - t.Run("collects results while streaming", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateStreaming - - m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if len(m.toolTracker.results) != 1 { - t.Errorf("expected 1 collected result, got %d", len(m.toolTracker.results)) - } - // Should not change state — still streaming - if m.state != StateStreaming { - t.Errorf("expected StateStreaming, got %d", m.state) - } - }) - - t.Run("fires results when all collected and persisted", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 2 - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - - cmd := m.handleToolCompleted("tool-2", tools.Result{ToolUseID: "tool-2"}) - - if m.state != StateComplete { - t.Errorf("expected StateComplete when all tools done, got %d", m.state) - } - if len(m.toolTracker.results) != 2 { - t.Errorf("expected 2 results, got %d", len(m.toolTracker.results)) - } - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults)") - } - }) - - t.Run("does not fire until all tools complete", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.pendingTools = 2 - - m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if m.state != StateAwaitingToolResults { - t.Errorf("expected StateAwaitingToolResults with 1 of 2 tools, got %d", m.state) - } - }) - - t.Run("waits for persist before firing results", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = false - m.toolTracker.pendingTools = 1 - - cmd := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if m.state != StateComplete { - t.Errorf("expected StateComplete, got %d", m.state) - } - if cmd != nil { - t.Error("expected nil cmd — persist hasn't completed yet") - } - }) - - t.Run("unknown tool completion increments protocol violation counter", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.pendingTools = 1 - m.toolTracker.pendingToolIDs = map[string]bool{"tool-1": true} - - m.handleToolCompleted("tool-x", tools.Result{ToolUseID: "tool-x"}) - - if got := m.protocolViolationCount; got != 1 { - t.Fatalf("protocolViolationCount = %d, want 1", got) - } - if len(m.toolTracker.results) != 0 { - t.Fatalf("toolResults len = %d, want 0", len(m.toolTracker.results)) - } - }) -} - -func TestInterleavedToolUseFlow(t *testing.T) { - t.Parallel() - - m := newTestTurn(t) - m.state = StateStreaming - m.assistantMessage.SetID("asst-1") - - // Stream completes with two tool_use blocks from interleaved tool input deltas. - m.handleStreamUpdate(streamUpdate{ - message: &domain.Message{ - ID: "asst-1", - StopReason: "tool_use", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-a", Name: "query"}}, - {Index: 1, Type: domain.BlockTypeToolUse, ToolUse: &domain.ToolUse{ID: "tool-b", Name: "query"}}, - }, - }, - done: true, - }) - - if m.state != StateAwaitingToolResults { - t.Fatalf("expected StateAwaitingToolResults, got %d", m.state) - } - if m.toolTracker.pendingTools != 2 { - t.Fatalf("expected 2 pending tools, got %d", m.toolTracker.pendingTools) - } - - // Unknown tool result is ignored once pending IDs are fixed. - m.handleToolCompleted("tool-c", tools.Result{ToolUseID: "tool-c"}) - if len(m.toolTracker.results) != 0 { - t.Fatalf("expected 0 collected results after unknown tool, got %d", len(m.toolTracker.results)) - } - - // Interleaved completions should only complete once all known tools are done. - m.handleToolCompleted("tool-b", tools.Result{ToolUseID: "tool-b"}) - if m.state != StateAwaitingToolResults { - t.Fatalf("expected awaiting state after first completion, got %d", m.state) - } - m.handleToolCompleted("tool-a", tools.Result{ToolUseID: "tool-a"}) - if m.state != StateComplete { - t.Fatalf("expected complete state after all tools, got %d", m.state) - } -} - -func TestPersistBeforeFireToolResults(t *testing.T) { - t.Parallel() - - t.Run("tools pre-completed fires after persist", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateComplete - m.toolTracker.pendingTools = 1 - m.toolTracker.results = []tools.Result{{ToolUseID: "tool-1"}} - m.toolTracker.persisted = false - - // Simulate assistantPersisted arriving - cmd := m.Update(assistantPersisted{turnID: m.userMessage.ID(), messageID: "asst-1"}) - - if !m.toolTracker.persisted { - t.Error("expected persisted = true") - } - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults)") - } - }) - - t.Run("tools complete after persist fires immediately", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 1 - - cmd := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - - if cmd == nil { - t.Error("expected non-nil cmd (fireToolResults) — already persisted") - } - }) - - t.Run("no-op when no pending tools", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateComplete - m.toolTracker.pendingTools = 0 - - cmd := m.Update(assistantPersisted{turnID: m.userMessage.ID(), messageID: "asst-1"}) - - if !m.toolTracker.persisted { - t.Error("expected persisted = true") - } - if cmd != nil { - t.Error("expected nil cmd — no tools to fire") - } - }) -} - -func TestFireToolResultsOnlyOnce(t *testing.T) { - t.Parallel() - - t.Run("second call returns nil", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - m.state = StateAwaitingToolResults - m.toolTracker.persisted = true - m.toolTracker.pendingTools = 1 - - cmd1 := m.handleToolCompleted("tool-1", tools.Result{ToolUseID: "tool-1"}) - if cmd1 == nil { - t.Fatal("expected non-nil cmd on first fire") - } - - cmd2 := m.fireToolResults() - if cmd2 != nil { - t.Error("expected nil cmd on second fireToolResults call") - } - }) -} - -func TestUpdate_TurnScopedRouting(t *testing.T) { - t.Parallel() - - t.Run("tool completed turn mismatch is ignored", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Update(msgs.ToolCompleted{ - TurnID: "user-other", - ToolUseID: "tool-1", - }) - - if got := m.protocolViolationCount; got != 0 { - t.Fatalf("protocolViolationCount = %d, want 0", got) - } - }) - - t.Run("assistant persisted turn mismatch is ignored", func(t *testing.T) { - t.Parallel() - m := newTestTurn(t) - - m.Update(assistantPersisted{ - turnID: "user-other", - messageID: "asst-1", - }) - - if got := m.protocolViolationCount; got != 0 { - t.Fatalf("protocolViolationCount = %d, want 0", got) - } - if m.toolTracker.persisted { - t.Fatal("persisted should remain false for mismatched turn") - } - }) -} diff --git a/internal/app/chat/messagelist/round/turn/turn_update.go b/internal/app/chat/messagelist/round/turn/turn_update.go deleted file mode 100644 index 2b9bb195..00000000 --- a/internal/app/chat/messagelist/round/turn/turn_update.go +++ /dev/null @@ -1,94 +0,0 @@ -package turn - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/domain/tools" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case turnStreamUpdateMsg: - if msg.turnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.handleStreamUpdate(msg.update)) - - case msgs.AssistantContentUpdated: - if msg.TurnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.assistantMessage.Update(msg)) - return tea.Batch(cmds...) - - case msgs.StreamCompleted: - if msg.TurnID != m.userMessage.ID() { - return nil - } - cmds = append(cmds, m.assistantMessage.Update(msg)) - return tea.Batch(cmds...) - - case msgs.ToolCompleted: - if msg.TurnID != m.userMessage.ID() { - // Message buses fan out to all turns; non-owner turns ignore. - return nil - } - cmds = append(cmds, m.handleToolCompleted(msg.ToolUseID, msg.ResultOrError())) - - case assistantPersisted: - if msg.turnID != m.userMessage.ID() { - // Internal completion events are broadcast; non-owner turns ignore. - return nil - } - if msg.messageID != "" { - m.assistantMessage.SetID(msg.messageID) - } - m.toolTracker.markPersisted() - if m.toolTracker.shouldFire(m.state) { - return m.fireToolResults() - } - return nil - } - - cmds = append(cmds, m.userMessage.Update(msg)) - cmds = append(cmds, m.assistantMessage.Update(msg)) - - return tea.Batch(cmds...) -} - -func (m *Model) handleToolCompleted(toolUseID string, result tools.Result) tea.Cmd { - // Ignore tools that don't belong to this turn. - // Before pendingToolIDs is set (during streaming), accept all tools — - // they'll be validated once the stream completes and IDs are known. - if !m.toolTracker.accepts(toolUseID) { - m.reportProtocolViolation( - "tool_completed_unknown_tool_use_id", - "tool_use_id", toolUseID, - "pending", m.toolTracker.pendingCount(), - "collected", m.toolTracker.collectedCount(), - ) - return nil - } - - // Collect results during streaming or awaiting - tools may complete before StreamCompleted - m.toolTracker.collect(result) - m.scope.Info("tool completed", "tool_use_id", toolUseID, "collected", m.toolTracker.collectedCount(), "pending", m.toolTracker.pendingCount()) - - // Only fire results once we're awaiting and have all of them - next := reduceOnToolCompleted(m.state, m.toolTracker.collectedCount(), m.toolTracker.pendingCount()) - if next == m.state { - return nil - } - m.state = next - if m.state == StateComplete { - m.scope.Info("all tools completed") - if m.toolTracker.shouldFire(m.state) { - return m.fireToolResults() - } - return nil - } - return nil -} diff --git a/internal/app/chat/messagelist/round/turn/user/user.go b/internal/app/chat/messagelist/round/turn/user/user.go deleted file mode 100644 index c24310b7..00000000 --- a/internal/app/chat/messagelist/round/turn/user/user.go +++ /dev/null @@ -1,97 +0,0 @@ -package user - -import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/styles" -) - -// Model renders a user message. -// It is a fixed-height component - height is determined by content. -// Implements block.Block. -type Model struct { - theme styles.Theme - id domain.MessageID - input msgs.UserSubmittedInput - width int - focused bool -} - -// New creates a new user message view. -func New(theme styles.Theme, id domain.MessageID, input msgs.UserSubmittedInput, width int) *Model { - return &Model{ - theme: theme, - id: id, - input: input, - width: width, - } -} - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - return nil -} - -// View renders the user message content without border decoration. -// The border is applied by renderBlock in the message list. -func (m *Model) View() string { - // Tool result messages are not rendered visually - if len(m.input.ToolResults) > 0 { - return "" - } - - style := lipgloss.NewStyle(). - Foreground(m.theme.Text). - Background(m.theme.Bg). - Padding(block.PaddingY, block.PaddingX). - Width(m.width) - - return style.Render(m.input.Text) -} - -// Height returns the number of lines this component renders. -func (m *Model) Height() int { - // Tool result messages have no visual representation - if len(m.input.ToolResults) > 0 { - return 0 - } - return lipgloss.Height(m.View()) -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -// ID returns the message ID. -func (m *Model) ID() domain.MessageID { - return m.id -} - -// Input returns the submitted input for this user message. -func (m *Model) Input() msgs.UserSubmittedInput { - return m.input -} - -// Kind implements block.Block. -func (m *Model) Kind() block.Kind { - return block.KindUser -} - -// SetFocused implements block.Block. -func (m *Model) SetFocused(focused bool) { - m.focused = focused -} - -// Focused implements block.Block. -func (m *Model) Focused() bool { - return m.focused -} - -// IsVisible returns false for tool result messages (they have no visual representation). -func (m *Model) IsVisible() bool { - return len(m.input.ToolResults) == 0 -} diff --git a/internal/app/chat/messagelist/round/turn/user/user_test.go b/internal/app/chat/messagelist/round/turn/user/user_test.go deleted file mode 100644 index b274b7bf..00000000 --- a/internal/app/chat/messagelist/round/turn/user/user_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package user - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/styles" -) - -func newTestUser(t *testing.T, text string, width int) *Model { - t.Helper() - theme := styles.NewTheme(true) - return New(theme, "user-1", msgs.UserSubmittedInput{Text: text}, width) -} - -func TestView(t *testing.T) { - t.Parallel() - - t.Run("renders within width", func(t *testing.T) { - t.Parallel() - for _, width := range []int{40, 60, 80, 120} { - m := newTestUser(t, "Hello, world!", width) - view := m.View() - for i, line := range strings.Split(view, "\n") { - w := ansi.StringWidth(line) - if w > width { - t.Errorf("width=%d line %d: got %d chars, want <=%d", width, i, w, width) - } - } - } - }) - - t.Run("wraps long text", func(t *testing.T) { - t.Parallel() - long := strings.Repeat("word ", 30) // ~150 chars - m := newTestUser(t, long, 40) - h := m.Height() - if h < 2 { - t.Errorf("expected wrapping (height >= 2), got height %d", h) - } - }) - - t.Run("tool results are invisible", func(t *testing.T) { - t.Parallel() - theme := styles.NewTheme(true) - m := New(theme, "user-1", msgs.UserSubmittedInput{ - ToolResults: []tools.Result{{ToolUseID: "tool-1"}}, - }, 80) - - if m.View() != "" { - t.Error("expected empty view for tool result message") - } - if m.Height() != 0 { - t.Errorf("expected height 0 for tool result message, got %d", m.Height()) - } - if m.IsVisible() { - t.Error("expected IsVisible() == false for tool result message") - } - }) -} - -func TestKind(t *testing.T) { - t.Parallel() - m := newTestUser(t, "hi", 80) - if m.Kind() != block.KindUser { - t.Errorf("expected KindUser, got %d", m.Kind()) - } -} - -func TestFocused(t *testing.T) { - t.Parallel() - m := newTestUser(t, "hi", 80) - - if m.Focused() { - t.Error("expected unfocused by default") - } - m.SetFocused(true) - if !m.Focused() { - t.Error("expected focused after SetFocused(true)") - } -} diff --git a/internal/app/chat/messagelist/rounds.go b/internal/app/chat/messagelist/rounds.go deleted file mode 100644 index b6b5b774..00000000 --- a/internal/app/chat/messagelist/rounds.go +++ /dev/null @@ -1,124 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/domain" -) - -// HasActiveRound returns true if the last round is still active. -func (m *Model) HasActiveRound() bool { - if len(m.rounds) == 0 { - return false - } - return m.rounds[len(m.rounds)-1].IsActive() -} - -// LastRound returns the last round or nil. -func (m *Model) LastRound() *round.Model { - if len(m.rounds) == 0 { - return nil - } - return m.rounds[len(m.rounds)-1] -} - -// HasTurn returns true when any round owns the given turn/user-message ID. -func (m *Model) HasTurn(turnID domain.MessageID) bool { - for _, r := range m.rounds { - if r.HasTurn(turnID) { - return true - } - } - return false -} - -// RemoveLastRound removes the last round and rebuilds blocks. -func (m *Model) RemoveLastRound() { - if len(m.rounds) == 0 { - return - } - m.rounds = m.rounds[:len(m.rounds)-1] - m.rebuildBlocks() -} - -// CancelActiveRound cancels the last round if it is still active. -func (m *Model) CancelActiveRound() { - if len(m.rounds) == 0 { - return - } - last := m.rounds[len(m.rounds)-1] - if last.IsActive() { - last.Cancel() - m.rebuildBlocks() - } -} - -// StartTurn creates a new round and begins streaming. -func (m *Model) StartTurn( - conversationID domain.ConversationID, - accountID domain.AccountID, - userMessageID domain.MessageID, - input msgs.UserSubmittedInput, - messages []domain.Message, - context []domain.ContextEntity, -) tea.Cmd { - m.scope.Debug("starting turn", "user_message_id", userMessageID) - - r := round.New( - m.theme, - conversationID, - accountID, - userMessageID, - input, - m.contentWidth(), - m.runtimeDeps, - m.toolRegistry, - m.scope, - ) - - m.rounds = append(m.rounds, r) - m.rebuildBlocks() - - startCmd := func() tea.Msg { - return msgs.TurnStarted{ - UserMessageID: userMessageID, - ConversationID: conversationID, - } - } - - return tea.Batch(r.Init(), r.StartStream(messages, context), startCmd) -} - -// rebuildBlocks collects blocks from the round hierarchy into a flat list -// and syncs the viewport with current heights/gaps. -func (m *Model) rebuildBlocks() { - var entries []blockEntry - for i, r := range m.rounds { - for _, b := range r.Blocks() { - entries = append(entries, blockEntry{block: b, roundIndex: i}) - } - } - m.blocks = entries - m.syncViewportItems() -} - -// syncViewportItems rebuilds the viewport's height/gap slices from the -// current block list. Called after rebuildBlocks and after toggles -// (which change block heights without changing the block list). -func (m *Model) syncViewportItems() { - items := projectItems(m.blocks, m.blockHeight) - m.layout = projectLayout(items, func(roundIndex int) bool { - return m.rounds[roundIndex].IsActive() - }) - m.vp.SetItems(m.layout.heights, m.layout.gapHeights()) - m.vp.SetTrailingHeight(m.layout.trailingHeight()) -} - -// updateRoundWidths sets the width on all rounds. -func (m *Model) updateRoundWidths() { - w := m.contentWidth() - for _, r := range m.rounds { - r.SetWidth(w) - } -} diff --git a/internal/app/chat/messagelist/selection_reducer.go b/internal/app/chat/messagelist/selection_reducer.go deleted file mode 100644 index a0e86b96..00000000 --- a/internal/app/chat/messagelist/selection_reducer.go +++ /dev/null @@ -1,90 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -type selectionState struct { - mouseDown bool - mouseDownBlock int - mouseDownX int - mouseDownY int - mouseDragBlock int - mouseDragX int - mouseDragY int -} - -type selectionPoint struct { - block int - x int - y int -} - -type clickDecision struct { - setFocusIdx bool - focusIdx int -} - -type releaseDecision struct { - handle bool - clickBlock int - clickY int -} - -type releaseAction int - -const ( - releaseActionNoop releaseAction = iota - releaseActionCopy - releaseActionClick -) - -func reduceSelectionClick(state selectionState, button tea.MouseButton, point selectionPoint, hit bool) (selectionState, clickDecision) { - if button != tea.MouseLeft || !hit { - return state, clickDecision{} - } - - state.mouseDown = true - state.mouseDownBlock = point.block - state.mouseDownX = point.x - state.mouseDownY = point.y - state.mouseDragBlock = point.block - state.mouseDragX = point.x - state.mouseDragY = point.y - - return state, clickDecision{setFocusIdx: true, focusIdx: point.block} -} - -func reduceSelectionMotion(state selectionState, button tea.MouseButton, point selectionPoint, hit bool) selectionState { - if !state.mouseDown || button != tea.MouseLeft || !hit { - return state - } - - state.mouseDragBlock = point.block - state.mouseDragX = point.x - state.mouseDragY = point.y - return state -} - -func reduceSelectionRelease(state selectionState) (selectionState, releaseDecision) { - if !state.mouseDown { - return state, releaseDecision{} - } - - decision := releaseDecision{ - handle: true, - clickBlock: state.mouseDownBlock, - clickY: state.mouseDownY, - } - state.mouseDown = false - return state, decision -} - -func reduceReleaseAction(hasHighlight bool, highlightedText string) releaseAction { - if !hasHighlight { - return releaseActionClick - } - if highlightedText == "" { - // Treat empty extraction as a click so collapsible blocks still toggle. - return releaseActionClick - } - return releaseActionCopy -} diff --git a/internal/app/chat/messagelist/selection_reducer_test.go b/internal/app/chat/messagelist/selection_reducer_test.go deleted file mode 100644 index 69f06fef..00000000 --- a/internal/app/chat/messagelist/selection_reducer_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package messagelist - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -func TestReduceSelectionClick(t *testing.T) { - t.Parallel() - - t.Run("non-left click ignored", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseRight, selectionPoint{block: 2, x: 4, y: 5}, true) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.setFocusIdx { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("miss click ignored", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseLeft, selectionPoint{block: -1, x: 4, y: 5}, false) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.setFocusIdx { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("left click sets anchor and focus", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDownBlock: -1, mouseDragBlock: -1} - next, d := reduceSelectionClick(start, tea.MouseLeft, selectionPoint{block: 3, x: 7, y: 9}, true) - if !next.mouseDown || next.mouseDownBlock != 3 || next.mouseDownX != 7 || next.mouseDownY != 9 { - t.Fatalf("unexpected down state: %+v", next) - } - if next.mouseDragBlock != 3 || next.mouseDragX != 7 || next.mouseDragY != 9 { - t.Fatalf("unexpected drag state: %+v", next) - } - if !d.setFocusIdx || d.focusIdx != 3 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceSelectionMotion(t *testing.T) { - t.Parallel() - - t.Run("ignored when not dragging", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: false, mouseDragBlock: 1} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: 2, x: 6, y: 8}, true) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - }) - - t.Run("ignored when no hit", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: true, mouseDragBlock: 1} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: -1, x: 6, y: 8}, false) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - }) - - t.Run("updates drag cursor", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: true, mouseDragBlock: 1, mouseDragX: 2, mouseDragY: 3} - next := reduceSelectionMotion(start, tea.MouseLeft, selectionPoint{block: 4, x: 10, y: 11}, true) - if next.mouseDragBlock != 4 || next.mouseDragX != 10 || next.mouseDragY != 11 { - t.Fatalf("unexpected drag state: %+v", next) - } - }) -} - -func TestReduceSelectionRelease(t *testing.T) { - t.Parallel() - - t.Run("ignored when not dragging", func(t *testing.T) { - t.Parallel() - start := selectionState{mouseDown: false} - next, d := reduceSelectionRelease(start) - if next != start { - t.Fatalf("state changed unexpectedly: %+v -> %+v", start, next) - } - if d.handle { - t.Fatalf("unexpected decision: %+v", d) - } - }) - - t.Run("release clears mouseDown and preserves click anchor", func(t *testing.T) { - t.Parallel() - start := selectionState{ - mouseDown: true, - mouseDownBlock: 5, - mouseDownY: 12, - mouseDragBlock: 7, - } - next, d := reduceSelectionRelease(start) - if next.mouseDown { - t.Fatalf("mouseDown should be false: %+v", next) - } - if !d.handle || d.clickBlock != 5 || d.clickY != 12 { - t.Fatalf("unexpected decision: %+v", d) - } - }) -} - -func TestReduceReleaseAction(t *testing.T) { - t.Parallel() - - t.Run("no highlight becomes click", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(false, "") - if got != releaseActionClick { - t.Fatalf("action=%v, want %v", got, releaseActionClick) - } - }) - - t.Run("empty extracted highlight becomes click", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(true, "") - if got != releaseActionClick { - t.Fatalf("action=%v, want %v", got, releaseActionClick) - } - }) - - t.Run("non-empty highlight becomes copy", func(t *testing.T) { - t.Parallel() - got := reduceReleaseAction(true, "hello") - if got != releaseActionCopy { - t.Fatalf("action=%v, want %v", got, releaseActionCopy) - } - }) -} diff --git a/internal/app/chat/messagelist/update.go b/internal/app/chat/messagelist/update.go deleted file mode 100644 index 7fbbbd47..00000000 --- a/internal/app/chat/messagelist/update.go +++ /dev/null @@ -1,40 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyPressMsg: - m.handleKeyPress(msg) - - case tea.MouseClickMsg: - m.handleMouseClick(msg) - - case tea.MouseMotionMsg: - m.handleMouseMotion(msg) - - case tea.MouseReleaseMsg: - cmds = append(cmds, m.handleMouseRelease(msg)...) - - case tea.MouseWheelMsg: - m.handleMouseWheel(msg) - - default: - if lifecycleCmds, handled := m.handleLifecycle(msg); handled { - cmds = append(cmds, lifecycleCmds...) - return tea.Batch(cmds...) - } - } - - // Forward to all rounds - for _, r := range m.rounds { - if cmd := r.Update(msg); cmd != nil { - cmds = append(cmds, cmd) - } - } - - return tea.Batch(cmds...) -} diff --git a/internal/app/chat/messagelist/update_key.go b/internal/app/chat/messagelist/update_key.go deleted file mode 100644 index b7ff1fa0..00000000 --- a/internal/app/chat/messagelist/update_key.go +++ /dev/null @@ -1,16 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -func (m *Model) handleKeyPress(msg tea.KeyPressMsg) { - decision := reduceKeyPress(msg, m.focused) - if decision.focusDelta < 0 { - m.vp.FocusPrev() - } else if decision.focusDelta > 0 { - m.vp.FocusNext() - } - if decision.scrollDelta != 0 { - m.vp.ScrollBy(decision.scrollDelta) - m.vp.UpdateFocusFromScroll() - } -} diff --git a/internal/app/chat/messagelist/update_lifecycle.go b/internal/app/chat/messagelist/update_lifecycle.go deleted file mode 100644 index 1615d953..00000000 --- a/internal/app/chat/messagelist/update_lifecycle.go +++ /dev/null @@ -1,34 +0,0 @@ -package messagelist - -import tea "charm.land/bubbletea/v2" - -func (m *Model) handleLifecycle(msg tea.Msg) ([]tea.Cmd, bool) { - decision := reduceLifecycle(msg, m.vp.AtBottom()) - if !decision.handle { - return nil, false - } - - var cmds []tea.Cmd - if decision.forwardRounds { - // Forward to rounds first so streaming state is updated - // before rebuildBlocks reads Blocks(). - for _, r := range m.rounds { - if cmd := r.Update(msg); cmd != nil { - cmds = append(cmds, cmd) - } - } - } - if decision.clearSelection { - m.clearSelection() - } - if decision.rebuild { - m.rebuildBlocks() - } - if decision.scrollToBottom { - m.vp.ScrollToBottom() - } - if decision.focusLastAtBottom && len(m.blocks) > 0 { - m.vp.SetFocusIdx(len(m.blocks) - 1) - } - return cmds, true -} diff --git a/internal/app/chat/messagelist/update_mouse.go b/internal/app/chat/messagelist/update_mouse.go deleted file mode 100644 index 1fd6d602..00000000 --- a/internal/app/chat/messagelist/update_mouse.go +++ /dev/null @@ -1,89 +0,0 @@ -package messagelist - -import ( - tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" - appevents "github.com/usetero/cli/internal/app/events" -) - -func (m *Model) handleMouseClick(msg tea.MouseClickMsg) { - if msg.Button != tea.MouseLeft { - return - } - - target := resolveMouseTarget(msg.X, msg.Y, m.originX, m.originY, m.vp.ItemAtY) - m.scope.Debug("click", - "msgX", msg.X, "msgY", msg.Y, - "originX", m.originX, "originY", m.originY, - "viewX", target.viewX, "viewY", target.viewY, - "blockIdx", target.blockIdx, "blockY", target.blockY, - "numBlocks", len(m.blocks), - "vpHeight", m.height) - - state, decision := reduceSelectionClick( - m.selectionState(), - msg.Button, - selectionPoint{block: target.blockIdx, x: target.viewX, y: target.blockY}, - target.hit, - ) - m.setSelectionState(state) - if decision.setFocusIdx { - m.vp.SetFocusIdx(decision.focusIdx) - } -} - -func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) { - target := resolveMouseTarget(msg.X, msg.Y, m.originX, m.originY, m.vp.ItemAtY) - state := reduceSelectionMotion( - m.selectionState(), - msg.Button, - selectionPoint{block: target.blockIdx, x: target.viewX, y: target.blockY}, - target.hit, - ) - m.setSelectionState(state) -} - -func (m *Model) handleMouseRelease(_ tea.MouseReleaseMsg) []tea.Cmd { - state, decision := reduceSelectionRelease(m.selectionState()) - m.setSelectionState(state) - if !decision.handle { - return nil - } - - hl := m.hasHighlight() - text := "" - if hl { - text = m.extractHighlight() - } - action := reduceReleaseAction(hl, text) - m.scope.Debug("release", - "mouseDownBlock", m.mouseDownBlock, - "hasHighlight", hl, - "action", action, - "downX", m.mouseDownX, "downY", m.mouseDownY, - "dragBlock", m.mouseDragBlock, - "dragX", m.mouseDragX, "dragY", m.mouseDragY) - - switch action { - case releaseActionNoop: - return nil - case releaseActionCopy: - return []tea.Cmd{ - tea.SetClipboard(text), - func() tea.Msg { - _ = clipboard.WriteAll(text) - return appevents.SuccessToastPublished{Message: "Copied to clipboard"} - }, - } - case releaseActionClick: - m.handleBlockClick(decision.clickBlock, decision.clickY) - } - return nil -} - -func (m *Model) handleMouseWheel(msg tea.MouseWheelMsg) { - if delta := reduceMouseWheel(msg.Button); delta != 0 { - m.vp.ScrollBy(delta) - m.vp.UpdateFocusFromScroll() - } -} diff --git a/internal/app/chat/messagelist/update_test.go b/internal/app/chat/messagelist/update_test.go deleted file mode 100644 index c1cb9124..00000000 --- a/internal/app/chat/messagelist/update_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package messagelist - -import ( - "context" - "testing" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/messagelist/block" - "github.com/usetero/cli/internal/app/chat/messagelist/round" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" - "github.com/usetero/cli/internal/styles" -) - -func hasBlockKind(entries []blockEntry, kind block.Kind) bool { - for _, e := range entries { - if e.block.Kind() == kind { - return true - } - } - return false -} - -func countBlockKind(entries []blockEntry, kind block.Kind) int { - n := 0 - for _, e := range entries { - if e.block.Kind() == kind { - n++ - } - } - return n -} - -// newStreamingMessageList creates a messagelist with a mock client that streams -// one text block then blocks forever. Suitable for testing cancel and thinking lifecycle. -func newStreamingMessageList(t *testing.T) *Model { - t.Helper() - theme := styles.NewTheme(true) - scope := logtest.NewScope(t) - - client := &chattest.MockClient{ - StreamFunc: func(_ context.Context, _ chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - msg := &domain.Message{ID: "asst-1", Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }} - onMessage(msg) - // Block forever to simulate in-progress stream - select {} - }, - } - runtimeDeps := usecase.NewRuntimeDeps(client) - - m := New(theme, runtimeDeps, nil, scope) - m.SetSize(80, 40) - return m -} - -func TestUpdate_StreamCompleted(t *testing.T) { - t.Parallel() - - t.Run("thinking animation removed after StreamCompleted", func(t *testing.T) { - t.Parallel() - - m := newStreamingMessageList(t) - - userMsgID := domain.MessageID("user-1") - m.StartTurn("conv-1", "acct-1", userMsgID, msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - // After StartTurn, thinking animation should be present. - if !hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Fatal("expected thinking animation block after StartTurn") - } - - // StreamCompleted must update streaming state before rebuildBlocks reads it. - m.Update(msgs.StreamCompleted{ - TurnID: userMsgID, - Message: domain.Message{ - ID: "asst-1", - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }, - }, - }) - - // Thinking animation should be gone. - if hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Error("thinking animation block should be removed after StreamCompleted") - } - - // Text content should remain. - if !hasBlockKind(m.blocks, block.KindAssistantText) { - t.Error("expected text block to remain after StreamCompleted") - } - }) -} - -func TestCancelActiveRound(t *testing.T) { - t.Parallel() - - t.Run("cancels active round and removes thinking", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - if !hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Fatal("expected thinking animation block after StartTurn") - } - - m.CancelActiveRound() - - if hasBlockKind(m.blocks, block.KindThinkingAnimation) { - t.Error("thinking animation should be removed after cancel") - } - if m.rounds[0].State() != round.StateCancelled { - t.Errorf("expected StateCancelled, got %d", m.rounds[0].State()) - } - }) - - t.Run("no-op when no rounds exist", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.CancelActiveRound() // should not panic - - if len(m.rounds) != 0 { - t.Error("expected no rounds") - } - }) - - t.Run("no-op when last round is complete", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "hi"}, nil, nil) - - // Complete it via StreamCompleted - m.Update(msgs.StreamCompleted{ - TurnID: "user-1", - Message: domain.Message{ID: "asst-1", StopReason: "end_turn"}, - }) - - m.CancelActiveRound() // should not change state - - if m.rounds[0].State() != round.StateComplete { - t.Errorf("expected StateComplete (unchanged), got %d", m.rounds[0].State()) - } - }) - - t.Run("each active round has one thinking block", func(t *testing.T) { - t.Parallel() - m := newStreamingMessageList(t) - - m.StartTurn("conv-1", "acct-1", "user-1", msgs.UserSubmittedInput{Text: "first"}, nil, nil) - m.StartTurn("conv-1", "acct-1", "user-2", msgs.UserSubmittedInput{Text: "second"}, nil, nil) - - count := countBlockKind(m.blocks, block.KindThinkingAnimation) - if count != 2 { - t.Errorf("expected 2 thinking blocks (one per active round), got %d", count) - } - }) -} diff --git a/internal/app/chat/model.go b/internal/app/chat/model.go deleted file mode 100644 index 44f231a1..00000000 --- a/internal/app/chat/model.go +++ /dev/null @@ -1,123 +0,0 @@ -package chat - -import ( - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - "github.com/usetero/cli/internal/app/chat/inputbar" - "github.com/usetero/cli/internal/app/chat/messagelist" - "github.com/usetero/cli/internal/app/chat/usecase" - "github.com/usetero/cli/internal/app/chattools" - "github.com/usetero/cli/internal/auth" - graphql "github.com/usetero/cli/internal/boundary/graphql" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" - "github.com/usetero/cli/internal/styles" -) - -const dbOpTimeout = 2 * time.Second - -// Chat-specific key bindings. -var ( - scrollUp = key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑↓", "scroll"), - ) - focusInputBar = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus input"), - ) - focusChat = key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "focus chat"), - ) -) - -// focus tracks which component has keyboard focus. -type focus int - -const ( - focusEditor focus = iota - focusMessages -) - -// Model is the main chat model. -// It is a flexible component - it renders exactly the size given by SetSize. -type Model struct { - scope log.Scope - focus focus - - inputBar *inputbar.Model - messageList *messagelist.Model - - // Conversation is created lazily on first message - conversationID domain.ConversationID - session *corechat.Session - - user *auth.User - account domain.Account - theme styles.Theme - width int - height int - originX int - originY int - - // Empty state - policySummary *domain.AccountSummary - - // Dependencies - services graphql.ServiceSet - runtimeDeps usecase.RuntimeDeps - toolRegistry *tools.Registry -} - -// emptyStatePollTickMsg triggers a policy summary fetch for the empty state. -type emptyStatePollTickMsg struct{} - -// emptyStateSummaryLoadedMsg carries an async empty-state summary fetch result. -type emptyStateSummaryLoadedMsg struct { - summary domain.AccountSummary - err error -} - -// New creates a new chat model. -func New( - user *auth.User, - account domain.Account, - theme styles.Theme, - services graphql.ServiceSet, - runtimeDeps usecase.RuntimeDeps, - toolRegistry *tools.Registry, - scope log.Scope, -) *Model { - scope = scope.Child("chat") - - return &Model{ - scope: scope, - inputBar: inputbar.New(user, theme, scope), - messageList: messagelist.New(theme, runtimeDeps, toolRegistry, scope), - user: user, - account: account, - theme: theme, - services: services, - runtimeDeps: runtimeDeps, - toolRegistry: toolRegistry, - } -} - -// Init initializes the model. -func (m *Model) Init() tea.Cmd { - return tea.Batch( - m.inputBar.Init(), - m.pollEmptyState(), - ) -} - -func (m *Model) pollEmptyState() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return emptyStatePollTickMsg{} - }) -} diff --git a/internal/app/chat/update.go b/internal/app/chat/update.go deleted file mode 100644 index a2e66c1e..00000000 --- a/internal/app/chat/update.go +++ /dev/null @@ -1,107 +0,0 @@ -package chat - -import ( - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - "github.com/usetero/cli/internal/app/chat/usecase" - appevents "github.com/usetero/cli/internal/app/events" -) - -// Update handles messages. -func (m *Model) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyPressMsg: - res := m.handleKeyPress(msg) - if res.stop { - return res.cmd - } - if res.handled && res.cmd != nil { - cmds = append(cmds, res.cmd) - } - - case emptyStatePollTickMsg: - return m.handleEmptyStatePoll() - - case emptyStateSummaryLoadedMsg: - m.handleEmptyStateSummary(msg) - return nil - - case tea.MouseClickMsg: - if cmd := m.handleMouseClick(msg); cmd != nil { - cmds = append(cmds, cmd) - } - default: - res := m.handleLifecycleMessage(msg) - if res.stop { - return res.cmd - } - if res.handled && res.cmd != nil { - cmds = append(cmds, res.cmd) - } - } - - // Forward to children. - cmds = append(cmds, m.inputBar.Update(msg)) - cmds = append(cmds, m.messageList.Update(msg)) - - return tea.Batch(cmds...) -} - -func (m *Model) handleStreamFailed(msg msgs.StreamFailed) tea.Cmd { - m.scope.Warn("stream failed", "class", usecase.ClassifyStreamError(m.runtimeDeps.StreamErrorMapper, msg.Err), "error", msg.Err) - - // Forward to round so it transitions to StateFailed. - cmds := []tea.Cmd{m.messageList.Update(msg)} - - // Clean up orphaned messages from DB (async to avoid blocking UI). - if last := m.messageList.LastRound(); last != nil { - ids := last.LastTurnMessageIDs() - if len(ids) > 0 { - if m.session != nil { - m.session.RemoveMessagesByID(ids) - } - cmds = append(cmds, m.cleanupOrphanedMessages(ids)) - } - - // Turn 1: remove round entirely, input bar restores text via pendingText. - if !last.HasAssistantContent() { - m.messageList.RemoveLastRound() - } - // Turn 2+: round stays visible with red error divider. - } - - cmds = append(cmds, m.inputBar.Update(msg)) - cmds = append(cmds, appevents.PublishErrorToastCmd(usecase.UserFacingStreamError(m.runtimeDeps.StreamErrorMapper, msg.Err), msg.Err, false)) - return tea.Batch(cmds...) -} - -// toggleFocus switches focus between editor and messages. -func (m *Model) toggleFocus() tea.Cmd { - switch m.focus { - case focusEditor: - if !m.hasMessages() { - return nil // nothing to focus - } - return m.setFocus(focusMessages) - default: - return m.setFocus(focusEditor) - } -} - -// setFocus sets focus to the given target. -func (m *Model) setFocus(f focus) tea.Cmd { - m.focus = f - switch f { - case focusEditor: - m.messageList.SetFocused(false) - return m.inputBar.Focus() - case focusMessages: - m.inputBar.Blur() - m.messageList.SetFocused(true) - return nil - } - return nil -} diff --git a/internal/app/chat/update_handlers.go b/internal/app/chat/update_handlers.go deleted file mode 100644 index 8ff30d6c..00000000 --- a/internal/app/chat/update_handlers.go +++ /dev/null @@ -1,114 +0,0 @@ -package chat - -import ( - "context" - "time" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - - msgs "github.com/usetero/cli/internal/app/chat/events" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/tea/keymap" -) - -type updateDispatch struct { - cmd tea.Cmd - handled bool - stop bool -} - -func (m *Model) handleKeyPress(msg tea.KeyPressMsg) updateDispatch { - if key.Matches(msg, keymap.Tab) { - return updateDispatch{cmd: m.toggleFocus(), handled: true, stop: true} - } - - if m.focus == focusMessages { - // Enter or esc returns to editor. - if key.Matches(msg, keymap.Send) || key.Matches(msg, keymap.Exit) { - return updateDispatch{cmd: m.setFocus(focusEditor), handled: true, stop: true} - } - // Only forward to message list when it's focused. - return updateDispatch{cmd: m.messageList.Update(msg), handled: true, stop: true} - } - - return updateDispatch{} -} - -func (m *Model) handleEmptyStatePoll() tea.Cmd { - if m.hasMessages() || m.services.Status == nil { - return nil // stop polling once messages exist - } - return tea.Batch(m.fetchEmptyStateSummary(), m.pollEmptyState()) -} - -func (m *Model) fetchEmptyStateSummary() tea.Cmd { - status := m.services.Status - return func() tea.Msg { - ctx, cancel := context.WithTimeout(m.runtimeDeps.EffectContext, time.Second) - defer cancel() - summary, err := status.GetAccountSummary(ctx) - return emptyStateSummaryLoadedMsg{summary: summary, err: err} - } -} - -func (m *Model) handleEmptyStateSummary(msg emptyStateSummaryLoadedMsg) { - if msg.err != nil { - return - } - m.policySummary = &msg.summary -} - -func (m *Model) handleMouseClick(msg tea.MouseClickMsg) tea.Cmd { - // Click on the message list area focuses it. - if m.hasMessages() && msg.Y >= m.originY && msg.Y < m.originY+m.height-m.inputBar.Height() { - if m.focus != focusMessages { - return m.setFocus(focusMessages) - } - return nil - } - if msg.Y >= m.originY+m.height-m.inputBar.Height() && m.focus != focusEditor { - return m.setFocus(focusEditor) - } - return nil -} - -func (m *Model) handleLifecycleMessage(msg tea.Msg) updateDispatch { - switch msg := msg.(type) { - case msgs.UserSubmittedInput: - return updateDispatch{cmd: m.handleUserInput(msg), handled: true} - - case conversationCreated: - m.scope.Info("conversation created", "id", msg.conversationID) - m.conversationID = msg.conversationID - return updateDispatch{cmd: m.persistUserMessage(msg.input), handled: true} - - case userMessagePersisted: - m.scope.Debug("received userMessagePersisted", "message_id", msg.messageID) - return updateDispatch{cmd: m.handlePersistedMessage(msg), handled: true} - - case msgs.StreamCompleted: - if m.session != nil && m.messageList.HasTurn(msg.TurnID) { - m.session.RecordAssistantMessage(msg.Message) - } - return updateDispatch{handled: true} - - case msgs.ToolResultMessagePersisted: - if m.session == nil { - m.session = corechat.NewSession(m.conversationID, nil) - } - m.session.AppendMessage(msg.Message) - return updateDispatch{handled: true} - - case msgs.StreamFailed: - return updateDispatch{cmd: m.handleStreamFailed(msg), handled: true, stop: true} - - case orphanedMessagesCleanupCompleted: - if msg.err != nil { - m.scope.Error("failed to cleanup orphaned messages", "count", len(msg.ids), "error", msg.err) - } - return updateDispatch{handled: true} - } - - return updateDispatch{} -} diff --git a/internal/app/chat/usecase/assistant_persistence.go b/internal/app/chat/usecase/assistant_persistence.go deleted file mode 100644 index 872b3edb..00000000 --- a/internal/app/chat/usecase/assistant_persistence.go +++ /dev/null @@ -1,30 +0,0 @@ -package usecase - -import ( - "context" - - "github.com/usetero/cli/internal/domain" -) - -type PersistAssistantInput struct { - AccountID domain.AccountID - ConversationID domain.ConversationID - Message domain.Message -} - -type AssistantPersister interface { - PersistAssistant(ctx context.Context, input PersistAssistantInput) (domain.MessageID, error) -} - -// MemoryAssistantPersister mints assistant message IDs without persisting. -// Chat is ephemeral: the rendered content lives in the in-memory message list -// for the duration of the session and is intentionally not stored. -type MemoryAssistantPersister struct{} - -func NewMemoryAssistantPersister() *MemoryAssistantPersister { - return &MemoryAssistantPersister{} -} - -func (p *MemoryAssistantPersister) PersistAssistant(_ context.Context, _ PersistAssistantInput) (domain.MessageID, error) { - return domain.NewMessageID(), nil -} diff --git a/internal/app/chat/usecase/assistant_persistence_test.go b/internal/app/chat/usecase/assistant_persistence_test.go deleted file mode 100644 index eaa03311..00000000 --- a/internal/app/chat/usecase/assistant_persistence_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestMemoryAssistantPersister_PersistAssistant_MintsID(t *testing.T) { - t.Parallel() - - p := NewMemoryAssistantPersister() - - msgID, err := p.PersistAssistant(context.Background(), PersistAssistantInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Message: domain.Message{ - Model: "claude-3", - StopReason: "end_turn", - Content: []domain.Block{ - {Index: 0, Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hello"}}, - }, - }, - }) - if err != nil { - t.Fatalf("PersistAssistant() error = %v", err) - } - if msgID == "" { - t.Fatal("PersistAssistant() returned empty message ID") - } -} - -func TestMemoryAssistantPersister_PersistAssistant_UniqueIDs(t *testing.T) { - t.Parallel() - - p := NewMemoryAssistantPersister() - input := PersistAssistantInput{AccountID: "acct-1", ConversationID: "conv-1"} - - first, err := p.PersistAssistant(context.Background(), input) - if err != nil { - t.Fatalf("PersistAssistant() error = %v", err) - } - second, err := p.PersistAssistant(context.Background(), input) - if err != nil { - t.Fatalf("PersistAssistant() error = %v", err) - } - if first == second { - t.Fatalf("expected unique message IDs, got %q twice", first) - } -} diff --git a/internal/app/chat/usecase/chat_gateway.go b/internal/app/chat/usecase/chat_gateway.go deleted file mode 100644 index b28998fc..00000000 --- a/internal/app/chat/usecase/chat_gateway.go +++ /dev/null @@ -1,12 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" -) - -// ChatGateway is the use-case boundary for chat streaming. -type ChatGateway interface { - StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) -} diff --git a/internal/app/chat/usecase/chat_gateway_boundary.go b/internal/app/chat/usecase/chat_gateway_boundary.go deleted file mode 100644 index 8aadfccf..00000000 --- a/internal/app/chat/usecase/chat_gateway_boundary.go +++ /dev/null @@ -1,29 +0,0 @@ -package usecase - -import ( - "context" - - chatboundary "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" -) - -// ChatBoundaryGateway adapts chatboundary.Client to the use-case ChatGateway. -type ChatBoundaryGateway struct { - client chatboundary.Client -} - -func NewChatBoundaryGateway(client chatboundary.Client) *ChatBoundaryGateway { - return &ChatBoundaryGateway{client: client} -} - -func (g *ChatBoundaryGateway) StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if g == nil || g.client == nil { - return nil, nil - } - wireReq := chatboundary.Request{ - ConversationID: req.ConversationID.String(), - Messages: req.Messages, - ContextEntities: req.ContextEntities, - } - return g.client.StreamSnapshots(ctx, wireReq, onSnapshot) -} diff --git a/internal/app/chat/usecase/chat_gateway_boundary_test.go b/internal/app/chat/usecase/chat_gateway_boundary_test.go deleted file mode 100644 index d135734c..00000000 --- a/internal/app/chat/usecase/chat_gateway_boundary_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - chat "github.com/usetero/cli/internal/boundary/chat" - "github.com/usetero/cli/internal/boundary/chat/chattest" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -func TestChatBoundaryGateway_StreamSnapshots_MapsRequestAndForwards(t *testing.T) { - t.Parallel() - - var captured chat.Request - wantMsg := &domain.Message{ID: "asst-1"} - client := &chattest.MockClient{ - StreamSnapshotsFunc: func(_ context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - captured = req - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID, - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: wantMsg, - }) - return &corechat.StreamResult{Message: wantMsg}, nil - }, - } - - gateway := NewChatBoundaryGateway(client) - var gotSnapshots []corechat.StreamSnapshot - result, err := gateway.StreamSnapshots(context.Background(), StreamRequest{ - ConversationID: "conv-1", - Messages: []domain.Message{{ID: "user-1", Role: domain.RoleUser}}, - }, func(s corechat.StreamSnapshot) { - gotSnapshots = append(gotSnapshots, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - if captured.ConversationID != "conv-1" { - t.Fatalf("conversation_id = %q, want conv-1", captured.ConversationID) - } - if len(captured.Messages) != 1 || captured.Messages[0].ID != "user-1" { - t.Fatalf("messages mapping mismatch: %+v", captured.Messages) - } - if len(gotSnapshots) != 1 || gotSnapshots[0].Message != wantMsg { - t.Fatalf("snapshot forwarding mismatch: %+v", gotSnapshots) - } - if result == nil || result.Message != wantMsg { - t.Fatalf("result forwarding mismatch: %+v", result) - } -} - -func TestChatBoundaryGateway_StreamSnapshots_NilGatewayOrClient(t *testing.T) { - t.Parallel() - - var nilGateway *ChatBoundaryGateway - result, err := nilGateway.StreamSnapshots(context.Background(), StreamRequest{ConversationID: "conv-1"}, nil) - if err != nil || result != nil { - t.Fatalf("nil gateway = (%v, %v), want (nil, nil)", result, err) - } - - gateway := NewChatBoundaryGateway(nil) - result, err = gateway.StreamSnapshots(context.Background(), StreamRequest{ConversationID: "conv-1"}, nil) - if err != nil || result != nil { - t.Fatalf("nil client = (%v, %v), want (nil, nil)", result, err) - } -} diff --git a/internal/app/chat/usecase/orphan_cleanup.go b/internal/app/chat/usecase/orphan_cleanup.go deleted file mode 100644 index a6c328f5..00000000 --- a/internal/app/chat/usecase/orphan_cleanup.go +++ /dev/null @@ -1,25 +0,0 @@ -package usecase - -import ( - "context" - - "github.com/usetero/cli/internal/domain" -) - -// OrphanMessageCleaner removes uncommitted/orphaned messages after cancellation/failure. -type OrphanMessageCleaner interface { - CleanupMessages(ctx context.Context, ids []domain.MessageID) error -} - -// MemoryOrphanMessageCleaner is a no-op cleaner. With ephemeral chat there is -// no persisted store to reconcile; the message list drops cancelled rounds in -// the UI, so orphan cleanup has nothing to do. -type MemoryOrphanMessageCleaner struct{} - -func NewMemoryOrphanMessageCleaner() *MemoryOrphanMessageCleaner { - return &MemoryOrphanMessageCleaner{} -} - -func (c *MemoryOrphanMessageCleaner) CleanupMessages(_ context.Context, _ []domain.MessageID) error { - return nil -} diff --git a/internal/app/chat/usecase/orphan_cleanup_test.go b/internal/app/chat/usecase/orphan_cleanup_test.go deleted file mode 100644 index 9bbda840..00000000 --- a/internal/app/chat/usecase/orphan_cleanup_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestMemoryOrphanMessageCleaner_CleanupMessages_NoOp(t *testing.T) { - t.Parallel() - - cleaner := NewMemoryOrphanMessageCleaner() - - if err := cleaner.CleanupMessages(context.Background(), []domain.MessageID{"msg-1", "msg-2"}); err != nil { - t.Fatalf("CleanupMessages() error = %v", err) - } -} - -func TestMemoryOrphanMessageCleaner_CleanupMessages_EmptyIDs(t *testing.T) { - t.Parallel() - - cleaner := NewMemoryOrphanMessageCleaner() - - if err := cleaner.CleanupMessages(context.Background(), nil); err != nil { - t.Fatalf("CleanupMessages() error = %v", err) - } -} diff --git a/internal/app/chat/usecase/runtime_deps.go b/internal/app/chat/usecase/runtime_deps.go deleted file mode 100644 index 16843053..00000000 --- a/internal/app/chat/usecase/runtime_deps.go +++ /dev/null @@ -1,41 +0,0 @@ -package usecase - -import ( - "context" - - chatboundary "github.com/usetero/cli/internal/boundary/chat" -) - -// RuntimeDeps contains orchestration dependencies for app/chat. -type RuntimeDeps struct { - StreamRunner StreamRunner - StreamErrorMapper StreamErrorMapper - AssistantPersister AssistantPersister - ToolLoop ToolLoop - OrphanCleaner OrphanMessageCleaner - EffectContext context.Context -} - -// NewRuntimeDeps wires the chat orchestration dependencies. Chat is ephemeral: -// messages live only in memory for the session, so the persistence collaborators -// are in-memory stand-ins rather than SQLite-backed stores. -func NewRuntimeDeps(client chatboundary.Client) RuntimeDeps { - gateway := NewChatBoundaryGateway(client) - return RuntimeDeps{ - StreamRunner: NewChatStreamRunner(gateway), - StreamErrorMapper: NewChatBoundaryStreamErrorMapper(), - AssistantPersister: NewMemoryAssistantPersister(), - ToolLoop: NewMemoryToolLoop(), - OrphanCleaner: NewMemoryOrphanMessageCleaner(), - EffectContext: context.Background(), - } -} - -// WithEffectContext returns a copy that uses ctx as the base for UI-triggered effects. -func (d RuntimeDeps) WithEffectContext(ctx context.Context) RuntimeDeps { - if ctx == nil { - ctx = context.Background() - } - d.EffectContext = ctx - return d -} diff --git a/internal/app/chat/usecase/stream_errors.go b/internal/app/chat/usecase/stream_errors.go deleted file mode 100644 index b5b89636..00000000 --- a/internal/app/chat/usecase/stream_errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package usecase - -type StreamErrorMapper interface { - Classify(err error) string - UserFacing(err error) string -} - -func ClassifyStreamError(mapper StreamErrorMapper, err error) string { - if mapper == nil { - return "unknown" - } - return mapper.Classify(err) -} - -func UserFacingStreamError(mapper StreamErrorMapper, err error) string { - if mapper == nil { - return "Chat stream failed. Please try again." - } - return mapper.UserFacing(err) -} diff --git a/internal/app/chat/usecase/stream_errors_boundary.go b/internal/app/chat/usecase/stream_errors_boundary.go deleted file mode 100644 index b2742733..00000000 --- a/internal/app/chat/usecase/stream_errors_boundary.go +++ /dev/null @@ -1,17 +0,0 @@ -package usecase - -import chatboundary "github.com/usetero/cli/internal/boundary/chat" - -type ChatBoundaryStreamErrorMapper struct{} - -func NewChatBoundaryStreamErrorMapper() *ChatBoundaryStreamErrorMapper { - return &ChatBoundaryStreamErrorMapper{} -} - -func (m *ChatBoundaryStreamErrorMapper) Classify(err error) string { - return string(chatboundary.ClassifyStreamError(err)) -} - -func (m *ChatBoundaryStreamErrorMapper) UserFacing(err error) string { - return chatboundary.UserFacingStreamError(err) -} diff --git a/internal/app/chat/usecase/stream_errors_boundary_test.go b/internal/app/chat/usecase/stream_errors_boundary_test.go deleted file mode 100644 index e611a6ae..00000000 --- a/internal/app/chat/usecase/stream_errors_boundary_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package usecase - -import ( - "context" - "testing" -) - -func TestChatBoundaryStreamErrorMapper(t *testing.T) { - t.Parallel() - - m := NewChatBoundaryStreamErrorMapper() - if got := m.Classify(context.Canceled); got == "" { - t.Fatal("classify should not be empty") - } - if got := m.UserFacing(context.Canceled); got == "" { - t.Fatal("user-facing should not be empty") - } -} diff --git a/internal/app/chat/usecase/stream_errors_test.go b/internal/app/chat/usecase/stream_errors_test.go deleted file mode 100644 index 926ef3d7..00000000 --- a/internal/app/chat/usecase/stream_errors_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package usecase - -import ( - "errors" - "testing" -) - -type fakeErrorMapper struct { - classify string - userFacing string -} - -func (f fakeErrorMapper) Classify(error) string { return f.classify } -func (f fakeErrorMapper) UserFacing(error) string { return f.userFacing } - -func TestClassifyStreamError(t *testing.T) { - t.Parallel() - - if got := ClassifyStreamError(nil, errors.New("x")); got != "unknown" { - t.Fatalf("nil mapper classify = %q, want unknown", got) - } - if got := ClassifyStreamError(fakeErrorMapper{classify: "timeout"}, errors.New("x")); got != "timeout" { - t.Fatalf("classify = %q, want timeout", got) - } -} - -func TestUserFacingStreamError(t *testing.T) { - t.Parallel() - - if got := UserFacingStreamError(nil, errors.New("x")); got == "" { - t.Fatal("nil mapper user-facing should not be empty") - } - if got := UserFacingStreamError(fakeErrorMapper{userFacing: "Readable"}, errors.New("x")); got != "Readable" { - t.Fatalf("user-facing = %q, want Readable", got) - } -} diff --git a/internal/app/chat/usecase/stream_request.go b/internal/app/chat/usecase/stream_request.go deleted file mode 100644 index d4555e8d..00000000 --- a/internal/app/chat/usecase/stream_request.go +++ /dev/null @@ -1,10 +0,0 @@ -package usecase - -import "github.com/usetero/cli/internal/domain" - -// StreamRequest is the app-level request for one assistant turn stream. -type StreamRequest struct { - ConversationID domain.ConversationID - Messages []domain.Message - ContextEntities []domain.ContextEntity -} diff --git a/internal/app/chat/usecase/stream_runner.go b/internal/app/chat/usecase/stream_runner.go deleted file mode 100644 index f7beac4c..00000000 --- a/internal/app/chat/usecase/stream_runner.go +++ /dev/null @@ -1,75 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// StreamUpdate is a normalized stream event consumed by UI models. -type StreamUpdate struct { - Message *domain.Message - Status corechat.StreamStatus - AbortReason string - Result *corechat.StreamResult - Err error - Done bool -} - -// StreamRunner executes one chat stream request and emits ordered updates. -type StreamRunner interface { - Start(ctx context.Context, req StreamRequest) <-chan StreamUpdate -} - -// ChatStreamRunner bridges boundary chat snapshots into use-case updates. -type ChatStreamRunner struct { - gateway ChatGateway -} - -func NewChatStreamRunner(gateway ChatGateway) *ChatStreamRunner { - return &ChatStreamRunner{gateway: gateway} -} - -func (r *ChatStreamRunner) Start(ctx context.Context, req StreamRequest) <-chan StreamUpdate { - updates := make(chan StreamUpdate, 10) - if r == nil || r.gateway == nil { - close(updates) - return updates - } - - go func() { - defer close(updates) - - var lastSnapshot *corechat.StreamSnapshot - result, err := r.gateway.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - ss := s - lastSnapshot = &ss - if !s.Done { - updates <- StreamUpdate{Message: s.Message, Status: s.Status} - } - }) - if err != nil { - updates <- StreamUpdate{Err: err, Done: true} - return - } - - var finalMsg *domain.Message - var status corechat.StreamStatus - var abort string - if lastSnapshot != nil { - finalMsg = lastSnapshot.Message - status = lastSnapshot.Status - abort = lastSnapshot.AbortReason - } - updates <- StreamUpdate{ - Message: finalMsg, - Status: status, - AbortReason: abort, - Result: result, - Done: true, - } - }() - - return updates -} diff --git a/internal/app/chat/usecase/stream_runner_test.go b/internal/app/chat/usecase/stream_runner_test.go deleted file mode 100644 index 293bef59..00000000 --- a/internal/app/chat/usecase/stream_runner_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package usecase - -import ( - "context" - "errors" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -type fakeGateway struct { - streamSnapshots func(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) -} - -func (f *fakeGateway) StreamSnapshots(ctx context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if f.streamSnapshots != nil { - return f.streamSnapshots(ctx, req, onSnapshot) - } - return &corechat.StreamResult{}, nil -} - -func TestChatStreamRunner_Start_ForwardsSnapshotsAndFinalResult(t *testing.T) { - t.Parallel() - - reqs := make([]StreamRequest, 0, 1) - streamMsg := &domain.Message{ID: "asst-1", Role: domain.RoleAssistant} - finalResult := &corechat.StreamResult{ - Message: streamMsg, - Metadata: &corechat.StreamMetadata{ - Title: "Hello", - ContextWindow: 200000, - InputTokens: 12, - OutputTokens: 4, - }, - } - - gateway := &fakeGateway{ - streamSnapshots: func(_ context.Context, req StreamRequest, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - reqs = append(reqs, req) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID.String(), - TurnID: "turn-1", - Seq: 1, - Status: corechat.StreamStatusStreaming, - Message: streamMsg, - }) - onSnapshot(corechat.StreamSnapshot{ - ConversationID: req.ConversationID.String(), - TurnID: "turn-1", - Seq: 2, - Status: corechat.StreamStatusCompleted, - Done: true, - Message: streamMsg, - Metadata: finalResult.Metadata, - }) - return finalResult, nil - }, - } - - runner := NewChatStreamRunner(gateway) - updates := runner.Start(context.Background(), StreamRequest{ - ConversationID: "conv-1", - Messages: []domain.Message{{ID: "user-1", Role: domain.RoleUser}}, - }) - - var got []StreamUpdate - for u := range updates { - got = append(got, u) - } - if len(got) != 2 { - t.Fatalf("updates = %d, want 2", len(got)) - } - if got[0].Done { - t.Fatal("first update should be non-terminal") - } - if got[0].Status != corechat.StreamStatusStreaming { - t.Fatalf("first status = %q, want %q", got[0].Status, corechat.StreamStatusStreaming) - } - if !got[1].Done { - t.Fatal("last update should be terminal") - } - if got[1].Status != corechat.StreamStatusCompleted { - t.Fatalf("last status = %q, want %q", got[1].Status, corechat.StreamStatusCompleted) - } - if got[1].Result == nil || got[1].Result.Metadata == nil || got[1].Result.Metadata.Title != "Hello" { - t.Fatalf("terminal result metadata missing/invalid: %+v", got[1].Result) - } - if len(reqs) != 1 || reqs[0].ConversationID != "conv-1" { - t.Fatalf("request mapping mismatch: %+v", reqs) - } -} - -func TestChatStreamRunner_Start_EmitsTerminalErrorUpdate(t *testing.T) { - t.Parallel() - - gateway := &fakeGateway{ - streamSnapshots: func(_ context.Context, _ StreamRequest, _ func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - return nil, errors.New("network timeout") - }, - } - - runner := NewChatStreamRunner(gateway) - updates := runner.Start(context.Background(), StreamRequest{ConversationID: "conv-1"}) - var got []StreamUpdate - for u := range updates { - got = append(got, u) - } - if len(got) != 1 { - t.Fatalf("updates = %d, want 1", len(got)) - } - if !got[0].Done { - t.Fatal("error update must be terminal") - } - if got[0].Err == nil || got[0].Err.Error() != "network timeout" { - t.Fatalf("err = %v, want network timeout", got[0].Err) - } -} - -func TestChatStreamRunner_Start_NilClientClosesImmediately(t *testing.T) { - t.Parallel() - - var runner *ChatStreamRunner - updates := runner.Start(context.Background(), StreamRequest{ConversationID: "conv-1"}) - if _, ok := <-updates; ok { - t.Fatal("expected closed updates channel for nil runner") - } -} diff --git a/internal/app/chat/usecase/tool_loop.go b/internal/app/chat/usecase/tool_loop.go deleted file mode 100644 index 1c02636f..00000000 --- a/internal/app/chat/usecase/tool_loop.go +++ /dev/null @@ -1,62 +0,0 @@ -package usecase - -import ( - "context" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -type PrepareNextTurnInput struct { - AccountID domain.AccountID - ConversationID domain.ConversationID - Results []domaintools.Result - Session *corechat.Session -} - -type PreparedNextTurn struct { - MessageID domain.MessageID - Messages []domain.Message - ToolResultMessage domain.Message -} - -type ToolLoop interface { - PrepareNextTurn(ctx context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) -} - -// MemoryToolLoop prepares the next turn entirely in memory, minting a local -// message ID and appending the tool results to the in-memory session. -type MemoryToolLoop struct{} - -func NewMemoryToolLoop() *MemoryToolLoop { - return &MemoryToolLoop{} -} - -func (t *MemoryToolLoop) PrepareNextTurn(_ context.Context, input PrepareNextTurnInput) (PreparedNextTurn, error) { - domainResults := make([]domain.ToolResult, len(input.Results)) - for i, r := range input.Results { - domainResults[i] = domain.ToolResult{ - ToolUseID: r.ToolUseID, - IsError: r.IsError(), - Content: r.ToMap(), - } - if r.Error != nil { - domainResults[i].Error = r.Error.Message - } - } - - msgID := domain.NewMessageID() - - session := input.Session - if session == nil { - session = corechat.NewSession(input.ConversationID, nil) - } - toolResultMessage := session.AppendUserToolResultsMessage(msgID, domainResults) - - return PreparedNextTurn{ - MessageID: msgID, - Messages: session.Messages(), - ToolResultMessage: toolResultMessage, - }, nil -} diff --git a/internal/app/chat/usecase/tool_loop_test.go b/internal/app/chat/usecase/tool_loop_test.go deleted file mode 100644 index 5a8a9754..00000000 --- a/internal/app/chat/usecase/tool_loop_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package usecase - -import ( - "context" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -func TestMemoryToolLoop_PrepareNextTurn_AppendsToolResultToSession(t *testing.T) { - t.Parallel() - - loop := NewMemoryToolLoop() - - session := corechat.NewSession("conv-1", []domain.Message{ - { - ID: "user-1", - Role: domain.RoleUser, - Content: []domain.Block{ - {Type: domain.BlockTypeText, Text: &domain.TextBlock{Content: "hi"}}, - }, - }, - }) - - out, err := loop.PrepareNextTurn(context.Background(), PrepareNextTurnInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Results: []domaintools.Result{ - { - ToolUseID: "tool-1", - Content: map[string]any{"ok": true}, - }, - }, - Session: session, - }) - if err != nil { - t.Fatalf("PrepareNextTurn() error = %v", err) - } - if out.MessageID == "" { - t.Fatal("MessageID is empty") - } - if out.ToolResultMessage.ID != out.MessageID { - t.Fatalf("tool result message id = %q, want %q", out.ToolResultMessage.ID, out.MessageID) - } - if len(out.Messages) != 2 { - t.Fatalf("messages len = %d, want 2", len(out.Messages)) - } - last := out.Messages[len(out.Messages)-1] - if last.Role != domain.RoleUser { - t.Fatalf("last role = %q, want %q", last.Role, domain.RoleUser) - } - if len(last.Content) != 1 || last.Content[0].Type != domain.BlockTypeToolResult { - t.Fatalf("last content mismatch: %+v", last.Content) - } - if last.Content[0].ToolResult == nil || last.Content[0].ToolResult.ToolUseID != "tool-1" { - t.Fatalf("tool_result mismatch: %+v", last.Content[0].ToolResult) - } -} - -func TestMemoryToolLoop_PrepareNextTurn_StartsSessionWhenNil(t *testing.T) { - t.Parallel() - - loop := NewMemoryToolLoop() - - out, err := loop.PrepareNextTurn(context.Background(), PrepareNextTurnInput{ - AccountID: "acct-1", - ConversationID: "conv-1", - Results: []domaintools.Result{ - {ToolUseID: "tool-1", Content: map[string]any{"k": "v"}}, - }, - Session: nil, - }) - if err != nil { - t.Fatalf("PrepareNextTurn() error = %v", err) - } - if out.MessageID == "" { - t.Fatal("expected a generated MessageID") - } - if len(out.Messages) != 1 { - t.Fatalf("messages len = %d, want 1", len(out.Messages)) - } - if out.ToolResultMessage.ID == "" || out.ToolResultMessage.ID != out.MessageID { - t.Fatalf("toolResultMessage ID mismatch: %+v", out.ToolResultMessage) - } -} diff --git a/internal/app/chat/view.go b/internal/app/chat/view.go deleted file mode 100644 index 8727a023..00000000 --- a/internal/app/chat/view.go +++ /dev/null @@ -1,132 +0,0 @@ -package chat - -import ( - "fmt" - "math" - "strings" - - "charm.land/lipgloss/v2" - "github.com/usetero/cli/internal/domain" -) - -// View renders the chat. This is a flexible component - renders exactly to SetSize dimensions. -func (m *Model) View() string { - if m.width == 0 || m.height == 0 { - return "" - } - - // Empty state: context-aware greeting + suggestions + input bar. - if !m.hasMessages() { - emptyHeight := m.height - m.inputBar.Height() - emptyView := lipgloss.NewStyle(). - Width(m.width). - Height(emptyHeight). - Align(lipgloss.Center, lipgloss.Center). - Render(m.emptyStateContent()) - - return lipgloss.JoinVertical(lipgloss.Left, emptyView, m.inputBar.View()) - } - - // Normal state: message list + spacer + input bar. - spacer := lipgloss.NewStyle().Width(m.width).Background(m.theme.Bg).Render("") - return lipgloss.JoinVertical(lipgloss.Left, m.messageList.View(), spacer, m.inputBar.View()) -} - -// emptyStateContent renders the context-aware empty state. -func (m *Model) emptyStateContent() string { - colors := m.theme - text := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) - muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) - - name := "" - if m.user != nil && m.user.FirstName != "" { - name = m.user.FirstName - } - - var headline string - var suggestions []string - - s := m.policySummary - if s == nil { - // Still loading. - if name != "" { - headline = fmt.Sprintf("Hey %s — loading your environment...", name) - } else { - headline = "Loading your environment..." - } - } else if s.ActiveServices == 0 { - // No services enabled — guide them to get started. - if name != "" { - headline = fmt.Sprintf("Welcome, %s — let's get your environment set up.", name) - } else { - headline = "Let's get your environment set up." - } - suggestions = []string{ - "Help me get started", - "What services do I have?", - "What does Tero do?", - } - } else if s.PendingPolicyCount > 0 { - // Pending work. - wp := wastePercent(*s) - if name != "" { - headline = fmt.Sprintf("Hey %s — %d%% waste across your services. Let's dig in:", name, wp) - } else { - headline = fmt.Sprintf("%d%% waste across your services. Let's dig in:", wp) - } - suggestions = []string{ - "Walk me through the pending policies", - "Which policies should I approve first?", - "Show me what's driving the most waste", - } - } else if s.ApprovedPolicyCount > 0 { - // Mid-journey — approved some, none pending. - if name != "" { - headline = fmt.Sprintf("Nice work, %s — %d policies approved. Let's keep going:", name, s.ApprovedPolicyCount) - } else { - headline = fmt.Sprintf("%d policies approved. Let's keep going:", s.ApprovedPolicyCount) - } - suggestions = []string{ - "How are the approved policies performing?", - "Are there any new recommendations?", - "Show me observed savings so far", - } - } else { - // All clean — no pending, no approved. - if name != "" { - headline = fmt.Sprintf("Looking good, %s — I'm watching for changes.", name) - } else { - headline = "Looking good — I'm watching for changes." - } - suggestions = []string{ - "What services are generating the most logs?", - "Show me a summary of my environment", - "Any optimization opportunities?", - } - } - - var lines []string - lines = append(lines, text.Render(headline)) - lines = append(lines, "") - for _, s := range suggestions { - lines = append(lines, muted.Render(" → "+s)) - } - - return strings.Join(lines, "\n") -} - -// wastePercent computes waste % preferring bytes. -func wastePercent(s domain.AccountSummary) int { - if s.TotalBytesPerHour != nil && *s.TotalBytesPerHour > 0 && - s.EstimatedBytesPerHour != nil && *s.EstimatedBytesPerHour > 0 { - return int(math.Round(*s.EstimatedBytesPerHour / *s.TotalBytesPerHour * 100)) - } - if s.EstimatedCostPerHour != nil && s.TotalCostPerHour != nil && *s.TotalCostPerHour > 0 { - return int(math.Round(*s.EstimatedCostPerHour / *s.TotalCostPerHour * 100)) - } - if s.TotalVolumePerHour != nil && *s.TotalVolumePerHour > 0 && - s.EstimatedVolumePerHour != nil && *s.EstimatedVolumePerHour > 0 { - return int(math.Round(*s.EstimatedVolumePerHour / *s.TotalVolumePerHour * 100)) - } - return 0 -} diff --git a/internal/app/chattools/reads.go b/internal/app/chattools/reads.go deleted file mode 100644 index 841e6758..00000000 --- a/internal/app/chattools/reads.go +++ /dev/null @@ -1,214 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain/tools" -) - -// The read tools expose the control-plane catalog to the chat agent over -// GraphQL. Each returns structured rows in the tool result (fed back to the -// model) and a one-line summary for the conversation UI. - -func deref(p *float64) any { - if p == nil { - return nil - } - return *p -} - -// NewListServicesTool lists enabled services with their current status. -func NewListServicesTool(services graphql.ServiceSet) ActionTool { - def := chat.Tool{ - Name: "list_services", - Description: "List enabled services with health, log-event counts, throughput, and cost. Use this to answer questions about the account's services.", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - } - - executor := func(_ json.RawMessage) (tools.Result, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - statuses, err := services.Status.ListServiceStatuses(ctx) - if err != nil { - return tools.Result{}, fmt.Errorf("list services: %w", err) - } - - rows := make([]map[string]any, 0, len(statuses)) - for _, s := range statuses { - rows = append(rows, map[string]any{ - "id": s.ID, - "name": s.Name, - "health": string(s.Health), - "log_events": s.LogEventCount, - "events_per_hour": deref(s.ServiceVolumePerHour), - "cost_per_hour_usd": deref(s.ServiceCostPerHourVolumeUSD), - "analyzed_log_events": s.LogEventAnalyzedCount, - }) - } - return tools.Result{Content: map[string]any{"services": rows, "count": len(rows)}}, nil - } - - return NewActionTool(def, executor, listConfig("Listing services", "services", "service")) -} - -// NewListIssuesTool lists active issues with detail. -func NewListIssuesTool(services graphql.ServiceSet) ActionTool { - def := chat.Tool{ - Name: "list_issues", - Description: "List active issues (highest priority first) with title, priority, owning service, and cost. Use this to answer questions about open issues.", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - } - - executor := func(_ json.RawMessage) (tools.Result, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - issues, err := services.Issues.List(ctx) - if err != nil { - return tools.Result{}, fmt.Errorf("list issues: %w", err) - } - - rows := make([]map[string]any, 0, len(issues)) - for _, i := range issues { - rows = append(rows, map[string]any{ - "id": i.ID, - "display_id": i.DisplayID, - "title": i.Title, - "priority": string(i.Priority), - "service": i.ServiceName, - "cost_per_hour_usd": deref(i.CostPerHour), - }) - } - return tools.Result{Content: map[string]any{"issues": rows, "count": len(rows)}}, nil - } - - return NewActionTool(def, executor, listConfig("Listing issues", "issues", "issue")) -} - -// NewListChecksTool lists product checks with posture. -func NewListChecksTool(services graphql.ServiceSet) ActionTool { - def := chat.Tool{ - Name: "list_checks", - Description: "List product checks with their domain (cost/compliance) and account-scoped posture (open findings, active issues, affected services, current cost).", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - } - - executor := func(_ json.RawMessage) (tools.Result, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - catalog, err := services.Checks.List(ctx) - if err != nil { - return tools.Result{}, fmt.Errorf("list checks: %w", err) - } - - rows := make([]map[string]any, 0, len(catalog.Checks)) - for _, c := range catalog.Checks { - rows = append(rows, map[string]any{ - "id": c.ID, - "name": c.Name, - "domain": string(c.Domain), - "open_findings": c.OpenFindingCount, - "active_issues": c.ActiveIssueCount, - "affected_services": c.AffectedServiceCount, - "cost_per_hour_usd": deref(c.CurrentCostPerHour), - }) - } - return tools.Result{Content: map[string]any{"checks": rows, "count": len(rows)}}, nil - } - - return NewActionTool(def, executor, listConfig("Listing checks", "checks", "check")) -} - -// NewListEdgeInstancesTool lists the account's edge instances. -func NewListEdgeInstancesTool(services graphql.ServiceSet) ActionTool { - def := chat.Tool{ - Name: "list_edge_instances", - Description: "List edge instances syncing policies from this account, with the service they run and when they last synced.", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - } - - executor := func(_ json.RawMessage) (tools.Result, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - fleet, err := services.EdgeInstances.List(ctx) - if err != nil { - return tools.Result{}, fmt.Errorf("list edge instances: %w", err) - } - - rows := make([]map[string]any, 0, len(fleet.Instances)) - for _, inst := range fleet.Instances { - rows = append(rows, map[string]any{ - "id": inst.ID, - "instance_id": inst.InstanceID, - "service": inst.ServiceName, - "namespace": inst.ServiceNamespace, - "last_sync_at": inst.LastSyncAt, - }) - } - return tools.Result{Content: map[string]any{"edge_instances": rows, "count": len(rows)}}, nil - } - - return NewActionTool(def, executor, listConfig("Listing edge instances", "edge instances", "edge instance")) -} - -// NewAccountStatusTool returns the account-level status summary. -func NewAccountStatusTool(services graphql.ServiceSet) ActionTool { - def := chat.Tool{ - Name: "account_status", - Description: "Get the account's overall status: readiness, service counts, log-event coverage, and total throughput/cost. Use this for high-level questions about the account.", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - } - - executor := func(_ json.RawMessage) (tools.Result, error) { - ctx, cancel := withToolTimeout() - defer cancel() - - summary, err := services.Status.GetAccountSummary(ctx) - if err != nil { - return tools.Result{}, fmt.Errorf("account status: %w", err) - } - - return tools.Result{Content: map[string]any{ - "ready_for_use": summary.ReadyForUse, - "health": string(summary.Health), - "services": summary.ServiceCount, - "active_services": summary.ActiveServices, - "ok_services": summary.OkServices, - "disabled_services": summary.DisabledServices, - "inactive_services": summary.InactiveServices, - "log_events": summary.EventCount, - "analyzed_log_events": summary.AnalyzedCount, - "events_per_hour": deref(summary.TotalVolumePerHour), - "cost_per_hour_usd": deref(summary.TotalCostPerHour), - }}, nil - } - - config := action.Config{ - DisplayName: func(json.RawMessage) string { return "Account status" }, - Status: func(json.RawMessage) string { return "Checking account status" }, - Result: func(tools.Result) string { return "Account status" }, - } - return NewActionTool(def, executor, config) -} - -// listConfig builds an action.Config for a list tool with a {count} result. -func listConfig(status, plural, singular string) action.Config { - return action.Config{ - DisplayName: func(json.RawMessage) string { return status }, - Status: func(json.RawMessage) string { return status }, - Result: func(result tools.Result) string { - n, _ := result.Content["count"].(int) - if n == 1 { - return fmt.Sprintf("Found 1 %s", singular) - } - return fmt.Sprintf("Found %d %s", n, plural) - }, - } -} diff --git a/internal/app/chattools/registry.go b/internal/app/chattools/registry.go deleted file mode 100644 index 9c5304fd..00000000 --- a/internal/app/chattools/registry.go +++ /dev/null @@ -1,73 +0,0 @@ -package tools - -import ( - "encoding/json" - - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - domaintools "github.com/usetero/cli/internal/domain/tools" -) - -// ActionTool bundles a definition, executor, and display config for the generic action model. -type ActionTool struct { - Def chat.Tool - Exec action.Executor - Config action.Config -} - -// Registry holds tool instances and provides definitions. -type Registry struct { - actions map[string]ActionTool -} - -// NewRegistry creates a registry from a map of action tools. All chat tools -// are GraphQL-backed action tools; there is no local-SQL query surface. -func NewRegistry(actions map[string]ActionTool) *Registry { - return &Registry{actions: actions} -} - -// Definitions returns tool definitions for the chat API. -func (r *Registry) Definitions() []chat.Tool { - defs := make([]chat.Tool, 0, len(r.actions)) - for _, a := range r.actions { - defs = append(defs, a.Def) - } - return defs -} - -// Lookup returns the action tool for the given name. -func (r *Registry) Lookup(name string) (ActionTool, bool) { - a, ok := r.actions[name] - return a, ok -} - -// NewActionTool is a helper to build an ActionTool from parts. -func NewActionTool( - def chat.Tool, - executor func(input json.RawMessage) (domaintools.Result, error), - config action.Config, -) ActionTool { - return ActionTool{ - Def: def, - Exec: executor, - Config: config, - } -} - -// UnknownTool returns an ActionTool for tools not in the registry. -// It shows the tool name and completes with an error so the result -// is reported back to the model. -func UnknownTool(name string) ActionTool { - return ActionTool{ - Exec: func(_ json.RawMessage) (domaintools.Result, error) { - return domaintools.Result{ - Content: map[string]any{"error": "unknown tool"}, - }, nil - }, - Config: action.Config{ - DisplayName: func(_ json.RawMessage) string { return name }, - Status: func(_ json.RawMessage) string { return "Executing" }, - Result: func(_ domaintools.Result) string { return "Unknown tool" }, - }, - } -} diff --git a/internal/app/chattools/runtime.go b/internal/app/chattools/runtime.go deleted file mode 100644 index eaad1123..00000000 --- a/internal/app/chattools/runtime.go +++ /dev/null @@ -1,12 +0,0 @@ -package tools - -import ( - "context" - "time" -) - -const toolTimeout = 3 * time.Second - -func withToolTimeout() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), toolTimeout) -} diff --git a/internal/app/chattools/setserviceenabled.go b/internal/app/chattools/setserviceenabled.go deleted file mode 100644 index 7c617bd7..00000000 --- a/internal/app/chattools/setserviceenabled.go +++ /dev/null @@ -1,102 +0,0 @@ -package tools - -import ( - "encoding/json" - "fmt" - - "github.com/google/uuid" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/action" - "github.com/usetero/cli/internal/boundary/chat" - graphql "github.com/usetero/cli/internal/boundary/graphql" - "github.com/usetero/cli/internal/domain/tools" -) - -// NewSetServiceEnabledAction creates an ActionTool for set_service_enabled. -// The enable/disable write is a synchronous control-plane GraphQL mutation. -func NewSetServiceEnabledAction(services graphql.Services) ActionTool { - def := chat.Tool{ - Name: "set_service_enabled", - Description: "Enable or disable a service for log analysis. Enabling triggers the analysis pipeline.", - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "service_id": { - Type: "string", - Description: "UUID of the service (e.g., '4a3b1c2d-...'). Use the query tool to look up service IDs: SELECT id, name FROM services", - }, - "enabled": { - Type: "boolean", - Description: "true to enable, false to disable", - }, - }, - []string{"service_id", "enabled"}, - ), - } - - executor := func(input json.RawMessage) (tools.Result, error) { - var in tools.SetServiceEnabledInput - if err := json.Unmarshal(input, &in); err != nil { - return tools.Result{}, err - } - - if _, parseErr := uuid.Parse(in.ServiceID.String()); parseErr != nil { - return tools.Result{}, fmt.Errorf( - "service ID %q is not a UUID — this looks like a name. "+ - "Use the query tool: SELECT id, name FROM services WHERE name LIKE '%%%s%%'", - in.ServiceID, in.ServiceID, - ) - } - - ctx, cancel := withToolTimeout() - defer cancel() - - var err error - if in.Enabled { - err = services.EnableService(ctx, in.ServiceID) - } else { - err = services.DisableService(ctx, in.ServiceID) - } - if err != nil { - return tools.Result{}, fmt.Errorf("set service enabled: %w", err) - } - - return tools.Result{ - Content: tools.SetServiceEnabledResult{ - ServiceID: in.ServiceID, - Enabled: in.Enabled, - }.ToMap(), - }, nil - } - - config := action.Config{ - DisplayName: func(input json.RawMessage) string { - var in tools.SetServiceEnabledInput - if json.Unmarshal(input, &in) == nil && !in.Enabled { - return "Disable Service" - } - return "Enable Service" - }, - Status: func(input json.RawMessage) string { - var in tools.SetServiceEnabledInput - if json.Unmarshal(input, &in) != nil { - return "" - } - if in.Enabled { - return fmt.Sprintf("Enabling %s", in.ServiceID) - } - return fmt.Sprintf("Disabling %s", in.ServiceID) - }, - Result: func(result tools.Result) string { - name, _ := result.Content["service_name"].(string) - if name == "" { - name, _ = result.Content["service_id"].(string) - } - enabled, _ := result.Content["enabled"].(bool) - if enabled { - return fmt.Sprintf("%s enabled", name) - } - return fmt.Sprintf("%s disabled", name) - }, - } - - return NewActionTool(def, executor, config) -} diff --git a/internal/app/commands.go b/internal/app/commands.go index ccbb0305..45f4c206 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -12,13 +12,12 @@ import ( func (m *Model) paletteCommands() []palette.Command { return []palette.Command{ { - Name: "New Conversation", + Name: "Refresh Issues", Handler: func() tea.Cmd { - m.chat = m.newChat() - m.statusBar.SetTitle("") - m.windowTitle = "" - m.updateLayout() - return m.chat.Init() + if m.explorer == nil { + return nil + } + return m.explorer.Refresh() }, }, { diff --git a/internal/app/explorer/explorer.go b/internal/app/explorer/explorer.go new file mode 100644 index 00000000..d59c053b --- /dev/null +++ b/internal/app/explorer/explorer.go @@ -0,0 +1,231 @@ +// Package explorer renders a minimal, read-only view of the account's active +// issues. It is the default interactive surface now that chat is gone. +package explorer + +import ( + "context" + "fmt" + "image/color" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + graphql "github.com/usetero/cli/internal/boundary/graphql" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +const fetchTimeout = 5 * time.Second + +var ( + keyUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑↓", "navigate")) + keyDown = key.NewBinding(key.WithKeys("down", "j")) + keyRefresh = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")) +) + +// Model is the issue explorer. +type Model struct { + services graphql.ServiceSet + scope log.Scope + theme styles.Theme + + width, height int + originX, originY int + + issues []domain.Issue + summary domain.IssueSummary + cursor int + loading bool + err error +} + +type issuesLoadedMsg struct { + issues []domain.Issue + summary domain.IssueSummary + err error +} + +// New creates a new explorer model. +func New(services graphql.ServiceSet, theme styles.Theme, scope log.Scope) *Model { + return &Model{ + services: services, + theme: theme, + scope: scope.Child("explorer"), + loading: true, + } +} + +// Init fetches the initial issue list. +func (m *Model) Init() tea.Cmd { + return m.fetch() +} + +// Refresh re-fetches the issue list. +func (m *Model) Refresh() tea.Cmd { + m.loading = true + return m.fetch() +} + +func (m *Model) fetch() tea.Cmd { + services := m.services + scope := m.scope + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + issues, err := services.Issues.List(ctx) + if err != nil { + scope.Error("list issues", "err", err) + return issuesLoadedMsg{err: err} + } + summary, err := services.Issues.GetSummary(ctx) + if err != nil { + scope.Error("issue summary", "err", err) + return issuesLoadedMsg{issues: issues, err: err} + } + return issuesLoadedMsg{issues: issues, summary: summary} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case issuesLoadedMsg: + m.loading = false + m.err = msg.err + m.issues = msg.issues + m.summary = msg.summary + if m.cursor >= len(m.issues) { + m.cursor = max(0, len(m.issues)-1) + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keyUp): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, keyDown): + if m.cursor < len(m.issues)-1 { + m.cursor++ + } + case key.Matches(msg, keyRefresh): + return m.Refresh() + } + } + return nil +} + +// SetSize updates the dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// SetOrigin records the page origin (kept for layout parity with other pages). +func (m *Model) SetOrigin(x, y int) { + m.originX = x + m.originY = y +} + +// ShortHelp returns the key bindings shown in the keybar. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyUp, keyRefresh} +} + +// View renders the explorer. +func (m *Model) View() string { + colors := m.theme + title := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg).Bold(true) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + var b strings.Builder + b.WriteString(title.Render("Active issues")) + b.WriteString(" ") + b.WriteString(muted.Render(m.headline())) + b.WriteString("\n\n") + + switch { + case m.loading: + b.WriteString(muted.Render("Loading issues…")) + return b.String() + case m.err != nil: + b.WriteString(lipgloss.NewStyle().Foreground(colors.Error).Background(colors.Bg).Render("Failed to load issues: " + m.err.Error())) + b.WriteString("\n\n") + b.WriteString(muted.Render("Press r to retry.")) + return b.String() + case len(m.issues) == 0: + b.WriteString(muted.Render("No active issues. 🎉")) + return b.String() + } + + for i, issue := range m.issues { + b.WriteString(m.renderRow(i, issue)) + b.WriteString("\n") + } + return b.String() +} + +func (m *Model) headline() string { + if m.loading || m.err != nil { + return "" + } + return fmt.Sprintf("%d open · %d high · %d medium · %d low", + m.summary.Open, m.summary.HighCount, m.summary.MediumCount, m.summary.LowCount) +} + +func (m *Model) renderRow(index int, issue domain.Issue) string { + colors := m.theme + cursor := " " + nameStyle := lipgloss.NewStyle().Foreground(colors.Text).Background(colors.Bg) + if index == m.cursor { + cursor = lipgloss.NewStyle().Foreground(colors.Accent).Background(colors.Bg).Render("▶ ") + nameStyle = nameStyle.Foreground(colors.Accent) + } + + prio := lipgloss.NewStyle().Background(colors.Bg).Foreground(priorityColor(colors, issue.Priority)).Render(pad(string(issue.Priority), 6)) + muted := lipgloss.NewStyle().Foreground(colors.TextMuted).Background(colors.Bg) + + meta := issue.DisplayID + if issue.ServiceName != "" { + meta += " · " + issue.ServiceName + } + + line := cursor + prio + " " + nameStyle.Render(truncate(issue.Title, m.titleWidth())) + " " + muted.Render(meta) + return line +} + +func (m *Model) titleWidth() int { + w := m.width - 30 + if w < 20 { + w = 20 + } + return w +} + +func priorityColor(theme styles.Theme, p domain.IssuePriority) color.Color { + switch p { + case domain.IssuePriorityHigh: + return theme.Error + case domain.IssuePriorityMedium: + return theme.Warning + default: + return theme.TextMuted + } +} + +func pad(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +func truncate(s string, n int) string { + if n <= 1 || len(s) <= n { + return s + } + return s[:n-1] + "…" +} diff --git a/internal/app/onboarding_orchestration.go b/internal/app/onboarding_orchestration.go index 3a336267..c9eed8f5 100644 --- a/internal/app/onboarding_orchestration.go +++ b/internal/app/onboarding_orchestration.go @@ -5,7 +5,6 @@ import ( tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/core/bootstrap" ) @@ -43,7 +42,7 @@ func (m *Model) handleOnboardingMessage(msg tea.Msg) (tea.Cmd, bool) { return catalogCmd, true case bootstrap.OnboardingComplete: - m.state = stateChat + m.state = stateExplorer m.user = msg.User m.account = msg.Account m.scope.Info("onboarding complete", @@ -51,33 +50,12 @@ func (m *Model) handleOnboardingMessage(msg tea.Msg) (tea.Cmd, bool) { "account", msg.Account.Name, ) - // Create chat model (sizing happens via updateLayout) - m.chat = m.newChat() - - // Size the new chat component + // Create the issue explorer (sizing happens via updateLayout). + m.explorer = m.newExplorer() m.updateLayout() - return m.chat.Init(), true + return m.explorer.Init(), true } return nil, false } - -func (m *Model) handleStreamCompleted(msg tea.Msg) { - stream, ok := msg.(msgs.StreamCompleted) - if !ok { - return - } - - if stream.Title != "" && m.chat != nil { - // Chat is ephemeral: the title is shown in the status bar and window - // title for the session but is not persisted. - m.statusBar.SetTitle(stream.Title) - m.windowTitle = "Tero: " + stream.Title - } - // Update context window usage in statusbar - if stream.InputTokens > 0 && stream.ContextWindow > 0 { - pct := (stream.InputTokens*100 + stream.ContextWindow - 1) / stream.ContextWindow // round up - m.statusBar.SetContextPercent(pct) - } -} diff --git a/internal/app/perf_logging.go b/internal/app/perf_logging.go index 278bc09f..d5f04410 100644 --- a/internal/app/perf_logging.go +++ b/internal/app/perf_logging.go @@ -59,7 +59,7 @@ func (m *Model) stateName() string { switch m.state { case stateOnboarding: return "onboarding" - case stateChat: + case stateExplorer: return "chat" default: return "unknown" diff --git a/internal/app/runtime_sync.go b/internal/app/runtime_sync.go index a5343491..f8e32f26 100644 --- a/internal/app/runtime_sync.go +++ b/internal/app/runtime_sync.go @@ -4,9 +4,6 @@ import ( "context" tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/usecase" - chattools "github.com/usetero/cli/internal/app/chattools" - chatboundary "github.com/usetero/cli/internal/boundary/chat" "github.com/usetero/cli/internal/domain" ) @@ -27,33 +24,9 @@ func (m *Model) startSession(accountID string) { m.scope.Info("session started", "account_id", accountID) } -// ensureRuntime scopes the session to the account and initializes dependent -// runtime services (status surfaces, chat tools, chat client). +// ensureRuntime scopes the session to the account and starts the status +// surfaces, which read from the account-scoped control-plane services. func (m *Model) ensureRuntime(accountID string) (tea.Cmd, error) { m.startSession(accountID) - - // Drawer tabs read from the account-scoped control-plane services. - catalogCmd := m.statusBar.SetServices(m.services) - - // Create tool registry. All tools are GraphQL-backed: the read tools query - // the control-plane catalog and set_service_enabled is a synchronous - // mutation. Policy approval moved to the issue model and is no longer a - // chat action. - m.toolRegistry = chattools.NewRegistry( - map[string]chattools.ActionTool{ - "list_services": chattools.NewListServicesTool(m.services), - "list_issues": chattools.NewListIssuesTool(m.services), - "list_checks": chattools.NewListChecksTool(m.services), - "list_edge_instances": chattools.NewListEdgeInstancesTool(m.services), - "account_status": chattools.NewAccountStatusTool(m.services), - "set_service_enabled": chattools.NewSetServiceEnabledAction(m.services.Services), - }, - ) - - // Create chat client with tool definitions - m.chatClient = chatboundary.NewClient(m.cfg.ChatEndpoint, m.authService, m.scope, m.toolRegistry.Definitions()). - WithAccountID(domain.AccountID(accountID)) - m.runtimeDeps = usecase.NewRuntimeDeps(m.chatClient).WithEffectContext(m.sessionCtx) - - return catalogCmd, nil + return m.statusBar.SetServices(m.services), nil } diff --git a/internal/app/update_routing.go b/internal/app/update_routing.go index 227a2a4b..20e3623e 100644 --- a/internal/app/update_routing.go +++ b/internal/app/update_routing.go @@ -1,12 +1,9 @@ package app import ( - "strings" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - msgs "github.com/usetero/cli/internal/app/chat/events" appevents "github.com/usetero/cli/internal/app/events" "github.com/usetero/cli/internal/tea/keymap" ) @@ -46,16 +43,6 @@ func (m *Model) handleInteractionMessage(msg tea.Msg) (tea.Cmd, bool) { } // Let downstream components observe mouse messages. return nil, false - case appevents.DrawerPromptRequested: - m.statusBar.CloseDrawer() - return func() tea.Msg { return msgs.UserSubmittedInput{Text: msg.Text} }, true - case msgs.UserSubmittedInput: - text := strings.TrimSpace(msg.Text) - if strings.EqualFold(text, "exit") || strings.EqualFold(text, "quit") { - m.quitDlg = newQuitDialog(m.theme) - return nil, true - } - return nil, false default: return nil, false } @@ -87,12 +74,6 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.statusBar.CloseDrawer() return nil, true } - // Esc cancels active round first; only show dialog if nothing to cancel. - if m.chat != nil { - if cancelled, cmd := m.chat.CancelActiveRound(); cancelled { - return cmd, true - } - } m.quitDlg = newQuitDialog(m.theme) return nil, true } @@ -134,9 +115,9 @@ func (m *Model) updateChildren(msg tea.Msg) tea.Cmd { if m.onboarding != nil { cmds = append(cmds, m.onboarding.Update(msg)) } - case stateChat: - if m.chat != nil { - cmds = append(cmds, m.chat.Update(msg)) + case stateExplorer: + if m.explorer != nil { + cmds = append(cmds, m.explorer.Update(msg)) } } diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go index 975ccdc9..730be04b 100644 --- a/internal/architecture/dependencies_test.go +++ b/internal/architecture/dependencies_test.go @@ -22,30 +22,15 @@ func TestDependencyBoundaries(t *testing.T) { } graphqlRoot := filepath.Join(root, "internal", "boundary", "graphql") - chatBoundaryRoot := filepath.Join(root, "internal", "boundary", "chat") coreRoot := filepath.Join(root, "internal", "core") - chatRoot := filepath.Join(root, "internal", "app", "chat") assertNoForbiddenImports(t, graphqlRoot, []string{ "github.com/usetero/cli/internal/app/", }) - assertNoForbiddenImports(t, chatBoundaryRoot, []string{ - "github.com/usetero/cli/internal/app/", - }) assertNoForbiddenImports(t, coreRoot, []string{ "github.com/usetero/cli/internal/app/", "github.com/usetero/cli/internal/boundary/graphql/", }) - assertNoForbiddenImportsExcept(t, chatRoot, []string{ - "github.com/usetero/cli/internal/boundary/chat", - }, []string{ - filepath.Join("internal", "app", "chat", "messagelist", "messagelisttest"), - filepath.Join("internal", "app", "chat", "usecase"), - }) - assertOnlyAllowedChatBoundaryImports(t, chatRoot, []string{ - filepath.Join("internal", "app", "chat", "messagelist", "messagelisttest"), - filepath.Join("internal", "app", "chat", "usecase"), - }) } func findModuleRoot(start string) (string, error) { diff --git a/internal/boundary/chat/AGENTS.md b/internal/boundary/chat/AGENTS.md deleted file mode 100644 index 989786e3..00000000 --- a/internal/boundary/chat/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Chat Core - -Owns stream protocol semantics and snapshot reduction. - -## Rules - -1. Preserve event ordering guarantees (`seq` monotonic per turn). -2. Keep stream reduction logic pure and testable. -3. Expose terminal semantics explicitly (`completed`, `tool_use`, `aborted`, `failed`). -4. Do not leak transport quirks into app-layer orchestration. - -## Implementation Guidance - -1. Put transition policy in reducers. -2. Keep client streaming glue thin around reducers/snapshots. -3. Include turn/conversation identifiers in stream envelope fields where available. - -## Required Tests for Changes - -1. Reducer transition tests for new lifecycle paths. -2. Ordering/scoping regression tests. -3. Aborted/cancelled terminal behavior tests. - diff --git a/internal/boundary/chat/chattest/mock_client.go b/internal/boundary/chat/chattest/mock_client.go deleted file mode 100644 index 95286531..00000000 --- a/internal/boundary/chat/chattest/mock_client.go +++ /dev/null @@ -1,63 +0,0 @@ -package chattest - -import ( - "context" - - "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" -) - -// MockClient is a mock implementation of chat.Client for testing. -type MockClient struct { - StreamFunc func(ctx context.Context, req chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) - StreamSnapshotsFunc func(ctx context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) - SetAccountFunc func(accountID domain.AccountID) - WithAccountFunc func(accountID domain.AccountID) chat.Client -} - -var _ chat.Client = (*MockClient)(nil) - -func (m *MockClient) Stream(ctx context.Context, req chat.Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - if m.StreamFunc != nil { - return m.StreamFunc(ctx, req, onMessage) - } - if m.StreamSnapshotsFunc != nil { - return m.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - if onMessage != nil { - onMessage(s.Message) - } - }) - } - return &corechat.StreamResult{}, nil -} - -func (m *MockClient) StreamSnapshots(ctx context.Context, req chat.Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - if m.StreamSnapshotsFunc != nil { - return m.StreamSnapshotsFunc(ctx, req, onSnapshot) - } - if m.StreamFunc != nil { - return m.StreamFunc(ctx, req, func(msg *domain.Message) { - if onSnapshot != nil { - onSnapshot(corechat.StreamSnapshot{Message: msg, Status: corechat.StreamStatusStreaming}) - } - }) - } - return &corechat.StreamResult{}, nil -} - -func (m *MockClient) SetAccountID(accountID domain.AccountID) { - if m.SetAccountFunc != nil { - m.SetAccountFunc(accountID) - } -} - -func (m *MockClient) WithAccountID(accountID domain.AccountID) chat.Client { - if m.WithAccountFunc != nil { - return m.WithAccountFunc(accountID) - } - if m.SetAccountFunc != nil { - m.SetAccountFunc(accountID) - } - return m -} diff --git a/internal/boundary/chat/client.go b/internal/boundary/chat/client.go deleted file mode 100644 index defc6287..00000000 --- a/internal/boundary/chat/client.go +++ /dev/null @@ -1,383 +0,0 @@ -// Package chat provides a client for the stateless Chat API. -// -// The Chat API is a pure function: f(messages, context) → stream of blocks. -// It doesn't read or write messages to any database. The client sends the -// full conversation history on every request and receives a streamed response. -// -// Message persistence is handled separately by the caller. -package chat - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - "github.com/hashicorp/go-retryablehttp" - "github.com/usetero/cli/internal/auth" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log" -) - -const ( - retryMax = 3 - retryWaitMin = 100 * time.Millisecond - retryWaitMax = 2 * time.Second - defaultStreamTimeout = 10 * time.Minute - maxErrorBodyPreview = 256 -) - -// HTTPDoer is the interface for making HTTP requests. -type HTTPDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -// Client sends messages to the Chat API and streams responses. -type Client interface { - // StreamSnapshots sends the conversation to the Chat API and streams normalized snapshots. - // Each snapshot includes scoped progress metadata (conversation/turn/seq/status). - StreamSnapshots(ctx context.Context, req Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) - - // Stream sends the conversation to the Chat API and streams the response. - // The onMessage callback is called each time the message is updated with new content. - // The returned StreamResult contains the final message and any metadata. - Stream(ctx context.Context, req Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) - - // SetAccountID sets the account ID for requests. - SetAccountID(accountID domain.AccountID) - // WithAccountID returns a new client scoped to accountID. - WithAccountID(accountID domain.AccountID) Client -} - -// client is the concrete implementation of Client. -type client struct { - endpoint string - httpClient HTTPDoer - auth auth.Auth - accountID domain.AccountID - mu sync.RWMutex - scope log.Scope - globalTools []Tool -} - -// Ensure client implements Client. -var _ Client = (*client)(nil) - -// NewClient creates a new Chat API client. -// - globalTools are included in every request automatically -// - Retries transient errors (connection reset, 502/503/504) up to 3 times with backoff -// - Gets a fresh token via auth.GetAccessToken before each request -func NewClient(endpoint string, authService auth.Auth, scope log.Scope, globalTools []Tool) Client { - retryClient := retryablehttp.NewClient() - retryClient.RetryMax = retryMax - retryClient.RetryWaitMin = retryWaitMin - retryClient.RetryWaitMax = retryWaitMax - retryClient.Logger = nil - - return &client{ - endpoint: strings.TrimSuffix(endpoint, "/"), - httpClient: retryClient.StandardClient(), - auth: authService, - scope: scope.Child("chat"), - globalTools: globalTools, - } -} - -// NewClientWithHTTP creates a new Chat API client with a custom HTTP client (for testing). -func NewClientWithHTTP(endpoint string, authService auth.Auth, httpClient HTTPDoer, scope log.Scope, globalTools []Tool) Client { - return &client{ - endpoint: strings.TrimSuffix(endpoint, "/"), - httpClient: httpClient, - auth: authService, - scope: scope.Child("chat"), - globalTools: globalTools, - } -} - -// SetAccountID sets the account ID for requests. -func (c *client) SetAccountID(accountID domain.AccountID) { - c.mu.Lock() - defer c.mu.Unlock() - c.accountID = accountID -} - -// WithAccountID returns a copy scoped to the given account. -func (c *client) WithAccountID(accountID domain.AccountID) Client { - return &client{ - endpoint: c.endpoint, - httpClient: c.httpClient, - auth: c.auth, - accountID: accountID, - scope: c.scope, - globalTools: append([]Tool(nil), c.globalTools...), - } -} - -// Stream sends the conversation to the Chat API and streams the response. -// The onMessage callback is called each time the message is updated with new content. -// Global tools are automatically merged with any request-specific tools. -func (c *client) Stream(ctx context.Context, req Request, onMessage func(*domain.Message)) (*corechat.StreamResult, error) { - return c.StreamSnapshots(ctx, req, func(s corechat.StreamSnapshot) { - if onMessage != nil { - onMessage(s.Message) - } - }) -} - -// StreamSnapshots sends the conversation to the Chat API and streams normalized snapshots. -// Global tools are automatically merged with any request-specific tools. -func (c *client) StreamSnapshots(ctx context.Context, req Request, onSnapshot func(corechat.StreamSnapshot)) (*corechat.StreamResult, error) { - ctx, cancel := withDefaultTimeout(ctx, defaultStreamTimeout) - defer cancel() - - // Merge global tools with request-specific tools - allTools := append(append([]Tool(nil), c.globalTools...), req.Tools...) - if err := validateTools(allTools); err != nil { - return nil, fmt.Errorf("validate tools: %w", err) - } - req.Tools = allTools - - url := c.endpoint + "/api/chat/v2/messages" - accountID := c.accountIDSnapshot() - - c.scope.Debug("sending to chat API", - log.String("url", url), - log.String("conversation_id", req.ConversationID), - log.Int("message_count", len(req.Messages)), - log.Int("context_count", len(req.ContextEntities)), - log.Int("tool_count", len(req.Tools)), - ) - for _, summary := range summarizeRequestMessages(req.Messages) { - c.scope.Debug("chat request message", "summary", summary) - } - c.scope.Debug("chat request tool lineage", "summary", summarizeToolLineage(req.Messages)) - - // Get fresh token for this request - token, err := c.auth.GetAccessToken(ctx) - if err != nil { - c.scope.Error("failed to get access token", "error", err) - return nil, fmt.Errorf("get access token: %w", err) - } - - // Log token metadata for debugging auth issues - if claims, parseErr := parseTokenAudience(token); parseErr == nil { - c.scope.Debug("token acquired", - log.String("sub", claims.sub), - log.String("aud", strings.Join(claims.aud, ", ")), - log.String("org_id", claims.orgID), - log.Bool("expired", claims.expired), - ) - } - - wireReq, err := toWireRequest(req) - if err != nil { - return nil, fmt.Errorf("build wire request: %w", err) - } - - body, err := json.Marshal(wireReq) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - c.setHeaders(httpReq, token, accountID) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - c.scope.Error("request failed", "error", err, "url", url) - return nil, fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - bodyPreview, bodyHash, bodyTruncated := summarizeErrorBody(respBody) - c.scope.Error("chat API returned error", - log.Int("status", resp.StatusCode), - log.String("url", url), - log.String("body_preview", bodyPreview), - log.String("body_sha256", bodyHash), - log.Int("body_bytes", len(respBody)), - log.Bool("body_truncated", bodyTruncated), - log.String("account_id", accountID.String()), - ) - return nil, fmt.Errorf("chat API error %d: %s", resp.StatusCode, bodyPreview) - } - - contentType := resp.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "text/event-stream") { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("expected text/event-stream, got %s: %s", contentType, string(respBody)) - } - - // Build typed stream snapshots via deterministic core stream machine. - streamMachine := corechat.NewStreamMachine(req.ConversationID) - - err = readStream(resp.Body, func(data []byte, done bool) error { - var ( - snap *corechat.StreamSnapshot - err error - ) - if done { - snap, err = streamMachine.ConsumeDone() - } else { - snap, err = streamMachine.ConsumeData(data) - } - if err != nil { - return err - } - if onSnapshot != nil { - onSnapshot(*snap) - } - return nil - }) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - reason := err.Error() - if cause := context.Cause(ctx); cause != nil { - reason = cause.Error() - } - if onSnapshot != nil { - if snap := streamMachine.Abort(reason); snap != nil { - onSnapshot(*snap) - } - } - return &corechat.StreamResult{ - Message: streamMachine.Message(), - Metadata: streamMachine.Metadata(), - }, nil - } - return nil, err - } - - result := &corechat.StreamResult{ - Message: streamMachine.Message(), - } - result.Metadata = streamMachine.Metadata() - - return result, nil -} - -func (c *client) setHeaders(req *http.Request, token string, accountID domain.AccountID) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Authorization", "Bearer "+token) - if accountID != "" { - req.Header.Set("X-Account-ID", accountID.String()) - } -} - -func (c *client) accountIDSnapshot() domain.AccountID { - c.mu.RLock() - defer c.mu.RUnlock() - return c.accountID -} - -func withDefaultTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if _, ok := ctx.Deadline(); ok { - return ctx, func() {} - } - return context.WithTimeout(ctx, timeout) -} - -// tokenClaims holds the subset of JWT claims we log for debugging. -type tokenClaims struct { - sub string - aud []string - orgID string - expired bool -} - -// parseTokenAudience extracts key claims from a JWT without signature verification. -func parseTokenAudience(token string) (tokenClaims, error) { - parts := strings.SplitN(token, ".", 3) - if len(parts) != 3 { - return tokenClaims{}, fmt.Errorf("invalid JWT format") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return tokenClaims{}, err - } - var raw struct { - Sub string `json:"sub"` - Aud []string `json:"aud"` - OrgID string `json:"org_id"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &raw); err != nil { - return tokenClaims{}, err - } - return tokenClaims{ - sub: raw.Sub, - aud: raw.Aud, - orgID: raw.OrgID, - expired: raw.Exp > 0 && time.Now().Unix() > raw.Exp, - }, nil -} - -func summarizeRequestMessages(messages []domain.Message) []string { - out := make([]string, 0, len(messages)) - for _, msg := range messages { - blockKinds := make([]string, 0, len(msg.Content)) - for _, b := range msg.Content { - blockKinds = append(blockKinds, string(b.Type)) - } - out = append(out, fmt.Sprintf( - "id=%s role=%s stop_reason=%s blocks=%d kinds=%s", - msg.ID, - msg.Role, - msg.StopReason, - len(msg.Content), - strings.Join(blockKinds, ","), - )) - } - return out -} - -func summarizeToolLineage(messages []domain.Message) string { - toolUses := make([]string, 0) - toolResults := make([]string, 0) - for _, msg := range messages { - for _, b := range msg.Content { - if b.ToolUse != nil && b.ToolUse.ID != "" { - toolUses = append(toolUses, b.ToolUse.ID) - } - if b.ToolResult != nil && b.ToolResult.ToolUseID != "" { - toolResults = append(toolResults, b.ToolResult.ToolUseID) - } - } - } - return fmt.Sprintf("tool_use_ids=[%s] tool_result_ids=[%s]", strings.Join(toolUses, ","), strings.Join(toolResults, ",")) -} - -func summarizeErrorBody(body []byte) (preview, sha string, truncated bool) { - sum := sha256.Sum256(body) - sha = hex.EncodeToString(sum[:]) - - if len(body) == 0 { - return "(empty)", sha, false - } - text := strings.TrimSpace(string(body)) - text = strings.Join(strings.Fields(text), " ") - if text == "" { - return "(empty)", sha, false - } - if len(text) <= maxErrorBodyPreview { - return text, sha, false - } - return text[:maxErrorBodyPreview] + "...", sha, true -} diff --git a/internal/boundary/chat/client_test.go b/internal/boundary/chat/client_test.go deleted file mode 100644 index 1bd3ad0b..00000000 --- a/internal/boundary/chat/client_test.go +++ /dev/null @@ -1,832 +0,0 @@ -package chat_test - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - "testing" - - "github.com/usetero/cli/internal/auth/authtest" - "github.com/usetero/cli/internal/boundary/chat" - corechat "github.com/usetero/cli/internal/core/chat" - "github.com/usetero/cli/internal/domain" - "github.com/usetero/cli/internal/log/logtest" -) - -type mockHTTPClient struct { - doFunc func(req *http.Request) (*http.Response, error) -} - -func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { - return m.doFunc(req) -} - -type blockingReadCloser struct { - ctx context.Context -} - -func (b *blockingReadCloser) Read(_ []byte) (int, error) { - <-b.ctx.Done() - return 0, b.ctx.Err() -} - -func (b *blockingReadCloser) Close() error { return nil } - -const minimalValidStream = `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - -func validRequest() chat.Request { - return chat.Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - } -} - -func TestClient_Stream(t *testing.T) { - t.Parallel() - - t.Run("sends request with correct headers", func(t *testing.T) { - t.Parallel() - - var capturedReq *http.Request - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "test-token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - client.SetAccountID("acc-123") - - _, err := client.Stream(context.Background(), chat.Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - ID: "msg-1", - ConversationID: "00000000-0000-0000-0000-000000000001", - Role: domain.RoleUser, - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }, func(msg *domain.Message) {}) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - if capturedReq.Header.Get("Authorization") != "Bearer test-token" { - t.Errorf("Authorization = %q, want %q", capturedReq.Header.Get("Authorization"), "Bearer test-token") - } - if capturedReq.Header.Get("X-Account-ID") != "acc-123" { - t.Errorf("X-Account-ID = %q, want %q", capturedReq.Header.Get("X-Account-ID"), "acc-123") - } - if capturedReq.Header.Get("Content-Type") != "application/json" { - t.Errorf("Content-Type = %q, want %q", capturedReq.Header.Get("Content-Type"), "application/json") - } - if capturedReq.Header.Get("Accept") != "text/event-stream" { - t.Errorf("Accept = %q, want %q", capturedReq.Header.Get("Accept"), "text/event-stream") - } - - body, err := io.ReadAll(capturedReq.Body) - if err != nil { - t.Fatalf("ReadAll(body) error = %v", err) - } - payload := string(body) - forbidden := []string{`"index"`, `"id"`, `"created_at"`} - for _, f := range forbidden { - if strings.Contains(payload, f) { - t.Fatalf("request payload unexpectedly contains %s: %s", f, payload) - } - } - if !strings.Contains(payload, `"chat_protocol_version":"v2"`) { - t.Fatalf("request payload missing chat_protocol_version=v2: %s", payload) - } - if !strings.Contains(payload, `"conversation_id":"00000000-0000-0000-0000-000000000001"`) { - t.Fatalf("request payload missing conversation_id: %s", payload) - } - }) - - t.Run("sends request to correct endpoint", func(t *testing.T) { - t.Parallel() - - var capturedReq *http.Request - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com/", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - want := "https://api.example.com/api/chat/v2/messages" - if capturedReq.URL.String() != want { - t.Errorf("URL = %q, want %q", capturedReq.URL.String(), want) - } - }) - - t.Run("returns error on auth failure", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - t.Fatal("HTTP client should not be called when auth fails") - return nil, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "", errors.New("token expired") - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "access token") { - t.Errorf("error = %q, want to contain 'access token'", err.Error()) - } - }) - - t.Run("returns error on invalid outgoing tool definitions", func(t *testing.T) { - t.Parallel() - - httpCalled := false - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - httpCalled = true - return nil, errors.New("should not send request") - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), chat.Request{ - Tools: []chat.Tool{{ - Name: "", - Description: "bad", - InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), - }}, - }, nil) - if err == nil || !strings.Contains(err.Error(), "validate tools") { - t.Fatalf("error = %v", err) - } - if httpCalled { - t.Fatal("HTTP request should not be sent for invalid tools") - } - }) - - t.Run("returns error on HTTP failure", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return nil, errors.New("connection refused") - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "connection refused") { - t.Errorf("error = %q, want to contain 'connection refused'", err.Error()) - } - }) - - t.Run("returns error on non-200 status", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(strings.NewReader("invalid token")), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "401") { - t.Errorf("error = %q, want to contain '401'", err.Error()) - } - }) - - t.Run("non-200 error body in returned error is truncated", func(t *testing.T) { - t.Parallel() - - longBody := "secret-token=abc123 " + strings.Repeat("x", 400) - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(strings.NewReader(longBody)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "400") { - t.Fatalf("error = %q, want to contain 400", err.Error()) - } - if strings.Contains(err.Error(), strings.Repeat("x", 300)) { - t.Fatalf("error unexpectedly contains full response body: %q", err.Error()) - } - if !strings.Contains(err.Error(), "secret-token=abc123") { - t.Fatalf("error should include a short preview for debugging: %q", err.Error()) - } - }) - - t.Run("returns error on wrong content type", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"chat_stream_version":"v2","error": "something"}`)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) {}) - if err == nil { - t.Fatal("Stream() expected error, got nil") - } - if !strings.Contains(err.Error(), "text/event-stream") { - t.Errorf("error = %q, want to mention expected content type", err.Error()) - } - }) - - t.Run("builds message from stream and calls onMessage", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":" world"}} -data: {"chat_stream_version":"v2","type":"content_block_stop"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var messages []*domain.Message - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) { - // Make a copy since the message is built incrementally - msgCopy := *msg - messages = append(messages, &msgCopy) - }) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - // Should have received multiple updates as the message was built - if len(messages) == 0 { - t.Fatal("expected at least one message callback") - } - - // Last message should have the complete content - lastMsg := messages[len(messages)-1] - if lastMsg.Model != "claude-3" { - t.Errorf("Model = %q, want %q", lastMsg.Model, "claude-3") - } - if lastMsg.StopReason != "end_turn" { - t.Errorf("StopReason = %q, want %q", lastMsg.StopReason, "end_turn") - } - if len(lastMsg.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(lastMsg.Content)) - } - if lastMsg.Content[0].Type != domain.BlockTypeText { - t.Errorf("Content[0].Type = %q, want %q", lastMsg.Content[0].Type, domain.BlockTypeText) - } - if lastMsg.Content[0].Text.Content != "Hello world" { - t.Errorf("Content[0].Text.Content = %q, want %q", lastMsg.Content[0].Text.Content, "Hello world") - } - }) - - t.Run("accumulates tool use with input deltas", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-1","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-1","tool_input_delta":"{\"sql\":"} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-1","tool_input_delta":"\"SELECT 1\"}"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-1"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"tool_use","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var lastMessage *domain.Message - _, err := client.Stream(context.Background(), validRequest(), func(msg *domain.Message) { - lastMessage = msg - }) - - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - - if lastMessage == nil { - t.Fatal("expected message") - } - if len(lastMessage.Content) != 1 { - t.Fatalf("Content length = %d, want 1", len(lastMessage.Content)) - } - if lastMessage.Content[0].Type != domain.BlockTypeToolUse { - t.Errorf("Content[0].Type = %q, want %q", lastMessage.Content[0].Type, domain.BlockTypeToolUse) - } - if lastMessage.Content[0].ToolUse.ID != "tool-1" { - t.Errorf("ToolUse.ID = %q, want %q", lastMessage.Content[0].ToolUse.ID, "tool-1") - } - if lastMessage.Content[0].ToolUse.Name != "query" { - t.Errorf("ToolUse.Name = %q, want %q", lastMessage.Content[0].ToolUse.Name, "query") - } - expectedInput := `{"sql":"SELECT 1"}` - if string(lastMessage.Content[0].ToolUse.Input) != expectedInput { - t.Errorf("ToolUse.Input = %q, want %q", string(lastMessage.Content[0].ToolUse.Input), expectedInput) - } - }) - - t.Run("handles multiple interleaved tool calls", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-a","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-b","name":"query"}} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-a","tool_input_delta":"{\"sql\":\"SELECT "} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-b","tool_input_delta":"{\"sql\":\"SELECT "} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-a","tool_input_delta":"1\"}"} -data: {"chat_stream_version":"v2","type":"tool_input_delta","tool_use_id":"tool-b","tool_input_delta":"2\"}"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-b"} -data: {"chat_stream_version":"v2","type":"content_block_stop","tool_use_id":"tool-a"} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"tool_use","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - result, err := client.Stream(context.Background(), validRequest(), nil) - if err != nil { - t.Fatalf("Stream() error = %v", err) - } - if result == nil || result.Message == nil { - t.Fatal("expected stream result message") - } - if len(result.Message.Content) != 2 { - t.Fatalf("content blocks = %d, want 2", len(result.Message.Content)) - } - if string(result.Message.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("tool-a input = %s", string(result.Message.Content[0].ToolUse.Input)) - } - if string(result.Message.Content[1].ToolUse.Input) != `{"sql":"SELECT 2"}` { - t.Fatalf("tool-b input = %s", string(result.Message.Content[1].ToolUse.Input)) - } - }) - - t.Run("fails on mid-stream error frame", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","error":"internal error"} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.Stream(context.Background(), validRequest(), nil) - if err == nil || !strings.Contains(err.Error(), "server error: internal error") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("WithAccountID remains stable during concurrent base account switches", func(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - if got := req.Header.Get("X-Account-ID"); got != "acc-scoped" { - return nil, fmt.Errorf("unexpected account header %q", got) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(minimalValidStream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - base := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - base.SetAccountID("acc-base") - scoped := base.WithAccountID("acc-scoped") - - stop := make(chan struct{}) - done := make(chan struct{}) - go func() { - defer close(done) - for { - select { - case <-stop: - return - default: - base.SetAccountID("acc-a") - base.SetAccountID("acc-b") - } - } - }() - - for i := 0; i < 100; i++ { - if _, err := scoped.Stream(context.Background(), validRequest(), nil); err != nil { - close(stop) - <-done - t.Fatalf("scoped Stream() error at iter %d: %v", i, err) - } - } - - close(stop) - <-done - }) -} - -func TestClient_StreamSnapshots(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":3,"type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - var snaps []corechat.StreamSnapshot - _, err := client.StreamSnapshots(context.Background(), validRequest(), func(s corechat.StreamSnapshot) { - snaps = append(snaps, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - - if len(snaps) == 0 { - t.Fatal("expected at least one snapshot") - } - - last := snaps[len(snaps)-1] - if !last.Done { - t.Fatal("last.Done = false, want true") - } - if last.Status != corechat.StreamStatusCompleted { - t.Fatalf("last.Status = %q, want %q", last.Status, corechat.StreamStatusCompleted) - } - if last.ConversationID != "00000000-0000-0000-0000-000000000001" { - t.Fatalf("last.ConversationID = %q, want 00000000-0000-0000-0000-000000000001", last.ConversationID) - } - if last.TurnID != "turn-1" { - t.Fatalf("last.TurnID = %q, want turn-1", last.TurnID) - } - if last.Seq != 3 { - t.Fatalf("last.Seq = %d, want 3", last.Seq) - } - if last.Metadata == nil { - t.Fatal("last.Metadata = nil, want non-nil") - } -} - -func TestClient_StreamSnapshots_CancelledContextEmitsAbortedSnapshot(t *testing.T) { - t.Parallel() - - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: &blockingReadCloser{ctx: req.Context()}, - }, nil - }, - } - - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - ctx, cancel := context.WithCancelCause(context.Background()) - cancel(errors.New("user_cancelled")) - - var snaps []corechat.StreamSnapshot - result, err := client.StreamSnapshots(ctx, validRequest(), func(s corechat.StreamSnapshot) { - snaps = append(snaps, s) - }) - if err != nil { - t.Fatalf("StreamSnapshots() error = %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on canceled context") - } - if len(snaps) == 0 { - t.Fatal("expected aborted snapshot") - } - - last := snaps[len(snaps)-1] - if !last.Done { - t.Fatal("last.Done = false, want true") - } - if last.Status != corechat.StreamStatusAborted { - t.Fatalf("last.Status = %q, want %q", last.Status, corechat.StreamStatusAborted) - } - if last.AbortReason != "user_cancelled" { - t.Fatalf("last.AbortReason = %q, want user_cancelled", last.AbortReason) - } -} - -func TestClient_StreamSnapshots_RejectsNonMonotonicSeq(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"text_delta","text":{"content":"b"}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":10,"output_tokens":2}} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "non-monotonic seq") { - t.Fatalf("error = %q, want non-monotonic seq", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsTurnMismatch(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-2","seq":2,"type":"text_delta","text":{"content":"b"}} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "turn_id mismatch") { - t.Fatalf("error = %q, want turn_id mismatch", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsMalformedToolOrdering(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":2,"type":"tool_input_delta","tool_use_id":"missing","tool_input_delta":"{}"} -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected protocol error, got nil") - } - if !strings.Contains(err.Error(), "unknown tool_use_id") { - t.Fatalf("error = %q, want unknown tool_use_id", err.Error()) - } -} - -func TestClient_StreamSnapshots_RejectsDoneBeforeMessageStop(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","conversation_id":"00000000-0000-0000-0000-000000000001","turn_id":"turn-1","seq":1,"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: [DONE] -` - httpClient := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(stream)), - }, nil - }, - } - mockAuth := &authtest.MockAuth{ - GetAccessTokenFunc: func(ctx context.Context) (string, error) { - return "token", nil - }, - } - client := chat.NewClientWithHTTP("https://api.example.com", mockAuth, httpClient, logtest.NewScope(t), nil) - - _, err := client.StreamSnapshots(context.Background(), validRequest(), nil) - if err == nil { - t.Fatal("expected protocol error, got nil") - } - if !strings.Contains(err.Error(), "before message_stop") { - t.Fatalf("error = %q, want before message_stop", err.Error()) - } -} diff --git a/internal/boundary/chat/protocol_mapping.go b/internal/boundary/chat/protocol_mapping.go deleted file mode 100644 index 8d398dd0..00000000 --- a/internal/boundary/chat/protocol_mapping.go +++ /dev/null @@ -1,207 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/boundary/chat/protocolv2" - "github.com/usetero/cli/internal/domain" -) - -func toWireRequest(req Request) (protocolv2.Request, error) { - messages := make([]protocolv2.Message, 0, len(req.Messages)) - for i, msg := range req.Messages { - wm, err := toWireMessage(msg) - if err != nil { - return protocolv2.Request{}, fmt.Errorf("messages[%d]: %w", i, err) - } - messages = append(messages, wm) - } - - wire := protocolv2.Request{ - ChatProtocolVersion: protocolv2.Version, - ConversationID: req.ConversationID, - Messages: messages, - ContextEntities: toWireContextEntities(req.ContextEntities), - Tools: toWireTools(req.Tools), - } - if err := protocolv2.Validate(wire); err != nil { - return protocolv2.Request{}, err - } - return wire, nil -} - -func toWireMessage(msg domain.Message) (protocolv2.Message, error) { - role, err := mapWireRole(msg.Role) - if err != nil { - return protocolv2.Message{}, err - } - stopReason, err := mapWireStopReason(msg.StopReason) - if err != nil { - return protocolv2.Message{}, err - } - - blocks := make([]protocolv2.Block, 0, len(msg.Content)) - for i, b := range msg.Content { - wb, err := toWireBlock(b) - if err != nil { - return protocolv2.Message{}, fmt.Errorf("content[%d]: %w", i, err) - } - blocks = append(blocks, wb) - } - - return protocolv2.Message{ - Role: role, - Content: blocks, - Model: msg.Model, - StopReason: stopReason, - }, nil -} - -func toWireBlock(b domain.Block) (protocolv2.Block, error) { - blockType, err := mapWireBlockType(b.Type) - if err != nil { - return protocolv2.Block{}, err - } - wb := protocolv2.Block{Type: blockType} - - switch b.Type { - case domain.BlockTypeText: - if b.Text == nil { - return protocolv2.Block{}, fmt.Errorf("text block missing payload") - } - wb.Text = &protocolv2.Text{Content: b.Text.Content} - case domain.BlockTypeThinking: - if b.Thinking == nil { - return protocolv2.Block{}, fmt.Errorf("thinking block missing payload") - } - wb.Thinking = &protocolv2.Thinking{Content: b.Thinking.Content} - case domain.BlockTypeToolUse: - if b.ToolUse == nil { - return protocolv2.Block{}, fmt.Errorf("tool_use block missing payload") - } - wb.ToolUse = &protocolv2.ToolUse{ID: b.ToolUse.ID, Name: b.ToolUse.Name, Input: b.ToolUse.Input} - case domain.BlockTypeToolResult: - if b.ToolResult == nil { - return protocolv2.Block{}, fmt.Errorf("tool_result block missing payload") - } - wb.ToolResult = &protocolv2.ToolResult{ - ToolUseID: b.ToolResult.ToolUseID, - IsError: boolPtr(b.ToolResult.IsError), - Error: b.ToolResult.Error, - Content: sanitizeToolResultContent(b.ToolResult.Content, b.ToolResult.IsError), - } - default: - return protocolv2.Block{}, fmt.Errorf("unsupported block type %q", b.Type) - } - - return wb, nil -} - -func toWireContextEntities(entities []domain.ContextEntity) []protocolv2.ContextEntity { - out := make([]protocolv2.ContextEntity, 0, len(entities)) - for _, entity := range entities { - out = append(out, protocolv2.ContextEntity{ - EntityType: protocolv2.ContextEntityType(entity.EntityType), - EntityID: entity.EntityID, - }) - } - return out -} - -func toWireTools(tools []Tool) []protocolv2.Tool { - out := make([]protocolv2.Tool, 0, len(tools)) - for _, tool := range tools { - out = append(out, protocolv2.Tool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: schemaToMap(tool.InputSchema), - }) - } - return out -} - -func sanitizeToolResultContent(content map[string]any, isError bool) json.RawMessage { - if isError { - return nil - } - - if len(content) == 0 { - return json.RawMessage(`{}`) - } - - out := make(map[string]any, len(content)) - for k, v := range content { - switch k { - case "tool_use_id", "is_error", "error": - continue - default: - out[k] = v - } - } - if len(out) == 0 { - return json.RawMessage(`{}`) - } - encoded, err := json.Marshal(out) - if err != nil { - return json.RawMessage(`{}`) - } - return encoded -} - -func boolPtr(v bool) *bool { - return &v -} - -func schemaToMap(schema Schema) map[string]any { - encoded, err := json.Marshal(schema) - if err != nil { - return map[string]any{} - } - var out map[string]any - if err := json.Unmarshal(encoded, &out); err != nil { - return map[string]any{} - } - return out -} - -func mapWireRole(role domain.Role) (protocolv2.Role, error) { - switch role { - case domain.RoleUser: - return protocolv2.RoleUser, nil - case domain.RoleAssistant: - return protocolv2.RoleAssistant, nil - default: - return "", fmt.Errorf("unsupported role %q", role) - } -} - -func mapWireStopReason(reason string) (*protocolv2.StopReason, error) { - switch reason { - case "": - return nil, nil - case string(protocolv2.StopReasonEndTurn): - v := protocolv2.StopReasonEndTurn - return &v, nil - case string(protocolv2.StopReasonToolUse): - v := protocolv2.StopReasonToolUse - return &v, nil - default: - return nil, fmt.Errorf("unsupported stop_reason %q", reason) - } -} - -func mapWireBlockType(blockType domain.BlockType) (protocolv2.BlockType, error) { - switch blockType { - case domain.BlockTypeText: - return protocolv2.BlockTypeText, nil - case domain.BlockTypeThinking: - return protocolv2.BlockTypeThinking, nil - case domain.BlockTypeToolUse: - return protocolv2.BlockTypeToolUse, nil - case domain.BlockTypeToolResult: - return protocolv2.BlockTypeToolResult, nil - default: - return "", fmt.Errorf("unsupported block type %q", blockType) - } -} diff --git a/internal/boundary/chat/protocol_mapping_test.go b/internal/boundary/chat/protocol_mapping_test.go deleted file mode 100644 index c7bc9699..00000000 --- a/internal/boundary/chat/protocol_mapping_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package chat - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/usetero/cli/internal/domain" -) - -func TestToWireRequest_StripsInternalFields(t *testing.T) { - t.Parallel() - - req := Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - ID: "msg-1", - ConversationID: "00000000-0000-0000-0000-000000000001", - Role: domain.RoleUser, - CreatedAt: time.Now(), - Content: []domain.Block{{ - Index: 7, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - Tools: []Tool{{ - Name: "query", - Description: "Run SQL", - InputSchema: NewObjectSchema(map[string]Property{"sql": {Type: "string"}}, []string{"sql"}), - }}, - } - - wireReq, err := toWireRequest(req) - if err != nil { - t.Fatalf("toWireRequest() error = %v", err) - } - data, err := json.Marshal(wireReq) - if err != nil { - t.Fatalf("json.Marshal(wireReq) error = %v", err) - } - payload := string(data) - - forbidden := []string{"\"id\"", "\"created_at\"", "\"index\""} - for _, key := range forbidden { - if strings.Contains(payload, key) { - t.Fatalf("payload contains forbidden field %s: %s", key, payload) - } - } - if !strings.Contains(payload, `"chat_protocol_version":"v2"`) { - t.Fatalf("payload missing chat_protocol_version=v2: %s", payload) - } - if !strings.Contains(payload, `"conversation_id":"00000000-0000-0000-0000-000000000001"`) { - t.Fatalf("payload missing conversation_id: %s", payload) - } - if !strings.Contains(payload, `"messages"`) || !strings.Contains(payload, `"content"`) { - t.Fatalf("payload missing expected fields: %s", payload) - } -} - -func TestToWireRequest_RejectsInvalidDomainBlock(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: nil, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_RejectsInvalidRole(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.Role("invalid"), - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_RejectsInvalidStopReason(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleAssistant, - StopReason: "invalid", - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - }) - if err == nil { - t.Fatal("expected error, got nil") - } -} - -func TestToWireRequest_SanitizesToolResultContent(t *testing.T) { - t.Parallel() - - wireReq, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{ - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "run query"}, - }}, - }, - { - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }}, - }, - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{ - "tool_use_id": "tool-1", - "rows": []map[string]any{{"id": "svc-1"}}, - }, - }, - }}, - }, - }, - }) - if err != nil { - t.Fatalf("toWireRequest() error = %v", err) - } - if len(wireReq.Messages) != 3 || len(wireReq.Messages[2].Content) != 1 { - t.Fatalf("unexpected wire request shape: %#v", wireReq) - } - content := wireReq.Messages[2].Content[0].ToolResult.Content - var parsed map[string]any - if err := json.Unmarshal(content, &parsed); err != nil { - t.Fatalf("tool_result.content should be JSON object, got error: %v", err) - } - if _, ok := parsed["tool_use_id"]; ok { - t.Fatalf("tool_use_id leaked into tool_result.content: %#v", parsed) - } -} - -func TestToWireRequest_RejectsEmptyMessages(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - }) - if err == nil || !strings.Contains(err.Error(), `"messages" is required`) { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsMessageWithEmptyContent(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: nil, - }}, - }) - if err == nil || !strings.Contains(err.Error(), "content is required") { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsInvalidContextEntity(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{{ - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}, - ContextEntities: []domain.ContextEntity{{ - EntityType: domain.ContextEntityType("policy"), - EntityID: "not-a-uuid", - }}, - }) - if err == nil || !strings.Contains(err.Error(), "invalid entity_type") { - t.Fatalf("error = %v", err) - } -} - -func TestToWireRequest_RejectsUnknownToolUseReference(t *testing.T) { - t.Parallel() - - _, err := toWireRequest(Request{ - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []domain.Message{ - { - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }, - { - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: "tool-1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }}, - }, - { - Role: domain.RoleUser, - Content: []domain.Block{ - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "tool-1", - Content: map[string]any{"rows": []any{}}, - }, - }, - { - Type: domain.BlockTypeToolResult, - ToolResult: &domain.ToolResult{ - ToolUseID: "toolu_missing", - Content: map[string]any{"rows": []any{}}, - }, - }, - }, - }, - }, - }) - if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) { - t.Fatalf("error = %v", err) - } -} diff --git a/internal/boundary/chat/protocolv2/request.go b/internal/boundary/chat/protocolv2/request.go deleted file mode 100644 index b5bc6196..00000000 --- a/internal/boundary/chat/protocolv2/request.go +++ /dev/null @@ -1,325 +0,0 @@ -package protocolv2 - -import ( - "encoding/json" - "fmt" - - "github.com/google/uuid" -) - -const Version = "v2" - -type Role string - -const ( - RoleUser Role = "user" - RoleAssistant Role = "assistant" -) - -type StopReason string - -const ( - StopReasonEndTurn StopReason = "end_turn" - StopReasonToolUse StopReason = "tool_use" -) - -type BlockType string - -const ( - BlockTypeText BlockType = "text" - BlockTypeThinking BlockType = "thinking" - BlockTypeToolUse BlockType = "tool_use" - BlockTypeToolResult BlockType = "tool_result" -) - -type ContextEntityType string - -const ( - ContextEntityTypeService ContextEntityType = "service" - ContextEntityTypeLogEvent ContextEntityType = "log_event" -) - -type Request struct { - ChatProtocolVersion string `json:"chat_protocol_version"` - ConversationID string `json:"conversation_id"` - Messages []Message `json:"messages"` - ContextEntities []ContextEntity `json:"context_entities,omitempty"` - Tools []Tool `json:"tools,omitempty"` -} - -type Message struct { - Role Role `json:"role"` - Content []Block `json:"content"` - Model string `json:"model,omitempty"` - StopReason *StopReason `json:"stop_reason,omitempty"` -} - -type Block struct { - Type BlockType `json:"type"` - Text *Text `json:"text,omitempty"` - Thinking *Thinking `json:"thinking,omitempty"` - ToolUse *ToolUse `json:"tool_use,omitempty"` - ToolResult *ToolResult `json:"tool_result,omitempty"` -} - -type Text struct { - Content string `json:"content"` -} - -type Thinking struct { - Content string `json:"content"` -} - -type ToolUse struct { - ID string `json:"id"` - Name string `json:"name"` - Input json.RawMessage `json:"input"` -} - -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - IsError *bool `json:"is_error"` - Error string `json:"error,omitempty"` - Content json.RawMessage `json:"content,omitempty"` -} - -type ContextEntity struct { - EntityType ContextEntityType `json:"entity_type"` - EntityID string `json:"entity_id"` -} - -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"input_schema"` -} - -func Validate(req Request) error { - if req.ChatProtocolVersion == "" { - return fmt.Errorf(`"chat_protocol_version" is required`) - } - if req.ChatProtocolVersion != Version { - return fmt.Errorf(`"chat_protocol_version" must be %q`, Version) - } - if req.ConversationID == "" { - return fmt.Errorf(`"conversation_id" is required`) - } - if _, err := uuid.Parse(req.ConversationID); err != nil { - return fmt.Errorf(`"conversation_id" must be a valid UUID`) - } - if len(req.Messages) == 0 { - return fmt.Errorf(`"messages" is required`) - } - - for i, msg := range req.Messages { - if err := validateMessage(msg); err != nil { - return fmt.Errorf("messages[%d]: %w", i, err) - } - } - if err := validateMessageRoleOrder(req.Messages); err != nil { - return err - } - for i, entity := range req.ContextEntities { - if err := validateContextEntity(entity); err != nil { - return fmt.Errorf("context_entities[%d]: %w", i, err) - } - } - if err := validateToolReferences(req.Messages); err != nil { - return err - } - return nil -} - -func validateMessage(msg Message) error { - switch msg.Role { - case RoleUser, RoleAssistant: - default: - return fmt.Errorf("unsupported role %q", msg.Role) - } - if len(msg.Content) == 0 { - return fmt.Errorf("content is required") - } - if msg.StopReason != nil { - switch *msg.StopReason { - case StopReasonEndTurn, StopReasonToolUse: - default: - return fmt.Errorf("unsupported stop_reason %q", *msg.StopReason) - } - } - - for i, block := range msg.Content { - if err := validateBlock(block); err != nil { - return fmt.Errorf("content[%d]: %w", i, err) - } - } - return nil -} - -func validateBlock(block Block) error { - switch block.Type { - case BlockTypeText: - if block.Text == nil { - return fmt.Errorf("text block missing payload") - } - case BlockTypeThinking: - if block.Thinking == nil { - return fmt.Errorf("thinking block missing payload") - } - case BlockTypeToolUse: - if block.ToolUse == nil { - return fmt.Errorf("tool_use block missing payload") - } - if block.ToolUse.ID == "" || block.ToolUse.Name == "" { - return fmt.Errorf("tool_use missing id/name") - } - case BlockTypeToolResult: - if block.ToolResult == nil { - return fmt.Errorf("tool_result block missing payload") - } - if err := validateToolResult(*block.ToolResult); err != nil { - return err - } - default: - return fmt.Errorf("unsupported block type %q", block.Type) - } - return nil -} - -func validateToolResult(result ToolResult) error { - if result.ToolUseID == "" { - return fmt.Errorf("tool_result missing tool_use_id") - } - if result.IsError == nil { - return fmt.Errorf(`tool_result "is_error" is required`) - } - if *result.IsError { - if result.Error == "" { - return fmt.Errorf(`tool_result "error" is required when is_error is true`) - } - if len(result.Content) > 0 { - return fmt.Errorf(`tool_result "content" must be empty when is_error is true`) - } - return nil - } - - if len(result.Content) == 0 { - return fmt.Errorf(`tool_result "content" is required when is_error is false`) - } - var parsed map[string]any - if err := json.Unmarshal(result.Content, &parsed); err != nil { - return fmt.Errorf("tool_result content must be valid JSON object") - } - if _, ok := parsed["tool_use_id"]; ok { - return fmt.Errorf("tool_result.content must not contain tool_use_id") - } - return nil -} - -func validateContextEntity(entity ContextEntity) error { - if entity.EntityType == "" { - return fmt.Errorf(`"entity_type" is required`) - } - if entity.EntityType != ContextEntityTypeService && entity.EntityType != ContextEntityTypeLogEvent { - return fmt.Errorf(`invalid entity_type %q`, entity.EntityType) - } - if entity.EntityID == "" { - return fmt.Errorf(`"entity_id" is required`) - } - if _, err := uuid.Parse(entity.EntityID); err != nil { - return fmt.Errorf(`"entity_id" must be a valid UUID`) - } - return nil -} - -func validateToolReferences(messages []Message) error { - seen := make(map[string]struct{}) - resolved := make(map[string]struct{}) - for i, msg := range messages { - for j, block := range msg.Content { - switch block.Type { - case BlockTypeText, BlockTypeThinking: - // No cross-message linkage requirements. - case BlockTypeToolUse: - id := block.ToolUse.ID - if _, exists := seen[id]; exists { - return fmt.Errorf("messages[%d].content[%d]: duplicate tool_use id %q", i, j, id) - } - seen[id] = struct{}{} - case BlockTypeToolResult: - id := block.ToolResult.ToolUseID - if _, exists := seen[id]; !exists { - return fmt.Errorf("messages[%d].content[%d]: unknown tool_use_id %q", i, j, id) - } - resolved[id] = struct{}{} - } - } - } - for id := range seen { - if _, ok := resolved[id]; !ok { - return fmt.Errorf("tool_use %q is missing a matching tool_result", id) - } - } - return nil -} - -func validateMessageRoleOrder(messages []Message) error { - if len(messages) == 0 { - return nil - } - if messages[0].Role != RoleUser { - return fmt.Errorf("messages[0]: first message must be role=%q", RoleUser) - } - for i := 1; i < len(messages); i++ { - if messages[i].Role == messages[i-1].Role { - return fmt.Errorf("messages[%d]: role %q cannot repeat consecutively", i, messages[i].Role) - } - } - for i, msg := range messages { - toolUseIDs := toolUseIDsInMessage(msg) - if len(toolUseIDs) == 0 { - continue - } - if msg.Role != RoleAssistant { - return fmt.Errorf("messages[%d]: tool_use blocks are only allowed in assistant messages", i) - } - if i+1 >= len(messages) { - return fmt.Errorf("messages[%d]: tool_use requires an immediate following user tool_result message", i) - } - next := messages[i+1] - if next.Role != RoleUser { - return fmt.Errorf("messages[%d]: tool_use must be followed by role=%q", i+1, RoleUser) - } - nextResultIDs := toolResultIDsInMessage(next) - if len(nextResultIDs) == 0 { - return fmt.Errorf("messages[%d]: missing tool_result blocks for prior tool_use", i+1) - } - for _, id := range toolUseIDs { - if _, ok := nextResultIDs[id]; !ok { - return fmt.Errorf("messages[%d]: missing tool_result for tool_use_id %q", i+1, id) - } - } - } - return nil -} - -func toolUseIDsInMessage(msg Message) []string { - ids := make([]string, 0) - for _, block := range msg.Content { - if block.Type != BlockTypeToolUse || block.ToolUse == nil { - continue - } - ids = append(ids, block.ToolUse.ID) - } - return ids -} - -func toolResultIDsInMessage(msg Message) map[string]struct{} { - ids := make(map[string]struct{}) - for _, block := range msg.Content { - if block.Type != BlockTypeToolResult || block.ToolResult == nil { - continue - } - ids[block.ToolResult.ToolUseID] = struct{}{} - } - return ids -} diff --git a/internal/boundary/chat/protocolv2/request_test.go b/internal/boundary/chat/protocolv2/request_test.go deleted file mode 100644 index fa3708ba..00000000 --- a/internal/boundary/chat/protocolv2/request_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package protocolv2 - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestValidate(t *testing.T) { - t.Parallel() - - newValidRequest := func() Request { - ok := false - return Request{ - ChatProtocolVersion: Version, - ConversationID: "00000000-0000-0000-0000-000000000001", - Messages: []Message{ - { - Role: RoleUser, - Content: []Block{ - { - Type: BlockTypeText, - Text: &Text{Content: "run query"}, - }, - }, - }, - { - Role: RoleAssistant, - Content: []Block{ - { - Type: BlockTypeToolUse, - ToolUse: &ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 1"}`), - }, - }, - }, - }, - { - Role: RoleUser, - Content: []Block{ - { - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_1", - IsError: &ok, - Content: json.RawMessage(`{"rows":[1]}`), - }, - }, - }, - }, - }, - ContextEntities: []ContextEntity{ - { - EntityType: ContextEntityTypeService, - EntityID: "00000000-0000-0000-0000-000000000002", - }, - }, - } - } - - t.Run("valid request passes", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - if err := Validate(req); err != nil { - t.Fatalf("Validate() error = %v", err) - } - }) - - t.Run("missing protocol version fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ChatProtocolVersion = "" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"chat_protocol_version" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("unknown protocol version fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ChatProtocolVersion = "v9" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"chat_protocol_version" must be "v2"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid conversation id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ConversationID = "conv-1" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"conversation_id" must be a valid UUID`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("empty messages fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"messages" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("empty message content fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[0].Content = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "content is required") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid role fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[0].Role = Role("invalid") - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "unsupported role") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid stop reason fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - bad := StopReason("invalid") - req.Messages[0].StopReason = &bad - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "unsupported stop_reason") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result requires is_error", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[2].Content[0].ToolResult.IsError = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"is_error" is required`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result error requires error text and empty content", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - yes := true - req.Messages[2].Content[0].ToolResult.IsError = &yes - req.Messages[2].Content[0].ToolResult.Content = json.RawMessage(`{"rows":[1]}`) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"error" is required when is_error is true`) { - t.Fatalf("error = %v", err) - } - - req.Messages[2].Content[0].ToolResult.Error = "boom" - err = Validate(req) - if err == nil || !strings.Contains(err.Error(), `"content" must be empty when is_error is true`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_result success requires content and forbids embedded tool_use_id", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - no := false - req.Messages[2].Content[0].ToolResult.IsError = &no - req.Messages[2].Content[0].ToolResult.Content = nil - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"content" is required when is_error is false`) { - t.Fatalf("error = %v", err) - } - - req.Messages[2].Content[0].ToolResult.Content = json.RawMessage(`{"tool_use_id":"toolu_1"}`) - err = Validate(req) - if err == nil || !strings.Contains(err.Error(), "must not contain tool_use_id") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid context entity type fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ContextEntities[0].EntityType = ContextEntityType("policy") - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "invalid entity_type") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("invalid context entity id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.ContextEntities[0].EntityID = "not-a-uuid" - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `"entity_id" must be a valid UUID`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("unknown tool_use_id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages[2].Content = append(req.Messages[2].Content, Block{ - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_missing", - IsError: req.Messages[2].Content[0].ToolResult.IsError, - Content: json.RawMessage(`{"rows":[2]}`), - }, - }) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("duplicate tool_use id fails", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = append(req.Messages, Message{ - Role: RoleAssistant, - Content: []Block{{ - Type: BlockTypeToolUse, - ToolUse: &ToolUse{ - ID: "toolu_1", - Name: "query", - Input: json.RawMessage(`{"sql":"select 2"}`), - }, - }}, - }, Message{ - Role: RoleUser, - Content: []Block{{ - Type: BlockTypeToolResult, - ToolResult: &ToolResult{ - ToolUseID: "toolu_1", - IsError: req.Messages[2].Content[0].ToolResult.IsError, - Content: json.RawMessage(`{"rows":[9]}`), - }, - }}, - }) - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `duplicate tool_use id "toolu_1"`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("tool_use must be followed by immediate user tool_result message", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - req.Messages[1], - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), "tool_use requires an immediate following user tool_result message") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("assistant turn between tool_use and tool_result fails ordering", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - req.Messages[1], - { - Role: RoleAssistant, - Content: []Block{{ - Type: BlockTypeText, - Text: &Text{Content: "intermediate"}, - }}, - }, - req.Messages[2], - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `role "assistant" cannot repeat consecutively`) { - t.Fatalf("error = %v", err) - } - }) - - t.Run("consecutive roles fail", func(t *testing.T) { - t.Parallel() - req := newValidRequest() - req.Messages = []Message{ - req.Messages[0], - { - Role: RoleUser, - Content: []Block{{ - Type: BlockTypeText, - Text: &Text{Content: "again"}, - }}, - }, - } - err := Validate(req) - if err == nil || !strings.Contains(err.Error(), `role "user" cannot repeat consecutively`) { - t.Fatalf("error = %v", err) - } - }) -} diff --git a/internal/boundary/chat/request.go b/internal/boundary/chat/request.go deleted file mode 100644 index f34c23da..00000000 --- a/internal/boundary/chat/request.go +++ /dev/null @@ -1,12 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// Request is the input to the Chat API. -// The client sends the full conversation history on every request. -type Request struct { - ConversationID string `json:"conversation_id"` - Messages []domain.Message `json:"messages"` - ContextEntities []domain.ContextEntity `json:"context_entities,omitempty"` - Tools []Tool `json:"tools"` -} diff --git a/internal/boundary/chat/stream.go b/internal/boundary/chat/stream.go deleted file mode 100644 index e9d6accd..00000000 --- a/internal/boundary/chat/stream.go +++ /dev/null @@ -1,45 +0,0 @@ -package chat - -import ( - "bufio" - "io" - "strings" -) - -const streamDone = "[DONE]" - -const ( - streamScannerInitialBuffer = 64 * 1024 - streamScannerMaxBuffer = 4 * 1024 * 1024 -) - -type streamDataHandler func(data []byte, done bool) error - -func readStream(r io.Reader, handler streamDataHandler) error { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, streamScannerInitialBuffer), streamScannerMaxBuffer) - - for scanner.Scan() { - line := scanner.Text() - if line == "" || strings.HasPrefix(line, ":") { - continue - } - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - if data == streamDone { - if err := handler(nil, true); err != nil { - return err - } - continue - } - - if err := handler([]byte(data), false); err != nil { - return err - } - } - - return scanner.Err() -} diff --git a/internal/boundary/chat/stream_errors.go b/internal/boundary/chat/stream_errors.go deleted file mode 100644 index bbf7e91d..00000000 --- a/internal/boundary/chat/stream_errors.go +++ /dev/null @@ -1,93 +0,0 @@ -package chat - -import ( - "context" - "errors" - "regexp" - "strconv" - "strings" -) - -// StreamErrorClass is a normalized category for stream failures. -type StreamErrorClass string - -const ( - StreamErrorClassCancelled StreamErrorClass = "cancelled" - StreamErrorClassTimeout StreamErrorClass = "timeout" - StreamErrorClassProtocol StreamErrorClass = "protocol_error" - StreamErrorClassRequest StreamErrorClass = "request_error" - StreamErrorClassServer StreamErrorClass = "server_error" - StreamErrorClassUnknown StreamErrorClass = "unknown" -) - -var httpStatusCodePattern = regexp.MustCompile(`\b([1-5][0-9]{2})\b`) - -// ClassifyStreamError maps raw stream errors into stable operational buckets. -func ClassifyStreamError(err error) StreamErrorClass { - if err == nil { - return StreamErrorClassUnknown - } - if errors.Is(err, context.Canceled) { - return StreamErrorClassCancelled - } - if errors.Is(err, context.DeadlineExceeded) { - return StreamErrorClassTimeout - } - - msg := strings.ToLower(err.Error()) - switch { - case strings.Contains(msg, "user_cancelled"), - strings.Contains(msg, "context canceled"), - strings.Contains(msg, "canceled"): - return StreamErrorClassCancelled - case strings.Contains(msg, "deadline exceeded"), - strings.Contains(msg, "timeout"): - return StreamErrorClassTimeout - case strings.Contains(msg, "protocol error"), - strings.Contains(msg, "parse event"): - return StreamErrorClassProtocol - case strings.Contains(msg, "server error:"), - strings.Contains(msg, "chat api error"): - if status, ok := firstHTTPStatusCode(msg); ok { - switch { - case status >= 400 && status <= 499: - return StreamErrorClassRequest - case status >= 500 && status <= 599: - return StreamErrorClassServer - } - } - return StreamErrorClassServer - default: - return StreamErrorClassUnknown - } -} - -func firstHTTPStatusCode(msg string) (int, bool) { - matches := httpStatusCodePattern.FindStringSubmatch(msg) - if len(matches) < 2 { - return 0, false - } - status, err := strconv.Atoi(matches[1]) - if err != nil || status < 100 || status > 599 { - return 0, false - } - return status, true -} - -// UserFacingStreamError returns concise error copy suitable for toast UI. -func UserFacingStreamError(err error) string { - switch ClassifyStreamError(err) { - case StreamErrorClassCancelled: - return "Request was cancelled." - case StreamErrorClassTimeout: - return "The response timed out. Please try again." - case StreamErrorClassProtocol: - return "The chat service returned an unexpected stream format. Please retry." - case StreamErrorClassRequest: - return "The request was rejected by the chat service. Please retry." - case StreamErrorClassServer: - return "The chat service returned an internal error. Please try again." - default: - return "Something went wrong while streaming the response. Please try again." - } -} diff --git a/internal/boundary/chat/stream_errors_test.go b/internal/boundary/chat/stream_errors_test.go deleted file mode 100644 index 4afbb26d..00000000 --- a/internal/boundary/chat/stream_errors_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package chat - -import ( - "context" - "errors" - "testing" -) - -func TestClassifyStreamError(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - err error - want StreamErrorClass - }{ - {name: "context canceled", err: context.Canceled, want: StreamErrorClassCancelled}, - {name: "deadline exceeded", err: context.DeadlineExceeded, want: StreamErrorClassTimeout}, - {name: "user cancelled text", err: errors.New("user_cancelled"), want: StreamErrorClassCancelled}, - {name: "protocol", err: errors.New("protocol error: unknown event type"), want: StreamErrorClassProtocol}, - {name: "parse event", err: errors.New("parse event: bad json"), want: StreamErrorClassProtocol}, - {name: "server", err: errors.New("server error: internal error"), want: StreamErrorClassServer}, - {name: "server 400", err: errors.New("server error: stream: POST https://x: 400 Bad Request"), want: StreamErrorClassRequest}, - {name: "chat api", err: errors.New("chat API error 500"), want: StreamErrorClassServer}, - {name: "chat api 400", err: errors.New("chat API error 400: bad request"), want: StreamErrorClassRequest}, - {name: "unknown", err: errors.New("boom"), want: StreamErrorClassUnknown}, - {name: "nil", err: nil, want: StreamErrorClassUnknown}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := ClassifyStreamError(tt.err); got != tt.want { - t.Fatalf("ClassifyStreamError() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestUserFacingStreamError(t *testing.T) { - t.Parallel() - - if got := UserFacingStreamError(errors.New("chat API error 400: bad request")); got != "The request was rejected by the chat service. Please retry." { - t.Fatalf("400 message = %q", got) - } - if got := UserFacingStreamError(errors.New("chat API error 500: internal")); got != "The chat service returned an internal error. Please try again." { - t.Fatalf("500 message = %q", got) - } -} diff --git a/internal/boundary/chat/stream_test.go b/internal/boundary/chat/stream_test.go deleted file mode 100644 index 90526164..00000000 --- a/internal/boundary/chat/stream_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package chat - -import ( - "errors" - "strings" - "testing" - - corechat "github.com/usetero/cli/internal/core/chat" -) - -func TestReadStream(t *testing.T) { - t.Parallel() - - t.Run("decodes happy-path v2 events", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"Hello"}} -data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn","input_tokens":1,"output_tokens":1}} -data: [DONE] -` - machine := corechat.NewStreamMachine("conv-1") - var snapshots []corechat.StreamSnapshot - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - var ( - snap *corechat.StreamSnapshot - err error - ) - if done { - snap, err = machine.ConsumeDone() - } else { - snap, err = machine.ConsumeData(data) - } - if err != nil { - return err - } - snapshots = append(snapshots, *snap) - return nil - }) - if err != nil { - t.Fatalf("readStream() error = %v", err) - } - if len(snapshots) != 4 { - t.Fatalf("snapshots = %d, want 4", len(snapshots)) - } - if snapshots[0].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[0].Status = %q", snapshots[0].Status) - } - if snapshots[1].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[1].Status = %q", snapshots[1].Status) - } - if snapshots[2].Status != corechat.StreamStatusStreaming { - t.Fatalf("snapshot[2].Status = %q", snapshots[2].Status) - } - if !snapshots[3].Done { - t.Fatal("snapshot[3].Done = false, want true") - } - }) - - t.Run("stops when handler returns error for error frame", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","error":"internal error"} -data: [DONE] -` - calls := 0 - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - calls++ - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "server error: internal error") { - t.Fatalf("error = %q", err.Error()) - } - if calls != 2 { - t.Fatalf("handler calls = %d, want 2", calls) - } - }) - - t.Run("rejects unknown event types", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"bogus"} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown event type") { - t.Fatalf("error = %q", err.Error()) - } - }) - - t.Run("rejects unknown fields", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"x"},"unexpected":1} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown field") { - t.Fatalf("error = %q", err.Error()) - } - }) - - t.Run("rejects missing protocol version", func(t *testing.T) { - t.Parallel() - - stream := `data: {"type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "chat_stream_version") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects mismatched protocol version", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v1","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "unsupported chat_stream_version") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects message_stop without token fields", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_stop","message_stop":{"stop_reason":"end_turn"}} -` - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if err == nil || !strings.Contains(err.Error(), "message_stop missing required fields") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("stops on handler error", func(t *testing.T) { - t.Parallel() - - stream := `data: {"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}} -data: {"chat_stream_version":"v2","type":"text_delta","text":{"content":"x"}} -` - handlerErr := errors.New("stop") - calls := 0 - machine := corechat.NewStreamMachine("conv-1") - err := readStream(strings.NewReader(stream), func(data []byte, done bool) error { - calls++ - if calls == 2 { - return handlerErr - } - if done { - _, err := machine.ConsumeDone() - return err - } - _, err := machine.ConsumeData(data) - return err - }) - if !errors.Is(err, handlerErr) { - t.Fatalf("error = %v, want %v", err, handlerErr) - } - }) -} diff --git a/internal/boundary/chat/tool.go b/internal/boundary/chat/tool.go deleted file mode 100644 index 0f8f3c5e..00000000 --- a/internal/boundary/chat/tool.go +++ /dev/null @@ -1,53 +0,0 @@ -package chat - -import "encoding/json" - -// Tool defines a tool the AI can call. -// This is the wire format sent to the Chat API. -type Tool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema Schema `json:"input_schema"` -} - -// Schema defines the JSON Schema for tool input. -type Schema struct { - Type string `json:"type"` - Properties map[string]Property `json:"properties"` // Always required by Anthropic API - Required []string `json:"required,omitempty"` -} - -// MarshalJSON ensures Properties is never null (Anthropic API requires it). -func (s Schema) MarshalJSON() ([]byte, error) { - type schema Schema // avoid recursion - if s.Properties == nil { - s.Properties = map[string]Property{} - } - return json.Marshal(schema(s)) -} - -// NewObjectSchema creates an object schema with the given properties. -func NewObjectSchema(properties map[string]Property, required []string) Schema { - if properties == nil { - properties = map[string]Property{} - } - return Schema{ - Type: "object", - Properties: properties, - Required: required, - } -} - -// Property defines a single property in a JSON Schema. -type Property struct { - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *Items `json:"items,omitempty"` -} - -// Items defines the schema for array items. -type Items struct { - Type string `json:"type,omitempty"` - Items *Items `json:"items,omitempty"` -} diff --git a/internal/boundary/chat/tool_validation.go b/internal/boundary/chat/tool_validation.go deleted file mode 100644 index 5b68a321..00000000 --- a/internal/boundary/chat/tool_validation.go +++ /dev/null @@ -1,32 +0,0 @@ -package chat - -import "fmt" - -func validateTools(tools []Tool) error { - seen := make(map[string]struct{}, len(tools)) - for i, tool := range tools { - if tool.Name == "" { - return fmt.Errorf("tools[%d]: name is required", i) - } - if _, exists := seen[tool.Name]; exists { - return fmt.Errorf("tools[%d]: duplicate tool name %q", i, tool.Name) - } - seen[tool.Name] = struct{}{} - - if tool.Description == "" { - return fmt.Errorf("tools[%d]: description is required", i) - } - if tool.InputSchema.Type != "object" { - return fmt.Errorf("tools[%d]: input_schema.type must be object", i) - } - if tool.InputSchema.Properties == nil { - return fmt.Errorf("tools[%d]: input_schema.properties is required", i) - } - for key := range tool.InputSchema.Properties { - if key == "" { - return fmt.Errorf("tools[%d]: property name must not be empty", i) - } - } - } - return nil -} diff --git a/internal/boundary/chat/tool_validation_test.go b/internal/boundary/chat/tool_validation_test.go deleted file mode 100644 index f27d2e54..00000000 --- a/internal/boundary/chat/tool_validation_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package chat - -import "testing" - -func TestValidateTools(t *testing.T) { - t.Parallel() - - valid := []Tool{{ - Name: "query", - Description: "Run SQL", - InputSchema: NewObjectSchema(map[string]Property{"sql": {Type: "string"}}, []string{"sql"}), - }} - if err := validateTools(valid); err != nil { - t.Fatalf("validateTools(valid) error = %v", err) - } - - tests := []struct { - name string - tools []Tool - }{ - {name: "missing name", tools: []Tool{{Description: "d", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "duplicate name", tools: []Tool{{Name: "q", Description: "d", InputSchema: NewObjectSchema(map[string]Property{}, nil)}, {Name: "q", Description: "d2", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "missing description", tools: []Tool{{Name: "q", InputSchema: NewObjectSchema(map[string]Property{}, nil)}}}, - {name: "wrong schema type", tools: []Tool{{Name: "q", Description: "d", InputSchema: Schema{Type: "string", Properties: map[string]Property{}}}}}, - {name: "nil properties", tools: []Tool{{Name: "q", Description: "d", InputSchema: Schema{Type: "object"}}}}, - {name: "empty property name", tools: []Tool{{Name: "q", Description: "d", InputSchema: NewObjectSchema(map[string]Property{"": {Type: "string"}}, nil)}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if err := validateTools(tt.tools); err == nil { - t.Fatalf("validateTools(%s): expected error", tt.name) - } - }) - } -} diff --git a/internal/cmd/dependencies.go b/internal/cmd/dependencies.go index db7ad988..b04e2fa8 100644 --- a/internal/cmd/dependencies.go +++ b/internal/cmd/dependencies.go @@ -19,7 +19,6 @@ func newAuthService(cliConfig *config.CLIConfig, scope log.Scope) *auth.Service cliConfig.WorkOSClientID, cliConfig.APIEndpoint, cliConfig.PowerSyncEndpoint, - cliConfig.ChatEndpoint, ) return auth.NewService(workosClient, tokenStore, scope) } diff --git a/internal/config/cli.go b/internal/config/cli.go index 0e5a3c1f..0c98eaaf 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,7 +8,6 @@ import ( type environmentDefaults struct { APIEndpoint string PowerSyncEndpoint string - ChatEndpoint string WorkOSClientID string } @@ -18,19 +17,16 @@ var environments = map[string]environmentDefaults{ "local": { APIEndpoint: "http://localhost:18081", PowerSyncEndpoint: "http://localhost:18084", - ChatEndpoint: "http://localhost:18083", WorkOSClientID: "client_01JQCC2CJMTB8AY2JRMZXFY9R1", }, "dev": { APIEndpoint: "https://api.usetero.dev", PowerSyncEndpoint: "https://powersync.usetero.dev", - ChatEndpoint: "https://chat.usetero.dev", WorkOSClientID: "client_01JQCC2CJMTB8AY2JRMZXFY9R1", }, "prd": { APIEndpoint: "https://api.usetero.com", PowerSyncEndpoint: "https://powersync.usetero.com", - ChatEndpoint: "https://chat.usetero.com", WorkOSClientID: "client_01JQCC2D06JF9ASFA6GRHMFA3N", }, } @@ -43,12 +39,10 @@ type CLIConfig struct { // APIEndpoint is the Tero control plane GraphQL endpoint APIEndpoint string - // PowerSyncEndpoint is the PowerSync service endpoint for local-first sync + // PowerSyncEndpoint is retained only as a token audience for auth + // compatibility; the CLI no longer syncs. PowerSyncEndpoint string - // ChatEndpoint is the Chat API endpoint for message streaming - ChatEndpoint string - // WorkOSClientID is the WorkOS OAuth client ID for authentication WorkOSClientID string @@ -73,7 +67,6 @@ func LoadCLIConfig() *CLIConfig { Env: env, APIEndpoint: getEnvOrDefault("TERO_API_ENDPOINT", defaults.APIEndpoint), PowerSyncEndpoint: getEnvOrDefault("TERO_POWERSYNC_ENDPOINT", defaults.PowerSyncEndpoint), - ChatEndpoint: getEnvOrDefault("TERO_CHAT_ENDPOINT", defaults.ChatEndpoint), WorkOSClientID: getEnvOrDefault("WORKOS_CLIENT_ID", defaults.WorkOSClientID), Debug: os.Getenv("TERO_DEBUG") == "true" || os.Getenv("TERO_DEBUG") == "1", } diff --git a/internal/config/cli_test.go b/internal/config/cli_test.go index 65dd6e6c..812453b0 100644 --- a/internal/config/cli_test.go +++ b/internal/config/cli_test.go @@ -26,9 +26,6 @@ func TestLoadCLIConfig_DefaultsToPrd(t *testing.T) { if cfg.PowerSyncEndpoint != "https://powersync.usetero.com" { t.Errorf("PowerSyncEndpoint = %q, want production", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "https://chat.usetero.com" { - t.Errorf("ChatEndpoint = %q, want production", cfg.ChatEndpoint) - } if cfg.WorkOSClientID != "client_01JQCC2D06JF9ASFA6GRHMFA3N" { t.Errorf("WorkOSClientID = %q, want production client", cfg.WorkOSClientID) } @@ -52,9 +49,6 @@ func TestLoadCLIConfig_Local(t *testing.T) { if cfg.PowerSyncEndpoint != "http://localhost:18084" { t.Errorf("PowerSyncEndpoint = %q, want localhost", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "http://localhost:18083" { - t.Errorf("ChatEndpoint = %q, want localhost", cfg.ChatEndpoint) - } if cfg.WorkOSClientID != "client_01JQCC2CJMTB8AY2JRMZXFY9R1" { t.Errorf("WorkOSClientID = %q, want local/dev client", cfg.WorkOSClientID) } @@ -78,9 +72,6 @@ func TestLoadCLIConfig_Dev(t *testing.T) { if cfg.PowerSyncEndpoint != "https://powersync.usetero.dev" { t.Errorf("PowerSyncEndpoint = %q, want dev", cfg.PowerSyncEndpoint) } - if cfg.ChatEndpoint != "https://chat.usetero.dev" { - t.Errorf("ChatEndpoint = %q, want dev", cfg.ChatEndpoint) - } } func TestLoadCLIConfig_Prd(t *testing.T) { diff --git a/internal/core/chat/accumulator.go b/internal/core/chat/accumulator.go deleted file mode 100644 index d255915f..00000000 --- a/internal/core/chat/accumulator.go +++ /dev/null @@ -1,207 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - "sort" - - "github.com/usetero/cli/internal/domain" -) - -// accumulator builds a domain.Message from a stream of protocol events. -type accumulator struct { - model string - stopReason string - blocks []domain.Block - current *domain.Block - nextIndex int - title string - contextWindow int - inputTokens int - outputTokens int - - openTools map[string]*toolAccumulator - openToolOrder []string - seenTools map[string]struct{} -} - -type toolAccumulator struct { - index int - id string - name string - input []byte -} - -func newAccumulator() *accumulator { - return &accumulator{ - openTools: make(map[string]*toolAccumulator), - seenTools: make(map[string]struct{}), - } -} - -func (a *accumulator) handle(e event) error { - if e.Done { - return nil - } - - switch e.Type { - case EventTypeMessageStart: - a.model = e.MessageStart.Model - a.contextWindow = *e.MessageStart.ContextWindow - return nil - - case EventTypeMessageStop: - if len(a.openTools) > 0 { - return fmt.Errorf("protocol error: message_stop with %d unfinished tool blocks", len(a.openTools)) - } - a.stopReason = e.MessageStop.StopReason - a.inputTokens = *e.MessageStop.InputTokens - a.outputTokens = *e.MessageStop.OutputTokens - a.finalizeCurrent() - return nil - - case EventTypeTextDelta: - a.handleTextDelta(*e.Text.Content) - return nil - - case EventTypeThinkingDelta: - a.handleThinkingDelta(*e.Thinking.Content) - return nil - - case EventTypeToolUse: - a.finalizeCurrent() - if _, exists := a.seenTools[e.ToolUse.ID]; exists { - return fmt.Errorf("protocol error: duplicate tool_use id %q", e.ToolUse.ID) - } - a.seenTools[e.ToolUse.ID] = struct{}{} - a.openTools[e.ToolUse.ID] = &toolAccumulator{ - index: a.nextIndex, - id: e.ToolUse.ID, - name: e.ToolUse.Name, - } - a.openToolOrder = append(a.openToolOrder, e.ToolUse.ID) - a.nextIndex++ - return nil - - case EventTypeToolInputDelta: - tool, ok := a.openTools[e.ToolUseID] - if !ok { - return fmt.Errorf("protocol error: tool_input_delta for unknown tool_use_id %q", e.ToolUseID) - } - tool.input = append(tool.input, e.ToolInputDelta...) - return nil - - case EventTypeContentBlockStop: - if e.ToolUseID == "" { - if len(a.openTools) > 0 && a.current == nil { - return fmt.Errorf("protocol error: content_block_stop missing tool_use_id for open tool block") - } - a.finalizeCurrent() - return nil - } - return a.finalizeTool(e.ToolUseID) - - case EventTypeMetadataUpdate: - if e.Metadata.Title != "" { - a.title = e.Metadata.Title - } - return nil - } - - return fmt.Errorf("protocol error: unhandled event type %q", e.Type) -} - -func (a *accumulator) handleTextDelta(delta string) { - if a.current == nil || a.current.Type != domain.BlockTypeText { - a.finalizeCurrent() - a.current = &domain.Block{ - Index: a.nextIndex, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: delta}, - } - a.nextIndex++ - return - } - a.current.Text.Content += delta -} - -func (a *accumulator) handleThinkingDelta(delta string) { - if a.current == nil || a.current.Type != domain.BlockTypeThinking { - a.finalizeCurrent() - a.current = &domain.Block{ - Index: a.nextIndex, - Type: domain.BlockTypeThinking, - Thinking: &domain.Thinking{Content: delta}, - } - a.nextIndex++ - return - } - a.current.Thinking.Content += delta -} - -func (a *accumulator) finalizeCurrent() { - if a.current == nil { - return - } - a.blocks = append(a.blocks, *a.current) - a.current = nil -} - -func (a *accumulator) finalizeTool(toolUseID string) error { - tool, ok := a.openTools[toolUseID] - if !ok { - return fmt.Errorf("protocol error: content_block_stop for unknown tool_use_id %q", toolUseID) - } - if !json.Valid(tool.input) { - return fmt.Errorf("protocol error: invalid JSON tool input for %q", toolUseID) - } - - a.blocks = append(a.blocks, domain.Block{ - Index: tool.index, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: tool.id, - Name: tool.name, - Input: json.RawMessage(append([]byte(nil), tool.input...)), - InputComplete: true, - }, - }) - delete(a.openTools, toolUseID) - return nil -} - -func (a *accumulator) message() *domain.Message { - content := make([]domain.Block, len(a.blocks)) - copy(content, a.blocks) - - if a.current != nil { - content = append(content, *a.current) - } - - for _, id := range a.openToolOrder { - tool, ok := a.openTools[id] - if !ok { - continue - } - content = append(content, domain.Block{ - Index: tool.index, - Type: domain.BlockTypeToolUse, - ToolUse: &domain.ToolUse{ - ID: tool.id, - Name: tool.name, - Input: json.RawMessage(append([]byte(nil), tool.input...)), - }, - }) - } - - sort.Slice(content, func(i, j int) bool { - return content[i].Index < content[j].Index - }) - - return &domain.Message{ - Role: domain.RoleAssistant, - Content: content, - Model: a.model, - StopReason: a.stopReason, - } -} diff --git a/internal/core/chat/accumulator_test.go b/internal/core/chat/accumulator_test.go deleted file mode 100644 index 07f695cc..00000000 --- a/internal/core/chat/accumulator_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package chat - -import ( - "strings" - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestAccumulator(t *testing.T) { - t.Parallel() - - t.Run("happy path text-only stream", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("Hello")}}, - {Type: EventTypeTextDelta, Text: &textContent{Content: strPtr(" world")}}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(9), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if msg.Model != "claude-3" { - t.Fatalf("model = %q", msg.Model) - } - if msg.StopReason != "end_turn" { - t.Fatalf("stop_reason = %q", msg.StopReason) - } - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeText { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if msg.Content[0].Text.Content != "Hello world" { - t.Fatalf("text = %q", msg.Content[0].Text.Content) - } - }) - - t.Run("single tool call", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "tool-1", Name: "query"}}, - {Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `{"sql":`}, - {Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `"SELECT 1"}`}, - {Type: EventTypeContentBlockStop, ToolUseID: "tool-1"}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeToolUse { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if string(msg.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("input = %s", string(msg.Content[0].ToolUse.Input)) - } - if !msg.Content[0].ToolUse.InputComplete { - t.Fatal("expected InputComplete=true") - } - }) - - t.Run("multiple interleaved tool calls", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - events := []event{ - {Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "a", Name: "query"}}, - {Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "b", Name: "query"}}, - {Type: EventTypeToolInputDelta, ToolUseID: "a", ToolInputDelta: `{"sql":"SELECT `}, - {Type: EventTypeToolInputDelta, ToolUseID: "b", ToolInputDelta: `{"sql":"SELECT `}, - {Type: EventTypeToolInputDelta, ToolUseID: "a", ToolInputDelta: `1"}`}, - {Type: EventTypeToolInputDelta, ToolUseID: "b", ToolInputDelta: `2"}`}, - {Type: EventTypeContentBlockStop, ToolUseID: "b"}, - {Type: EventTypeContentBlockStop, ToolUseID: "a"}, - {Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}, - {Done: true}, - } - for _, e := range events { - if err := acc.handle(e); err != nil { - t.Fatalf("handle(%s) error = %v", e.Type, err) - } - } - - msg := acc.message() - if len(msg.Content) != 2 { - t.Fatalf("blocks = %d, want 2", len(msg.Content)) - } - if string(msg.Content[0].ToolUse.Input) != `{"sql":"SELECT 1"}` { - t.Fatalf("tool a input = %s", string(msg.Content[0].ToolUse.Input)) - } - if string(msg.Content[1].ToolUse.Input) != `{"sql":"SELECT 2"}` { - t.Fatalf("tool b input = %s", string(msg.Content[1].ToolUse.Input)) - } - }) - - t.Run("malformed ordering errors", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - if err := acc.handle(event{Type: EventTypeToolInputDelta, ToolUseID: "missing", ToolInputDelta: "{}"}); err == nil { - t.Fatal("expected unknown tool error") - } - - acc = newAccumulator() - _ = acc.handle(event{Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - if err := acc.handle(event{Type: EventTypeContentBlockStop, ToolUseID: "missing"}); err == nil { - t.Fatal("expected unknown stop id error") - } - }) - - t.Run("rejects invalid tool input JSON", func(t *testing.T) { - t.Parallel() - - acc := newAccumulator() - _ = acc.handle(event{Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _ = acc.handle(event{Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: "tool-1", Name: "query"}}) - _ = acc.handle(event{Type: EventTypeToolInputDelta, ToolUseID: "tool-1", ToolInputDelta: `{`}) - err := acc.handle(event{Type: EventTypeContentBlockStop, ToolUseID: "tool-1"}) - if err == nil || !strings.Contains(err.Error(), "invalid JSON") { - t.Fatalf("error = %v, want invalid JSON", err) - } - }) -} diff --git a/internal/core/chat/fuzz_test.go b/internal/core/chat/fuzz_test.go deleted file mode 100644 index 5d1d9540..00000000 --- a/internal/core/chat/fuzz_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package chat - -import ( - "fmt" - "strings" - "testing" -) - -func FuzzDecodeEventData(f *testing.F) { - f.Add([]byte(`{"chat_stream_version":"v2","type":"message_start","message_start":{"model":"claude-3","context_window":200000}}`)) - f.Add([]byte(`{"chat_stream_version":"v2","type":"tool_use","tool_use":{"id":"tool-1","name":"query"}}`)) - f.Add([]byte(`{"chat_stream_version":"v2","error":"internal error"}`)) - f.Add([]byte(`{"chat_stream_version":"v2","type":"unknown"}`)) - f.Add([]byte(`not json`)) - - f.Fuzz(func(t *testing.T, data []byte) { - e, err := decodeEventData(data) - if err == nil && e.Type == "" { - t.Fatalf("decodeEventData returned nil error with empty type for %q", string(data)) - } - }) -} - -func FuzzReducerApplySequence(f *testing.F) { - f.Add([]byte{0, 1, 6, 8}) - f.Add([]byte{0, 3, 4, 5, 6, 8}) - f.Add([]byte{0, 3, 3, 4, 4, 5, 5, 6, 8}) - f.Add([]byte{1, 8}) - - f.Fuzz(func(t *testing.T, ops []byte) { - r := newReducer("conv-fuzz") - for i, op := range ops { - seq := i + 1 - if op%16 == 0 && i > 0 { - seq = i // intentionally non-monotonic sometimes - } - - toolID := fmt.Sprintf("tool-%d", op%3) - var e event - switch op % 9 { - case 0: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}} - case 1: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr(strings.Repeat("x", int(op%5)))}} - case 2: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeThinkingDelta, Thinking: &textContent{Content: strPtr(strings.Repeat("t", int(op%5)))}} - case 3: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeToolUse, ToolUse: &toolUseEvent{ID: toolID, Name: "query"}} - case 4: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeToolInputDelta, ToolUseID: toolID, ToolInputDelta: `{"k":1}`} - case 5: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeContentBlockStop, ToolUseID: toolID} - case 6: - stopReason := "end_turn" - if op%2 == 0 { - stopReason = "tool_use" - } - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: stopReason, InputTokens: intPtr(1), OutputTokens: intPtr(1)}} - case 7: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Type: EventTypeMetadataUpdate, Metadata: &metadata{Title: "fuzz"}} - default: - e = event{ConversationID: "conv-fuzz", TurnID: "turn-fuzz", Seq: seq, Done: true} - } - - _, _ = r.apply(e) - if op%11 == 0 { - _ = r.abortSnapshot("fuzz abort") - } - } - }) -} diff --git a/internal/core/chat/protocol.go b/internal/core/chat/protocol.go deleted file mode 100644 index edf75ea6..00000000 --- a/internal/core/chat/protocol.go +++ /dev/null @@ -1,98 +0,0 @@ -package chat - -import "encoding/json" - -const chatStreamVersionV2 = "v2" - -// EventType identifies the kind of SSE event from the Chat API. -type EventType string - -const ( - EventTypeMessageStart EventType = "message_start" - EventTypeTextDelta EventType = "text_delta" - EventTypeThinkingDelta EventType = "thinking_delta" - EventTypeToolUse EventType = "tool_use" - EventTypeToolInputDelta EventType = "tool_input_delta" - EventTypeContentBlockStop EventType = "content_block_stop" - EventTypeMessageStop EventType = "message_stop" - EventTypeMetadataUpdate EventType = "metadata_update" -) - -// event is a single event from the Chat API response stream. -// This is internal to the chat package. -type event struct { - ChatStreamVersion string `json:"chat_stream_version"` - ConversationID string `json:"conversation_id,omitempty"` - TurnID string `json:"turn_id,omitempty"` - Seq int `json:"seq,omitempty"` - Type EventType `json:"type"` - - Text *textContent `json:"text,omitempty"` - Thinking *textContent `json:"thinking,omitempty"` - - ToolUse *toolUseEvent `json:"tool_use,omitempty"` - ToolUseID string `json:"tool_use_id,omitempty"` - ToolInputDelta string `json:"tool_input_delta,omitempty"` - - MessageStart *messageStart `json:"message_start,omitempty"` - MessageStop *messageStop `json:"message_stop,omitempty"` - Metadata *metadata `json:"metadata,omitempty"` - - Done bool `json:"-"` -} - -type textContent struct { - Content *string `json:"content"` -} - -type toolUseEvent struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type messageStart struct { - Model string `json:"model"` - ContextWindow *int `json:"context_window"` -} - -type messageStop struct { - StopReason string `json:"stop_reason"` - InputTokens *int `json:"input_tokens"` - OutputTokens *int `json:"output_tokens"` -} - -type metadata struct { - Title string `json:"title,omitempty"` -} - -// errorEnvelope represents an error frame from the stream. -type errorEnvelope struct { - Error any `json:"error"` -} - -func parseErrorMessage(raw json.RawMessage) (string, bool) { - var frame errorEnvelope - if err := json.Unmarshal(raw, &frame); err != nil { - return "", false - } - if frame.Error == nil { - return "", false - } - - switch v := frame.Error.(type) { - case string: - if v == "" { - return "", false - } - return v, true - case map[string]any: - if msg, _ := v["message"].(string); msg != "" { - return msg, true - } - if msg, _ := v["error"].(string); msg != "" { - return msg, true - } - } - - return "", false -} diff --git a/internal/core/chat/reducer.go b/internal/core/chat/reducer.go deleted file mode 100644 index 3e884fc4..00000000 --- a/internal/core/chat/reducer.go +++ /dev/null @@ -1,136 +0,0 @@ -package chat - -import ( - "fmt" -) - -type reducer struct { - acc *accumulator - conversationID string - turnID string - lastSeq int - terminal bool - started bool - stopped bool -} - -func newReducer(conversationID string) *reducer { - return &reducer{ - acc: newAccumulator(), - conversationID: conversationID, - } -} - -func (r *reducer) apply(e event) (*StreamSnapshot, error) { - if r.terminal { - return nil, fmt.Errorf("received event after terminal state") - } - - if e.ConversationID != "" && r.conversationID != "" && e.ConversationID != r.conversationID { - return nil, fmt.Errorf("conversation_id mismatch: got %q want %q", e.ConversationID, r.conversationID) - } - if e.ConversationID != "" { - r.conversationID = e.ConversationID - } - - if e.TurnID != "" { - if r.turnID == "" { - r.turnID = e.TurnID - } else if e.TurnID != r.turnID { - return nil, fmt.Errorf("turn_id mismatch: got %q want %q", e.TurnID, r.turnID) - } - } - - if e.Seq > 0 { - if r.lastSeq > 0 && e.Seq <= r.lastSeq { - return nil, fmt.Errorf("non-monotonic seq: got %d after %d", e.Seq, r.lastSeq) - } - r.lastSeq = e.Seq - } - - if e.Done { - if !r.stopped { - return nil, fmt.Errorf("protocol error: received [DONE] before message_stop") - } - if err := r.acc.handle(e); err != nil { - return nil, err - } - r.terminal = true - - status := StreamStatusCompleted - if r.acc.stopReason == "tool_use" { - status = StreamStatusToolUse - } - - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: status, - Done: true, - Message: r.acc.message(), - Metadata: r.metadata(), - }, nil - } - - if !r.started { - if e.Type != EventTypeMessageStart { - return nil, fmt.Errorf("protocol error: first event must be message_start, got %q", e.Type) - } - r.started = true - } else { - if e.Type == EventTypeMessageStart { - return nil, fmt.Errorf("protocol error: duplicate message_start") - } - if r.stopped && e.Type != EventTypeMetadataUpdate { - return nil, fmt.Errorf("protocol error: event %q after message_stop", e.Type) - } - } - - if err := r.acc.handle(e); err != nil { - return nil, err - } - - if e.Type == EventTypeMessageStop { - r.stopped = true - } - - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: StreamStatusStreaming, - Done: false, - Message: r.acc.message(), - Metadata: r.metadata(), - }, nil -} - -func (r *reducer) abortSnapshot(reason string) *StreamSnapshot { - if r.terminal { - return nil - } - r.terminal = true - return &StreamSnapshot{ - ConversationID: r.conversationID, - TurnID: r.turnID, - Seq: r.lastSeq, - Status: StreamStatusAborted, - AbortReason: reason, - Done: true, - Message: r.acc.message(), - Metadata: r.metadata(), - } -} - -func (r *reducer) metadata() *StreamMetadata { - if r.acc.title == "" && r.acc.contextWindow == 0 && r.acc.inputTokens == 0 && r.acc.outputTokens == 0 { - return nil - } - return &StreamMetadata{ - Title: r.acc.title, - ContextWindow: r.acc.contextWindow, - InputTokens: r.acc.inputTokens, - OutputTokens: r.acc.outputTokens, - } -} diff --git a/internal/core/chat/reducer_properties_test.go b/internal/core/chat/reducer_properties_test.go deleted file mode 100644 index eb230fe0..00000000 --- a/internal/core/chat/reducer_properties_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package chat - -import "testing" - -func TestReducerProperty_SequenceMustBeStrictlyIncreasing(t *testing.T) { - t.Parallel() - - for a := 1; a <= 4; a++ { - for b := 1; b <= 4; b++ { - for c := 1; c <= 4; c++ { - seqs := []int{a, b, c} - r := newReducer("conv-1") - _, _ = r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 0, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - - errAt := -1 - for i, s := range seqs { - _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: s, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}) - if err != nil { - errAt = i - break - } - } - - hasNonIncreasing := b <= a || c <= b - if hasNonIncreasing && errAt == -1 { - t.Fatalf("expected error for non-increasing sequence %v", seqs) - } - if !hasNonIncreasing && errAt != -1 { - t.Fatalf("unexpected error for increasing sequence %v", seqs) - } - } - } - } -} - -func TestReducerProperty_TurnIDMustStayStable(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}); err != nil { - t.Fatalf("unexpected error on first turn event: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err != nil { - t.Fatalf("unexpected error for same turn: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-2", Seq: 3, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err == nil { - t.Fatal("expected turn mismatch error, got nil") - } -} - -func TestReducerProperty_NoEventsAfterTerminal(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}); err != nil { - t.Fatalf("unexpected error before done: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}); err != nil { - t.Fatalf("unexpected message_stop error: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 3, Done: true}); err != nil { - t.Fatalf("unexpected done error: %v", err) - } - if _, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 4, Type: EventTypeMetadataUpdate, Metadata: &metadata{}}); err == nil { - t.Fatal("expected error for event after terminal, got nil") - } -} diff --git a/internal/core/chat/reducer_test.go b/internal/core/chat/reducer_test.go deleted file mode 100644 index 9787893f..00000000 --- a/internal/core/chat/reducer_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package chat - -import ( - "strings" - "testing" -) - -func TestReducer(t *testing.T) { - t.Parallel() - - t.Run("requires message_start first", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, err := r.apply(event{Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("x")}}) - if err == nil || !strings.Contains(err.Error(), "first event must be message_start") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("builds completed snapshot with metadata", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - events := []event{ - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}, - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 2, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("hello")}}, - {ConversationID: "conv-1", TurnID: "turn-1", Seq: 3, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(3)}}, - } - for _, e := range events { - if _, err := r.apply(e); err != nil { - t.Fatalf("apply(%s) error = %v", e.Type, err) - } - } - - snap, err := r.apply(event{ConversationID: "conv-1", TurnID: "turn-1", Seq: 4, Done: true}) - if err != nil { - t.Fatalf("apply(done) error = %v", err) - } - if !snap.Done || snap.Status != StreamStatusCompleted { - t.Fatalf("unexpected final snapshot: %#v", snap) - } - if snap.Metadata == nil || snap.Metadata.ContextWindow != 200000 { - t.Fatalf("metadata = %#v", snap.Metadata) - } - }) - - t.Run("maps tool_use stop reason to tool_use status", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "tool_use", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - snap, err := r.apply(event{Seq: 3, Done: true}) - if err != nil { - t.Fatalf("apply(done) error = %v", err) - } - if snap.Status != StreamStatusToolUse { - t.Fatalf("status = %q", snap.Status) - } - }) - - t.Run("rejects content after message_stop", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - _, err := r.apply(event{Seq: 3, Type: EventTypeTextDelta, Text: &textContent{Content: strPtr("late")}}) - if err == nil || !strings.Contains(err.Error(), "after message_stop") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("allows metadata_update after message_stop before done", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - snap, err := r.apply(event{Seq: 3, Type: EventTypeMetadataUpdate, Metadata: &metadata{Title: "hello"}}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if snap == nil || snap.Metadata == nil || snap.Metadata.Title != "hello" { - t.Fatalf("expected metadata title on snapshot, got %#v", snap) - } - _, err = r.apply(event{Seq: 4, Done: true}) - if err != nil { - t.Fatalf("unexpected done error: %v", err) - } - }) - - t.Run("rejects done before message_stop", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, err := r.apply(event{Seq: 2, Done: true}) - if err == nil || !strings.Contains(err.Error(), "before message_stop") { - t.Fatalf("error = %v", err) - } - }) - - t.Run("rejects non-monotonic seq", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{Seq: 2, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - _, err := r.apply(event{Seq: 2, Type: EventTypeMessageStop, MessageStop: &messageStop{StopReason: "end_turn", InputTokens: intPtr(10), OutputTokens: intPtr(2)}}) - if err == nil { - t.Fatal("expected non-monotonic seq error") - } - }) - - t.Run("abortSnapshot emits terminal aborted status", func(t *testing.T) { - t.Parallel() - - r := newReducer("conv-1") - _, _ = r.apply(event{TurnID: "turn-1", Seq: 1, Type: EventTypeMessageStart, MessageStart: &messageStart{Model: "claude-3", ContextWindow: intPtr(200000)}}) - snap := r.abortSnapshot("user_cancelled") - if snap == nil || snap.Status != StreamStatusAborted || !snap.Done { - t.Fatalf("unexpected abort snapshot: %#v", snap) - } - if again := r.abortSnapshot("user_cancelled"); again != nil { - t.Fatal("second abortSnapshot should return nil") - } - }) -} diff --git a/internal/core/chat/session.go b/internal/core/chat/session.go deleted file mode 100644 index b717d692..00000000 --- a/internal/core/chat/session.go +++ /dev/null @@ -1,160 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// Session owns in-memory message history for one active chat loop. -// Persistence is handled separately by callers. -type Session struct { - conversationID domain.ConversationID - history []domain.Message -} - -// NewSession creates a session with an initial history snapshot. -func NewSession(conversationID domain.ConversationID, initial []domain.Message) *Session { - return &Session{ - conversationID: conversationID, - history: cloneMessages(initial), - } -} - -// Messages returns a defensive copy of the current history. -func (s *Session) Messages() []domain.Message { - return cloneMessages(s.history) -} - -// AppendMessage appends a message to history. -func (s *Session) AppendMessage(message domain.Message) { - if message.ID == "" { - return - } - s.history = append(s.history, cloneMessage(message)) -} - -// AppendUserTextMessage appends a user text message and returns it. -func (s *Session) AppendUserTextMessage(messageID domain.MessageID, text string) domain.Message { - msg := domain.Message{ - ID: messageID, - ConversationID: s.conversationID, - Role: domain.RoleUser, - Content: []domain.Block{{ - Index: 0, - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: text}, - }}, - } - s.history = append(s.history, msg) - return msg -} - -// AppendUserToolResultsMessage appends a user tool_result message and returns it. -func (s *Session) AppendUserToolResultsMessage(messageID domain.MessageID, results []domain.ToolResult) domain.Message { - msg := buildToolResultMessage(s.conversationID, messageID, results) - s.history = append(s.history, msg) - return msg -} - -// RecordAssistantMessage upserts an assistant message in history by message ID. -func (s *Session) RecordAssistantMessage(message domain.Message) { - if message.ID == "" { - return - } - message.Role = domain.RoleAssistant - for i := range s.history { - if s.history[i].ID == message.ID { - s.history[i] = cloneMessage(message) - return - } - } - s.history = append(s.history, cloneMessage(message)) -} - -// RemoveMessagesByID removes all messages whose IDs are in ids. -func (s *Session) RemoveMessagesByID(ids []domain.MessageID) { - if len(ids) == 0 || len(s.history) == 0 { - return - } - drop := make(map[domain.MessageID]struct{}, len(ids)) - for _, id := range ids { - drop[id] = struct{}{} - } - kept := s.history[:0] - for _, msg := range s.history { - if _, ok := drop[msg.ID]; ok { - continue - } - kept = append(kept, msg) - } - s.history = kept -} - -func buildToolResultMessage(conversationID domain.ConversationID, messageID domain.MessageID, results []domain.ToolResult) domain.Message { - blocks := make([]domain.Block, len(results)) - for i, result := range results { - r := result - blocks[i] = domain.Block{ - Index: i, - Type: domain.BlockTypeToolResult, - ToolResult: &r, - } - } - return domain.Message{ - ID: messageID, - ConversationID: conversationID, - Role: domain.RoleUser, - Content: blocks, - } -} - -func cloneMessages(in []domain.Message) []domain.Message { - out := make([]domain.Message, len(in)) - for i := range in { - out[i] = cloneMessage(in[i]) - } - return out -} - -func cloneMessage(in domain.Message) domain.Message { - out := in - if in.Content != nil { - out.Content = make([]domain.Block, len(in.Content)) - for i := range in.Content { - out.Content[i] = cloneBlock(in.Content[i]) - } - } - return out -} - -func cloneBlock(in domain.Block) domain.Block { - out := in - if in.Text != nil { - v := *in.Text - out.Text = &v - } - if in.Thinking != nil { - v := *in.Thinking - out.Thinking = &v - } - if in.ToolUse != nil { - v := *in.ToolUse - if in.ToolUse.Input != nil { - v.Input = append([]byte(nil), in.ToolUse.Input...) - } - out.ToolUse = &v - } - if in.ToolResult != nil { - v := *in.ToolResult - if in.ToolResult.Content != nil { - v.Content = cloneAnyMap(in.ToolResult.Content) - } - out.ToolResult = &v - } - return out -} - -func cloneAnyMap(in map[string]any) map[string]any { - out := make(map[string]any, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/internal/core/chat/session_test.go b/internal/core/chat/session_test.go deleted file mode 100644 index 2eb7b851..00000000 --- a/internal/core/chat/session_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package chat - -import ( - "testing" - - "github.com/usetero/cli/internal/domain" -) - -func TestSession_RecordAssistantMessageUpsert(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{{ - ID: "asst-1", - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "old"}, - }}, - }}) - - s.RecordAssistantMessage(domain.Message{ - ID: "asst-1", - Role: domain.RoleAssistant, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "new"}, - }}, - }) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - if got := msgs[0].Content[0].Text.Content; got != "new" { - t.Fatalf("assistant content = %q, want %q", got, "new") - } -} - -func TestSession_AppendUserToolResultsMessage(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", nil) - s.AppendUserToolResultsMessage("tool-result-1", []domain.ToolResult{ - { - ToolUseID: "tool-a", - Content: map[string]any{"rows": []any{1, 2}}, - }, - { - ToolUseID: "tool-b", - IsError: true, - Error: "failed", - }, - }) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - msg := msgs[0] - if msg.Role != domain.RoleUser { - t.Fatalf("role = %q, want %q", msg.Role, domain.RoleUser) - } - if len(msg.Content) != 2 { - t.Fatalf("len(content) = %d, want 2", len(msg.Content)) - } - if msg.Content[0].Type != domain.BlockTypeToolResult || msg.Content[0].ToolResult.ToolUseID != "tool-a" { - t.Fatalf("unexpected first block: %#v", msg.Content[0]) - } - if msg.Content[1].Type != domain.BlockTypeToolResult || msg.Content[1].ToolResult.ToolUseID != "tool-b" { - t.Fatalf("unexpected second block: %#v", msg.Content[1]) - } -} - -func TestSession_AppendUserTextMessage(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", nil) - s.AppendUserTextMessage("msg-1", "hello") - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - msg := msgs[0] - if msg.Role != domain.RoleUser { - t.Fatalf("role = %q, want %q", msg.Role, domain.RoleUser) - } - if len(msg.Content) != 1 || msg.Content[0].Type != domain.BlockTypeText || msg.Content[0].Text == nil { - t.Fatalf("unexpected content: %#v", msg.Content) - } - if msg.Content[0].Text.Content != "hello" { - t.Fatalf("text = %q, want %q", msg.Content[0].Text.Content, "hello") - } -} - -func TestSession_MessagesReturnsDefensiveCopy(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{{ - ID: "msg-1", - Role: domain.RoleUser, - Content: []domain.Block{{ - Type: domain.BlockTypeText, - Text: &domain.TextBlock{Content: "hello"}, - }}, - }}) - - got := s.Messages() - got[0].Content[0].Text.Content = "mutated" - - again := s.Messages() - if again[0].Content[0].Text.Content != "hello" { - t.Fatalf("session history was mutated through returned slice") - } -} - -func TestSession_RemoveMessagesByID(t *testing.T) { - t.Parallel() - - s := NewSession("conv-1", []domain.Message{ - {ID: "a", Role: domain.RoleUser}, - {ID: "b", Role: domain.RoleAssistant}, - {ID: "c", Role: domain.RoleUser}, - }) - - s.RemoveMessagesByID([]domain.MessageID{"b", "c"}) - - msgs := s.Messages() - if len(msgs) != 1 { - t.Fatalf("len(messages) = %d, want 1", len(msgs)) - } - if msgs[0].ID != "a" { - t.Fatalf("remaining id = %q, want %q", msgs[0].ID, "a") - } -} diff --git a/internal/core/chat/stream_decode.go b/internal/core/chat/stream_decode.go deleted file mode 100644 index 5013f84f..00000000 --- a/internal/core/chat/stream_decode.go +++ /dev/null @@ -1,82 +0,0 @@ -package chat - -import ( - "bytes" - "encoding/json" - "fmt" - "io" -) - -func decodeEventData(data []byte) (event, error) { - var header struct { - ChatStreamVersion string `json:"chat_stream_version"` - Type EventType `json:"type"` - } - if err := json.Unmarshal(data, &header); err != nil { - return event{}, fmt.Errorf("parse event: %w", err) - } - if header.ChatStreamVersion != chatStreamVersionV2 { - return event{}, fmt.Errorf("protocol error: unsupported chat_stream_version %q", header.ChatStreamVersion) - } - if msg, ok := parseErrorMessage(data); ok { - return event{}, fmt.Errorf("server error: %s", msg) - } - - var e event - if err := strictUnmarshal(data, &e); err != nil { - return event{}, fmt.Errorf("parse event: %w", err) - } - if e.Type == "" { - return event{}, fmt.Errorf("protocol error: missing event type") - } - - switch e.Type { - case EventTypeMessageStart: - if e.MessageStart == nil || e.MessageStart.Model == "" || e.MessageStart.ContextWindow == nil { - return event{}, fmt.Errorf("protocol error: message_start missing required fields") - } - case EventTypeTextDelta: - if e.Text == nil || e.Text.Content == nil { - return event{}, fmt.Errorf("protocol error: text_delta missing content") - } - case EventTypeThinkingDelta: - if e.Thinking == nil || e.Thinking.Content == nil { - return event{}, fmt.Errorf("protocol error: thinking_delta missing content") - } - case EventTypeToolUse: - if e.ToolUse == nil || e.ToolUse.ID == "" || e.ToolUse.Name == "" { - return event{}, fmt.Errorf("protocol error: tool_use missing id/name") - } - case EventTypeToolInputDelta: - if e.ToolUseID == "" { - return event{}, fmt.Errorf("protocol error: tool_input_delta missing tool_use_id") - } - case EventTypeContentBlockStop: - // tool_use_id is optional and required only for tool blocks. - case EventTypeMessageStop: - if e.MessageStop == nil || e.MessageStop.StopReason == "" || e.MessageStop.InputTokens == nil || e.MessageStop.OutputTokens == nil { - return event{}, fmt.Errorf("protocol error: message_stop missing required fields") - } - case EventTypeMetadataUpdate: - if e.Metadata == nil || e.Metadata.Title == "" { - return event{}, fmt.Errorf("protocol error: metadata_update missing title") - } - default: - return event{}, fmt.Errorf("protocol error: unknown event type %q", e.Type) - } - - return e, nil -} - -func strictUnmarshal(data []byte, out any) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(out); err != nil { - return err - } - var trailing struct{} - if err := dec.Decode(&trailing); err != io.EOF { - return fmt.Errorf("unexpected trailing JSON data") - } - return nil -} diff --git a/internal/core/chat/stream_machine.go b/internal/core/chat/stream_machine.go deleted file mode 100644 index 38663229..00000000 --- a/internal/core/chat/stream_machine.go +++ /dev/null @@ -1,68 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -type StreamStatus string - -const ( - StreamStatusStreaming StreamStatus = "streaming" - StreamStatusCompleted StreamStatus = "completed" - StreamStatusToolUse StreamStatus = "tool_use" - StreamStatusAborted StreamStatus = "aborted" -) - -// StreamMetadata contains post-stream metadata from the Chat API. -type StreamMetadata struct { - Title string - ContextWindow int - InputTokens int - OutputTokens int -} - -// StreamSnapshot is a deterministic progress snapshot for one stream turn. -type StreamSnapshot struct { - ConversationID string - TurnID string - Seq int - Status StreamStatus - AbortReason string - Done bool - Message *domain.Message - Metadata *StreamMetadata -} - -// StreamMachine consumes SSE data payloads and emits typed stream snapshots. -type StreamMachine struct { - red *reducer -} - -func NewStreamMachine(conversationID string) *StreamMachine { - return &StreamMachine{red: newReducer(conversationID)} -} - -// ConsumeData parses and applies one non-[DONE] SSE data payload. -func (m *StreamMachine) ConsumeData(data []byte) (*StreamSnapshot, error) { - e, err := decodeEventData(data) - if err != nil { - return nil, err - } - return m.red.apply(e) -} - -// ConsumeDone applies terminal [DONE]. -func (m *StreamMachine) ConsumeDone() (*StreamSnapshot, error) { - return m.red.apply(event{Done: true}) -} - -// Abort emits a terminal aborted snapshot. -func (m *StreamMachine) Abort(reason string) *StreamSnapshot { - return m.red.abortSnapshot(reason) -} - -func (m *StreamMachine) Message() *domain.Message { - return m.red.acc.message() -} - -func (m *StreamMachine) Metadata() *StreamMetadata { - return m.red.metadata() -} diff --git a/internal/core/chat/stream_result.go b/internal/core/chat/stream_result.go deleted file mode 100644 index 2f660787..00000000 --- a/internal/core/chat/stream_result.go +++ /dev/null @@ -1,9 +0,0 @@ -package chat - -import "github.com/usetero/cli/internal/domain" - -// StreamResult captures the final stream outputs for a turn. -type StreamResult struct { - Message *domain.Message - Metadata *StreamMetadata // nil if no metadata_update event was received -} diff --git a/internal/core/chat/test_helpers_test.go b/internal/core/chat/test_helpers_test.go deleted file mode 100644 index 96b08708..00000000 --- a/internal/core/chat/test_helpers_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package chat - -func intPtr(v int) *int { return &v } - -func strPtr(v string) *string { return &v } From c55f142cde31e71160e6deee147523cf86dcd238 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 13:05:23 -0700 Subject: [PATCH 16/20] feat: add -o json output to the surface commands Add a persistent --output/-o flag (table default, json) on the root command, inherited by every subcommand. A shared emit() helper routes each command's result: --output=json writes indented JSON, otherwise the table renderer runs. Each command marshals a stable, snake_case output struct (raw numbers, omitempty for unmeasured costs) rather than internal types, so the JSON is clean and scriptable. Covers status, issues, checks, services, and edge. Verified live against prd in both formats. --- internal/cmd/root.go | 1 + internal/cmd/surfaces.go | 212 ++++++++++++++++++++++++++++----------- 2 files changed, 156 insertions(+), 57 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 853e3a4d..4fe37122 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -91,6 +91,7 @@ Just run 'tero' to start an interactive chat session.`, // Global flags with defaults from CLI config rootCmd.PersistentFlags().String("endpoint", cliConfig.APIEndpoint, "Tero control plane endpoint") rootCmd.PersistentFlags().BoolP("debug", "d", cliConfig.Debug, "Enable debug logging") + rootCmd.PersistentFlags().StringP("output", "o", "table", "Output format: table or json") return rootCmd } diff --git a/internal/cmd/surfaces.go b/internal/cmd/surfaces.go index 393e0085..5fe2b697 100644 --- a/internal/cmd/surfaces.go +++ b/internal/cmd/surfaces.go @@ -2,9 +2,12 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" + "strings" "text/tabwriter" + "time" "github.com/spf13/cobra" @@ -16,6 +19,18 @@ import ( "github.com/usetero/cli/internal/styles" ) +// emit renders a command's result. When --output=json it writes data as +// indented JSON; otherwise it runs the table renderer. Every surface command +// routes through this so JSON support is uniform and free per command. +func emit(cmd *cobra.Command, data any, table func() error) error { + if format, _ := cmd.Flags().GetString("output"); strings.EqualFold(format, "json") { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + return table() +} + // accountServices resolves an authenticated, account-scoped service set from // the active org's default account. Returns a helpful error when the user is // not authenticated or has not finished onboarding. @@ -60,6 +75,14 @@ func rate(p *float64) string { return format.Volume(*p) + "/hr" } +type issueOut struct { + ID string `json:"id"` + DisplayID string `json:"display_id"` + Priority string `json:"priority"` + Service string `json:"service,omitempty"` + Title string `json:"title"` +} + // NewIssuesCmd lists the account's active issues. func NewIssuesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("issues") @@ -75,22 +98,35 @@ func NewIssuesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { if err != nil { return fmt.Errorf("list issues: %w", err) } - s := styles.DetectTheme().Styles - if len(issues) == 0 { - fmt.Println(s.Help.Render("No active issues.")) - return nil + out := make([]issueOut, len(issues)) + for i, is := range issues { + out[i] = issueOut{ID: is.ID, DisplayID: is.DisplayID, Priority: string(is.Priority), Service: is.ServiceName, Title: is.Title} } - w := newTabWriter() - fmt.Fprintln(w, "PRIORITY\tID\tSERVICE\tCOST/YR\tTITLE") - for _, i := range issues { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - i.Priority, dashIfEmpty(i.DisplayID), dashIfEmpty(i.ServiceName), cost(i.CostPerHour), i.Title) - } - return w.Flush() + return emit(cmd, out, func() error { + if len(issues) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No active issues.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "PRIORITY\tID\tSERVICE\tTITLE") + for _, i := range issues { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + i.Priority, dashIfEmpty(i.DisplayID), dashIfEmpty(i.ServiceName), i.Title) + } + return w.Flush() + }) }, } } +type serviceOut struct { + Name string `json:"name"` + Health string `json:"health"` + LogEvents int64 `json:"log_events"` + EventsPerHour *float64 `json:"events_per_hour,omitempty"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` +} + // NewServicesCmd lists enabled services and their status. func NewServicesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("services") @@ -106,22 +142,38 @@ func NewServicesCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command if err != nil { return fmt.Errorf("list services: %w", err) } - s := styles.DetectTheme().Styles - if len(statuses) == 0 { - fmt.Println(s.Help.Render("No enabled services.")) - return nil - } - w := newTabWriter() - fmt.Fprintln(w, "SERVICE\tHEALTH\tLOG EVENTS\tVOLUME\tCOST/YR") - for _, svc := range statuses { - fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", - svc.Name, svc.Health, svc.LogEventCount, rate(svc.ServiceVolumePerHour), cost(svc.ServiceCostPerHourVolumeUSD)) + out := make([]serviceOut, len(statuses)) + for i, svc := range statuses { + out[i] = serviceOut{ + Name: svc.Name, Health: string(svc.Health), LogEvents: svc.LogEventCount, + EventsPerHour: svc.ServiceVolumePerHour, CostPerHourUSD: svc.ServiceCostPerHourVolumeUSD, + } } - return w.Flush() + return emit(cmd, out, func() error { + if len(statuses) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No enabled services.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tHEALTH\tLOG EVENTS\tVOLUME\tCOST/YR") + for _, svc := range statuses { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", + svc.Name, svc.Health, svc.LogEventCount, rate(svc.ServiceVolumePerHour), cost(svc.ServiceCostPerHourVolumeUSD)) + } + return w.Flush() + }) }, } } +type checkOut struct { + Name string `json:"name"` + Domain string `json:"domain"` + OpenFindings int64 `json:"open_findings"` + ActiveIssues int64 `json:"active_issues"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` +} + // NewChecksCmd lists product checks and their posture. func NewChecksCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("checks") @@ -137,22 +189,45 @@ func NewChecksCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { if err != nil { return fmt.Errorf("list checks: %w", err) } - s := styles.DetectTheme().Styles - if len(catalog.Checks) == 0 { - fmt.Println(s.Help.Render("No checks.")) - return nil - } - w := newTabWriter() - fmt.Fprintln(w, "CHECK\tDOMAIN\tOPEN FINDINGS\tACTIVE ISSUES\tCOST/YR") - for _, c := range catalog.Checks { - fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", - c.Name, c.Domain, c.OpenFindingCount, c.ActiveIssueCount, cost(c.CurrentCostPerHour)) + out := make([]checkOut, len(catalog.Checks)) + for i, c := range catalog.Checks { + out[i] = checkOut{ + Name: c.Name, Domain: string(c.Domain), OpenFindings: c.OpenFindingCount, + ActiveIssues: c.ActiveIssueCount, CostPerHourUSD: c.CurrentCostPerHour, + } } - return w.Flush() + return emit(cmd, out, func() error { + if len(catalog.Checks) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No checks.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "CHECK\tDOMAIN\tOPEN FINDINGS\tACTIVE ISSUES\tCOST/YR") + for _, c := range catalog.Checks { + fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", + c.Name, c.Domain, c.OpenFindingCount, c.ActiveIssueCount, cost(c.CurrentCostPerHour)) + } + return w.Flush() + }) }, } } +type statusOut struct { + Health string `json:"health"` + ReadyForUse bool `json:"ready_for_use"` + ActiveServices int64 `json:"active_services"` + TotalServices int64 `json:"total_services"` + LogEvents int64 `json:"log_events"` + AnalyzedEvents int64 `json:"analyzed_events"` + EventsPerHour *float64 `json:"events_per_hour,omitempty"` + CostPerHourUSD *float64 `json:"cost_per_hour_usd,omitempty"` + OpenIssues int64 `json:"open_issues"` + HighIssues int64 `json:"high_issues"` + MediumIssues int64 `json:"medium_issues"` + LowIssues int64 `json:"low_issues"` +} + // NewStatusCmd prints the account-level status summary. func NewStatusCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("status") @@ -172,23 +247,38 @@ func NewStatusCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { if err != nil { return fmt.Errorf("issue summary: %w", err) } - theme := styles.DetectTheme() - s := theme.Styles - fmt.Println(s.Title.Render("Account Status")) - w := newTabWriter() - fmt.Fprintf(w, "Health\t%s\n", summary.Health) - fmt.Fprintf(w, "Ready for use\t%t\n", summary.ReadyForUse) - fmt.Fprintf(w, "Services\t%d active / %d total\n", summary.ActiveServices, summary.ServiceCount) - fmt.Fprintf(w, "Log events\t%d (%d analyzed)\n", summary.EventCount, summary.AnalyzedCount) - fmt.Fprintf(w, "Volume\t%s\n", rate(summary.TotalVolumePerHour)) - fmt.Fprintf(w, "Cost\t%s\n", cost(summary.TotalCostPerHour)) - fmt.Fprintf(w, "Open issues\t%d (%d high, %d medium, %d low)\n", - issues.Open, issues.HighCount, issues.MediumCount, issues.LowCount) - return w.Flush() + out := statusOut{ + Health: string(summary.Health), ReadyForUse: summary.ReadyForUse, + ActiveServices: summary.ActiveServices, TotalServices: summary.ServiceCount, + LogEvents: summary.EventCount, AnalyzedEvents: summary.AnalyzedCount, + EventsPerHour: summary.TotalVolumePerHour, CostPerHourUSD: summary.TotalCostPerHour, + OpenIssues: issues.Open, HighIssues: issues.HighCount, MediumIssues: issues.MediumCount, LowIssues: issues.LowCount, + } + return emit(cmd, out, func() error { + s := styles.DetectTheme().Styles + fmt.Println(s.Title.Render("Account Status")) + w := newTabWriter() + fmt.Fprintf(w, "Health\t%s\n", summary.Health) + fmt.Fprintf(w, "Ready for use\t%t\n", summary.ReadyForUse) + fmt.Fprintf(w, "Services\t%d active / %d total\n", summary.ActiveServices, summary.ServiceCount) + fmt.Fprintf(w, "Log events\t%d (%d analyzed)\n", summary.EventCount, summary.AnalyzedCount) + fmt.Fprintf(w, "Volume\t%s\n", rate(summary.TotalVolumePerHour)) + fmt.Fprintf(w, "Cost\t%s\n", cost(summary.TotalCostPerHour)) + fmt.Fprintf(w, "Open issues\t%d (%d high, %d medium, %d low)\n", + issues.Open, issues.HighCount, issues.MediumCount, issues.LowCount) + return w.Flush() + }) }, } } +type edgeOut struct { + Service string `json:"service"` + Namespace string `json:"namespace,omitempty"` + InstanceID string `json:"instance_id"` + LastSyncAt string `json:"last_sync_at"` +} + // NewEdgeCmd lists the account's edge instances. func NewEdgeCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { scope = scope.Child("edge") @@ -204,18 +294,26 @@ func NewEdgeCmd(scope log.Scope, cliConfig *config.CLIConfig) *cobra.Command { if err != nil { return fmt.Errorf("list edge instances: %w", err) } - s := styles.DetectTheme().Styles - if len(fleet.Instances) == 0 { - fmt.Println(s.Help.Render("No edge instances registered.")) - return nil - } - w := newTabWriter() - fmt.Fprintln(w, "SERVICE\tNAMESPACE\tINSTANCE\tLAST SYNC") - for _, inst := range fleet.Instances { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - inst.ServiceName, dashIfEmpty(inst.ServiceNamespace), inst.InstanceID, inst.LastSyncAt.Format("2006-01-02 15:04")) + out := make([]edgeOut, len(fleet.Instances)) + for i, inst := range fleet.Instances { + out[i] = edgeOut{ + Service: inst.ServiceName, Namespace: inst.ServiceNamespace, + InstanceID: inst.InstanceID, LastSyncAt: inst.LastSyncAt.Format(time.RFC3339), + } } - return w.Flush() + return emit(cmd, out, func() error { + if len(fleet.Instances) == 0 { + fmt.Println(styles.DetectTheme().Styles.Help.Render("No edge instances registered.")) + return nil + } + w := newTabWriter() + fmt.Fprintln(w, "SERVICE\tNAMESPACE\tINSTANCE\tLAST SYNC") + for _, inst := range fleet.Instances { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + inst.ServiceName, dashIfEmpty(inst.ServiceNamespace), inst.InstanceID, inst.LastSyncAt.Format("2006-01-02 15:04")) + } + return w.Flush() + }) }, } } From ab65ad9586c25ffec3af86aae5d3ba5540c1e271 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 14:24:13 -0700 Subject: [PATCH 17/20] ci: fix checks after PowerSync/SQLite removal - gen-check: drop the deleted `go generate ./internal/sqlite` step (the package no longer exists) - powersync-replay: remove the job, its workflow input, the gate dependency, and the nightly invocation (the replay test is gone) - lint: fix SA4000 (snapshotKey determinism check uses two vars), remove the now-unused chat boundary assertion helpers, and delete the unused ptr/deref helpers - unit: add ripgrep to hermit so the event/naming lint scripts have `rg` in CI (they silently found nothing without it) task lint is clean (0 issues) and the full suite passes. --- .github/workflows/_go-checks.yaml | 30 ----- .github/workflows/nightly.yaml | 2 - bin/.ripgrep-15.1.0.pkg | 1 + bin/rg | 1 + .../app/statusbar/surfaces/surfaces_test.go | 4 +- internal/architecture/dependencies_test.go | 106 ------------------ internal/boundary/graphql/ptr.go | 16 --- 7 files changed, 5 insertions(+), 155 deletions(-) create mode 120000 bin/.ripgrep-15.1.0.pkg create mode 120000 bin/rg delete mode 100644 internal/boundary/graphql/ptr.go diff --git a/.github/workflows/_go-checks.yaml b/.github/workflows/_go-checks.yaml index e532afda..bf36f251 100644 --- a/.github/workflows/_go-checks.yaml +++ b/.github/workflows/_go-checks.yaml @@ -8,11 +8,6 @@ on: required: false default: ubuntu-latest type: string - run-powersync-replay: - description: Run PowerSync replay correctness check - required: false - default: true - type: boolean jobs: lint: @@ -83,9 +78,6 @@ jobs: # Regenerate API client from committed schema snapshot. (cd internal/boundary/graphql/gen && go run github.com/Khan/genqlient) - # Regenerate SQLite reflection/sqlc artifacts from embedded schema snapshot. - go generate ./internal/sqlite - if [ -n "$(git status --porcelain)" ]; then echo "Generated artifacts are out of date. Run generation locally and commit results." git status --short @@ -93,27 +85,6 @@ jobs: exit 1 fi - powersync-replay: - runs-on: ${{ inputs.runner }} - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup Hermit - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - with: - cache: "true" - - - name: Run PowerSync replay correctness test - if: ${{ inputs.run-powersync-replay }} - run: task test:correctness:powersync-replay - - - name: Skip PowerSync replay correctness test - if: ${{ !inputs.run-powersync-replay }} - run: echo "PowerSync replay correctness test disabled for this workflow call" - tagged-compile: runs-on: ${{ inputs.runner }} timeout-minutes: 15 @@ -137,7 +108,6 @@ jobs: - unit - integration - gen-check - - powersync-replay - tagged-compile runs-on: ubuntu-latest timeout-minutes: 1 diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 1b3cd34d..b783afa8 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -12,8 +12,6 @@ concurrency: jobs: checks: uses: ./.github/workflows/_go-checks.yaml - with: - run-powersync-replay: true secrets: inherit integration-live: diff --git a/bin/.ripgrep-15.1.0.pkg b/bin/.ripgrep-15.1.0.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.ripgrep-15.1.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/rg b/bin/rg new file mode 120000 index 00000000..fa2775bd --- /dev/null +++ b/bin/rg @@ -0,0 +1 @@ +.ripgrep-15.1.0.pkg \ No newline at end of file diff --git a/internal/app/statusbar/surfaces/surfaces_test.go b/internal/app/statusbar/surfaces/surfaces_test.go index ca07291f..3b538c99 100644 --- a/internal/app/statusbar/surfaces/surfaces_test.go +++ b/internal/app/statusbar/surfaces/surfaces_test.go @@ -101,7 +101,9 @@ func TestSnapshotKeyDetectsContentChange(t *testing.T) { Rows: [][]string{{"Critical", "1", "needs review"}}, } - if snapshotKey(base) != snapshotKey(base) { + first := snapshotKey(base) + second := snapshotKey(base) + if first != second { t.Fatal("snapshotKey must be stable for identical snapshots") } diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go index 730be04b..4f10286e 100644 --- a/internal/architecture/dependencies_test.go +++ b/internal/architecture/dependencies_test.go @@ -80,109 +80,3 @@ func assertNoForbiddenImports(t *testing.T, dir string, forbiddenPrefixes []stri t.Fatalf("walk %s: %v", dir, err) } } - -func assertNoForbiddenImportsExcept(t *testing.T, dir string, forbiddenPrefixes []string, allowedRelPaths []string) { - t.Helper() - - root, err := findModuleRoot(dir) - if err != nil { - t.Fatalf("find module root: %v", err) - } - - fs := token.NewFileSet() - err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - - f, err := parser.ParseFile(fs, path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imp := range f.Imports { - p := strings.Trim(imp.Path.Value, `"`) - for _, prefix := range forbiddenPrefixes { - if !strings.HasPrefix(p, prefix) { - continue - } - allowed := false - for _, allow := range allowedRelPaths { - if rel == allow || strings.HasPrefix(rel, allow+string(filepath.Separator)) { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s imports forbidden package %s", path, p) - } - } - } - return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", dir, err) - } -} - -func assertOnlyAllowedChatBoundaryImports(t *testing.T, dir string, allowedRelPaths []string) { - t.Helper() - - root, err := findModuleRoot(dir) - if err != nil { - t.Fatalf("find module root: %v", err) - } - - fs := token.NewFileSet() - err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - - f, err := parser.ParseFile(fs, path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imp := range f.Imports { - p := strings.Trim(imp.Path.Value, `"`) - if !strings.HasPrefix(p, "github.com/usetero/cli/internal/boundary/chat") { - continue - } - allowed := false - for _, allow := range allowedRelPaths { - if rel == allow || strings.HasPrefix(rel, allow+string(filepath.Separator)) { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s imports boundary/chat outside allowed boundary", path) - } - } - return nil - }) - if err != nil { - t.Fatalf("walk %s: %v", dir, err) - } -} diff --git a/internal/boundary/graphql/ptr.go b/internal/boundary/graphql/ptr.go deleted file mode 100644 index 87edd517..00000000 --- a/internal/boundary/graphql/ptr.go +++ /dev/null @@ -1,16 +0,0 @@ -package graphql - -// ptr returns a pointer to the given value. -// Useful for setting optional fields in generated GraphQL inputs. -func ptr[T any](v T) *T { - return &v -} - -// deref safely dereferences a pointer, returning the zero value if nil. -func deref[T any](p *T) T { - if p == nil { - var zero T - return zero - } - return *p -} From f98a997fe79e2a4f6e0fd19066ee96974766ad26 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 14:51:05 -0700 Subject: [PATCH 18/20] ci: fix Workflow Lint and Security scans (pre-existing infra) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These failures are unrelated to the PowerSync removal; they are dependency/toolchain hygiene that fails any current PR. - Workflow Lint: re-pin reviewdog/action-actionlint to a resolvable v1 SHA (the old pinned commit no longer exists upstream). - Security/govulncheck: bump the go directive to 1.25.11 so the security job (setup-go from go.mod) builds against a patched standard library — the listed CVEs (net/textproto, crypto/x509, net, net/http, crypto/tls) are fixed in the 1.25.9-1.25.11 patches; the 1.26 line is not patched yet. Hermit jobs keep using go 1.26.0 (>= 1.25.11). - Security/govulncheck + OSV: bump golang.org/x/net to v0.56.0 and golang.org/x/sys to v0.46.0 to clear GO-2026-4918 and GO-2026-5024-5030. govulncheck now reports only standard-library findings (cleared in 1.25.11), osv-scanner exits clean, and build/vet/test are green. --- .github/workflows/workflow-lint.yaml | 2 +- go.mod | 15 +++++++-------- go.sum | 28 ++++++++++++---------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/.github/workflows/workflow-lint.yaml b/.github/workflows/workflow-lint.yaml index a9b3ae8b..0405a2dc 100644 --- a/.github/workflows/workflow-lint.yaml +++ b/.github/workflows/workflow-lint.yaml @@ -20,6 +20,6 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run actionlint - uses: reviewdog/action-actionlint@f45e6423f07e6ea6fce879bbdb0d74407c1fbf55 # v1 + uses: reviewdog/action-actionlint@e0207a28405ecad11953ba625a95d92f7889572a # v1 with: fail_level: any diff --git a/go.mod b/go.mod index d7c50a92..67e03496 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/usetero/cli -go 1.25.0 +go 1.25.11 require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 @@ -14,14 +14,13 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/lucasb-eyer/go-colorful v1.3.0 - github.com/mattn/go-sqlite3 v1.14.34 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.10.2 github.com/workos/workos-go/v6 v6.4.0 github.com/zalando/go-keyring v0.2.6 - golang.org/x/mod v0.33.0 + golang.org/x/mod v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -57,11 +56,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 137a3d09..e1c57e7a 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiI charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971/go.mod h1:i61Y3FmdbcBNSKa+pKB3DaE4uVQmBLMs/xlvRyHcXAE= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -112,8 +110,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -164,19 +160,19 @@ github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH8 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 5b13008d3f86101d002b23c0ccd3e31c3ff4db32 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 14:58:19 -0700 Subject: [PATCH 19/20] =?UTF-8?q?docs:=20rewrite=20README=20for=20the=20cu?= =?UTF-8?q?rrent=20CLI,=20organized=20by=20Di=C3=A1taxis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README described the old chat-first interface, which no longer exists. Rewrite it to reflect what the CLI actually does today — connect Datadog and read your account's issues, checks, services, and status — and drop the removed chat 'block waste / edit code' flows. Organize by Diátaxis: Getting started (tutorial), How-to guides, Reference (commands, flags, UI keys, env vars, files), and Concepts. Command/flag reference verified against the built binary. --- README.md | 334 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 234 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index faf71788..e4765157 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ # Tero CLI -Improve your observability data quality from the terminal. +The terminal interface to the [Tero](https://usetero.com) control plane. Connect a +Datadog account and explore the issues, waste, and posture Tero finds in your +observability data — interactively in your terminal, or as JSON for scripting. *Built by the creators of [Vector.dev](https://vector.dev).* -## What is this? +Tero analyzes what your logs mean semantically — patterns, quality, and value — +and surfaces the waste that doesn't help you during incidents (typically 40%+ of +log volume). The CLI is read-only: it connects to your existing observability +platform over an API and shows you what Tero found. It deploys no agents, +collectors, or pipelines. -Tero helps you find and fix waste in your observability data. - -Connect your Datadog account (read-only) and Tero will: -- Understand what your logs mean semantically - patterns, quality, value -- Identify waste - typically 40%+ of volume that doesn't help during incidents -- Help you remove it with informed actions - only what won't hurt you - -**If someone sent you here:** Your team lead or SRE found waste in one of your services. The CLI will show you exactly what patterns are wasteful and why, then help you fix them. Takes 10 minutes. - -**If you're evaluating Tero:** This is how you interact with the platform. Install it, connect your Datadog account, see what we find. Takes 5 minutes. - -## Quick Start +--- -**Install:** +## Install ```bash # Quick install (macOS and Linux) @@ -36,154 +31,293 @@ scoop install tero docker pull usetero/tero ``` -**Run:** +Verify the install: ```bash -tero +tero --version ``` -On first run, `tero` will: -1. Ask you to authenticate (or create an account) -2. Walk you through connecting your Datadog account (read-only API key) -3. Analyze your data and show you what it found +--- + +The rest of this README follows the [Diátaxis](https://diataxis.fr) structure: -After that, just run `tero` anytime to explore waste, check status, or take action. +- **[Getting started](#getting-started)** — a guided first run (start here). +- **[How-to guides](#how-to-guides)** — recipes for specific tasks. +- **[Reference](#reference)** — commands, flags, keys, and configuration. +- **[Concepts](#concepts)** — how Tero works and why. -## What does it do? +--- -`tero` is an interactive chat interface. Ask questions, get answers about your observability data. +## Getting started -The CLI doesn't just identify waste—it teaches you what makes observability data valuable. Each recommendation explains why something is or isn't useful during incidents, helping your team get better at instrumentation over time. +A first run takes about five minutes. You'll authenticate, connect a Datadog +account, and land in the issue explorer. -**Common workflows:** +### 1. Launch the app +```bash +tero ``` -"How much waste do I have?" -→ Shows total waste across your account, broken down by service -"What's wrong with checkout-api?" -→ Shows specific waste patterns in that service -→ Explains what each pattern is and why it's waste -→ Lets you take action +`tero` with no arguments opens the interactive terminal UI and walks you through +onboarding. -"Show me the database_connection_debug logs" -→ Displays actual log samples -→ Explains the pattern and cost impact +### 2. Authenticate -"Block those logs" -→ Creates exclusion rule in Datadog -→ Confirms savings -``` +On first run you'll be prompted to log in (or create an account). This opens a +browser-based device login. Once you're authenticated, the CLI remembers you — +you won't be asked again until you log out. -**Example session:** +### 3. Connect a Datadog account +If the selected account has no Datadog connection yet, onboarding walks you +through it: + +1. **Pick your Datadog region** — US1, US3, US5, EU1, AP1, or US1-FED. +2. **Enter your Datadog API key** — *Datadog → Organization Settings → API Keys.* +3. **Enter your Datadog Application key** — *Datadog → Organization Settings → + Application Keys.* + +Tero validates the keys, registers the account, and begins analyzing your data. +Access is **read-only**: Tero only reads telemetry metadata. + +### 4. Explore your issues + +After onboarding you land in the **issue explorer** — a read-only list of the +active issues Tero found, highest priority first. Use: + +- `↑` / `↓` (or `k` / `j`) to move through issues +- `r` to refresh +- `ctrl+d` to open the status drawer (Issues, Checks, Services, Log events, Edge) +- `/` to open the command palette (refresh, switch org/account, theme, quit) +- `ctrl+c` to quit + +### 5. Or just ask the CLI directly + +You don't need the UI to see your account. The same data is available as plain +commands: + +```bash +tero status # account health, services, log events, cost, open issues +tero issues # active issues +tero services # enabled services +tero checks # product checks and posture ``` -$ tero -Welcome back, Ben. +That's it. Run `tero` anytime to explore, or use the commands above in scripts. -I analyzed your Datadog account. Found $89K/year in waste across 12 services. +--- -Services with the most waste: - 1. checkout-api: $50K/year - 2. user-auth: $24K/year - 3. payment-processor: $15K/year +## How-to guides -Want to start with checkout-api? +### Authenticate, or check who you are -> yes +```bash +tero auth login # browser device login +tero auth status # show the current user and org +tero auth logout # clear stored credentials +``` -Found 24 waste patterns in checkout-api. Here are the top 3: +### Connect (or reconnect) a Datadog account -┌────────────────────────────┬──────────┬──────────────┐ -│ Event │ Volume │ Cost Impact │ -├────────────────────────────┼──────────┼──────────────┤ -│ middleware_execution_trace │ 2M/hr │ $26K/year │ -│ database_query_start │ 800K/hr │ $11K/year │ -│ cache_operation_success │ 1M/hr │ $13K/year │ -└────────────────────────────┴──────────┴──────────────┘ +Connecting Datadog happens interactively inside `tero`. It runs automatically +during onboarding when the active account has no Datadog connection. To connect +a *different* account, run `tero`, press `/`, and choose **Switch Account** — +onboarding re-runs for the account you select. -> what is middleware_execution_trace? +> There is no headless `tero datadog connect` command yet; connection is +> interactive only. -This log tracks execution through the middleware chain. Template: +### See your account status at a glance - 2024-01-15 10:23:45.123 INFO middleware executing name=cors path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.125 INFO middleware executing name=auth path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.127 INFO middleware executing name=rate_limit path=/api/checkout request_id=req_x7k2m - 2024-01-15 10:23:45.129 INFO middleware executing name=request_logger path=/api/checkout request_id=req_x7k2m +```bash +tero status +``` -Logs 6-8 times per request as it moves through the middleware chain. At 250K requests/hour, that's 2M logs/hour. +``` +Account Status +Health OK +Ready for use true +Services 3 active / 17 total +Log events 197 (197 analyzed) +Volume 4.2k/hr +Cost $74/yr +Open issues 1 (0 high, 1 medium, 0 low) +``` -Likely added during development to debug middleware ordering. When middleware fails, you get actual error logs. Success logs don't help during incidents. +### List your active issues -Safe to drop. +```bash +tero issues +``` -> block it +``` +PRIORITY ID SERVICE TITLE +medium ISS-4 accounting Order receipt logs include full customer shipping addresses +``` -Created exclusion rule in Datadog. This will save $26K/year immediately. +### Inspect services, checks, and edge instances -This log is emitted from src/middleware/logger.ts - want me to help you remove it from the code so it doesn't come back? +```bash +tero services # enabled services with health, volume, and cost +tero checks # product checks with open findings, active issues, and cost +tero edge # registered edge instances +``` -> yes +### Get machine-readable output for scripting -[Scanning your local repository for the logging statement...] -Found in src/middleware/logger.ts:45 +Every surface command supports `-o json` (default is `table`): -[Opens your editor with the change ready to review] +```bash +tero issues -o json +tero status -o json | jq '.open_issues' +tero services -o json | jq -r '.[] | select(.health != "OK") | .name' +``` -Done. Blocked in Datadog and removed from code. Want to see the next waste pattern? +```json +[ + { + "id": "019eaa9e-3242-7ba8-92b1-1b034a4b532d", + "display_id": "ISS-4", + "priority": "medium", + "service": "accounting", + "title": "Order receipt logs include full customer shipping addresses" + } +] ``` -## Safety +### Switch organization or account -**Read-only access:** Tero only reads data from Datadog. We never write or modify anything without your explicit confirmation. +Inside `tero`, press `/` to open the command palette and choose **Switch +Organization** or **Switch Account**. To switch org from a script: -**No pipeline required:** We're not a data pipeline or routing tool. Tero connects via API to your existing observability platforms - no new infrastructure to deploy or manage. +```bash +tero auth switch +``` -**No infrastructure changes:** No agents to install. No collector configs to update. No deployment required. Just a read-only API connection. +### Start over -**Opt-in actions:** When you choose to block waste, we configure your existing tools (Datadog exclusion rules, code changes, etc.). Everything is reversible. +```bash +tero reset # clear stored preferences and authentication for this environment +``` -## What This Isn't +--- -**Not a cost-cutting tool.** Tero helps you improve observability quality. Reduced costs are a side effect of better data. +## Reference + +### Commands + +| Command | Description | +|---------|-------------| +| `tero` | Launch the interactive UI (onboarding → issue explorer). | +| `tero status` | Account health, service/event counts, cost, and open-issue summary. | +| `tero issues` | Active issues (priority, ID, service, title). | +| `tero checks` | Product checks with findings, active issues, and cost. | +| `tero services` | Enabled services with health, volume, and cost. | +| `tero edge` | Edge instances registered for the account. | +| `tero auth login` | Authenticate via browser device login. | +| `tero auth status` | Show the current user and organization. | +| `tero auth logout` | Clear stored credentials. | +| `tero auth switch [org]` | Switch the active organization. | +| `tero auth token` | Print the current access token. | +| `tero reset` | Clear preferences and authentication for the current environment. | + +### Global flags + +| Flag | Description | +|------|-------------| +| `-o, --output ` | Output format for surface commands. Default `table`. | +| `--endpoint ` | Override the control-plane endpoint. | +| `-d, --debug` | Enable debug logging. | +| `-v, --version` | Print the CLI version. | +| `-h, --help` | Help for any command. | + +### Interactive UI keys + +| Key | Action | +|-----|--------| +| `↑` / `↓` (or `k` / `j`) | Navigate issues / drawer rows. | +| `r` | Refresh the issue list. | +| `ctrl+d` | Toggle the status drawer (Issues, Checks, Services, Log events, Edge). | +| `tab` | Next tab in the drawer. | +| `esc` | Close the drawer. | +| `/` | Open the command palette. | +| `ctrl+c` | Quit. | + +### Environment variables + +| Variable | Description | +|----------|-------------| +| `TERO_ENV` | Environment to target: `prd` (default), `dev`, or `local`. | +| `TERO_API_ENDPOINT` | Override the control-plane GraphQL endpoint. | +| `TERO_DEBUG` | Set to `1` or `true` to enable debug logging. | + +### Files + +Credentials and preferences live under `~/.tero/environments//`. Logs are +written to `~/.tero/environments//tero.log`. Run `tero internal inspect +paths` to print the resolved locations. -**Not a pipeline.** We don't route, sample, or transform your data in flight. We analyze what you have and help you improve it at the source. +--- + +## Concepts -**Not automatic.** We never drop data without your explicit approval. You're in control of every action. +### How it works -## Common Questions +The CLI is a thin presentation layer over the Tero **control plane**. It holds no +local database and runs no sync engine: every command reads (or writes) directly +to the control plane over GraphQL. Intelligence — the semantic analysis of your +logs, the issues, the cost modeling — lives server-side. The CLI's job is to +authenticate you, connect your data source, and show you the results. -**What Datadog permissions does Tero need?** +### What Tero finds -Read-only access to start. Tero will request specific write permissions (like creating exclusion rules) only when you choose to take action. See [setup guide](https://tero.com/docs/setup) for details. +Tero builds a semantic catalog of your log events — what each pattern *means*, +how much it costs, and whether it helps during an incident. From that it surfaces +**issues** (things worth your attention, like leaking PII or high-cost noise) and +runs **checks** across cost and compliance domains. The CLI lets you browse all +of this per account. -**What data does Tero collect?** +### Safety -We analyze metadata about your telemetry (log event names, volumes, services, costs) to build our semantic catalog. We don't store your actual log content. See [Privacy Policy](https://tero.com/privacy). +- **Read-only by default.** Tero reads telemetry metadata to build its catalog. + It does not store your raw log content. +- **No infrastructure.** No agents, collectors, or pipeline configs — just a + read-only API connection to your existing platform. +- **Opt-in everything.** Connecting Datadog requires keys you provide; nothing is + configured without your action. -**Does this work with other observability tools?** +### What this isn't -Datadog only right now. CloudWatch, Splunk, and others coming soon. +- **Not a pipeline.** Tero doesn't route, sample, or transform data in flight. It + analyzes what you already have and helps you improve it at the source. +- **Not a cost tool.** Reduced spend is a side effect of better data, not the + goal. Tero explains *why* a pattern is or isn't valuable. -**More questions?** +### Supported sources -See our [full documentation](https://tero.com/docs) or [contact us](https://tero.com/contact). +Datadog today. CloudWatch, Splunk, and others are on the roadmap. + +--- ## Resources -- **[Documentation](https://tero.com/docs)** - Full platform docs and guides -- **[GitHub Issues](https://github.com/usetero/cli/issues)** - Bug reports and feature requests -- **[Contact Us](https://tero.com/contact)** - Questions or feedback -- **[Contributing](CONTRIBUTING.md)** - Developer documentation for working on the CLI +- **[Documentation](https://tero.com/docs)** — full platform docs and guides +- **[GitHub Issues](https://github.com/usetero/cli/issues)** — bug reports and feature requests +- **[Contact us](https://tero.com/contact)** — questions or feedback +- **[Contributing](CONTRIBUTING.md)** — developer documentation for working on the CLI ## About -Tero is from the creators of [Vector.dev](https://vector.dev) (acquired by Datadog). We've spent a decade inside enterprise observability systems and seen this problem from every angle - as engineers, founders, and inside major vendors. - -We built Tero because observability data quality is broken and nobody's fixing it. Not the vendors (they profit from waste), not the pipelines (they can't understand semantic meaning), and not the cost tools (they show you bills, not solutions). +Tero is from the creators of [Vector.dev](https://vector.dev) (acquired by +Datadog). We've spent a decade inside enterprise observability systems and seen +this problem from every angle — as engineers, founders, and inside major vendors. -Tero is different. We understand what your data means, identify what's wrong, and help you fix it - the right way. +We built Tero because observability data quality is broken and nobody's fixing +it: not the vendors (they profit from waste), not the pipelines (they can't +understand semantic meaning), and not the cost tools (they show you bills, not +solutions). Tero understands what your data means, identifies what's wrong, and +helps you fix it at the source. --- From 88c434c51018e34b6ec3efb5bb78da009f830370 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 12 Jun 2026 14:58:50 -0700 Subject: [PATCH 20/20] docs: use usetero.com for documentation and contact links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4765157..3132e17f 100644 --- a/README.md +++ b/README.md @@ -302,9 +302,9 @@ Datadog today. CloudWatch, Splunk, and others are on the roadmap. ## Resources -- **[Documentation](https://tero.com/docs)** — full platform docs and guides +- **[Documentation](https://usetero.com/docs)** — full platform docs and guides - **[GitHub Issues](https://github.com/usetero/cli/issues)** — bug reports and feature requests -- **[Contact us](https://tero.com/contact)** — questions or feedback +- **[Contact us](https://usetero.com/contact)** — questions or feedback - **[Contributing](CONTRIBUTING.md)** — developer documentation for working on the CLI ## About