From 23402becaf1dba54b2f14b45b35bc57981f3958a Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 11:57:12 -0500 Subject: [PATCH 01/13] feat: add sql query for approving/denying policy --- internal/sqlite/gen/log_event_policies.sql.go | 34 +++++++++++++++++++ internal/sqlite/gen/querier.go | 2 ++ .../sqlite/queries/log_event_policies.sql | 10 ++++++ 3 files changed, 46 insertions(+) diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go index 3b347aaa..5dc5d78d 100644 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ b/internal/sqlite/gen/log_event_policies.sql.go @@ -9,6 +9,23 @@ 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 ` @@ -20,6 +37,23 @@ func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { 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 +} + const listPolicyCategoryStatuses = `-- name: ListPolicyCategoryStatuses :many SELECT COALESCE(category, '') AS category, diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go index 55702610..9a52dd4f 100644 --- a/internal/sqlite/gen/querier.go +++ b/internal/sqlite/gen/querier.go @@ -9,6 +9,7 @@ import ( ) type Querier interface { + ApproveLogEventPolicy(ctx context.Context, arg ApproveLogEventPolicyParams) error CountConversations(ctx context.Context) (int64, error) CountLogEventPolicies(ctx context.Context) (int64, error) CountLogEvents(ctx context.Context) (int64, error) @@ -16,6 +17,7 @@ type Querier interface { CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, 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) diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql index c88d768c..6783ad5e 100644 --- a/internal/sqlite/queries/log_event_policies.sql +++ b/internal/sqlite/queries/log_event_policies.sql @@ -27,3 +27,13 @@ WHERE category IS NOT NULL AND category != '' GROUP BY category ORDER BY SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) DESC; + +-- 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 = ?; \ No newline at end of file From 52a0c7643885edfa3ce0be9e045e755d3f8561ec Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 13:52:45 -0500 Subject: [PATCH 02/13] feat: Add wrapper methods to call sqlc functions --- internal/sqlite/log_event_policies.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index 9e4324a0..6c590ba8 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -11,6 +11,8 @@ import ( type LogEventPolicies interface { Count(ctx context.Context) (int64, error) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) + Approve(ctx context.Context, arg gen.ApproveLogEventPolicyParams) error + Dismiss(ctx context.Context, arg gen.DismissLogEventPolicyParams) error } // logEventPoliciesImpl implements LogEventPolicies. @@ -51,6 +53,14 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma return result, nil } +func (l *logEventPoliciesImpl) Approve(ctx context.Context, arg gen.ApproveLogEventPolicyParams) error { + return l.queries.ApproveLogEventPolicy(ctx, arg) +} + +func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, arg gen.DismissLogEventPolicyParams) error { + return l.queries.DismissLogEventPolicy(ctx, arg) +} + func derefFloat(p *float64) float64 { if p == nil { return 0 From 61603bdfdfc964a28fb6397f18d22c9bbf78df94 Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 14:11:20 -0500 Subject: [PATCH 03/13] fix: Update approve/dismiss wrappers --- internal/sqlite/log_event_policies.go | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index 6c590ba8..a329150e 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "time" "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/sqlite/gen" @@ -53,12 +54,30 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma return result, nil } -func (l *logEventPoliciesImpl) Approve(ctx context.Context, arg gen.ApproveLogEventPolicyParams) error { - return l.queries.ApproveLogEventPolicy(ctx, arg) +func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userId string) error { + now := time.Now().UTC().Format(time.RFC3339) + err := l.queries.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, arg gen.DismissLogEventPolicyParams) error { - return l.queries.DismissLogEventPolicy(ctx, arg) +func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userId string) error { + now := time.Now().UTC().Format(time.RFC3339) + err := l.queries.DismissLogEventPolicy(ctx, gen.DismissLogEventPolicyParams{ + ID: &id, + DismissedAt: &now, + DismissedBy: &userId, + }) + if err != nil { + return WrapSQLiteError(err, "dismiss log event policy") + } + return nil } func derefFloat(p *float64) float64 { From b96f1105e9025da345321e9a0030d9e3df14b08d Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 14:49:58 -0500 Subject: [PATCH 04/13] feat: add log policy api bits and tests --- internal/api/apitest/mock_client.go | 16 +++++ internal/api/client.go | 22 +++++++ internal/api/policy_service.go | 65 ++++++++++++++++++++ internal/api/services.go | 2 + internal/app/app.go | 2 +- internal/sqlite/log_event_policies.go | 12 ++-- internal/upload/log_event_policy_handler.go | 62 +++++++++++++++++++ internal/upload/uploader.go | 6 +- internal/upload/uploader_integration_test.go | 2 +- internal/upload/uploader_test.go | 5 ++ 10 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 internal/api/policy_service.go create mode 100644 internal/upload/log_event_policy_handler.go diff --git a/internal/api/apitest/mock_client.go b/internal/api/apitest/mock_client.go index 5057934b..a7204eec 100644 --- a/internal/api/apitest/mock_client.go +++ b/internal/api/apitest/mock_client.go @@ -24,6 +24,8 @@ type MockClient struct { 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) + ApproveLogEventPolicyFunc func(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) + DismissLogEventPolicyFunc func(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -134,3 +136,17 @@ func (m *MockClient) CreateMessage(ctx context.Context, input gen.CreateMessageI } return nil, nil } + +func (m *MockClient) ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) { + if m.ApproveLogEventPolicyFunc != nil { + return m.ApproveLogEventPolicyFunc(ctx, id) + } + return nil, nil +} + +func (m *MockClient) DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) { + if m.DismissLogEventPolicyFunc != nil { + return m.DismissLogEventPolicyFunc(ctx, id) + } + return nil, nil +} diff --git a/internal/api/client.go b/internal/api/client.go index bc7395b6..1fac96ec 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -51,6 +51,10 @@ type Client interface { // Message operations CreateMessage(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) + + // Policy operations + ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) + DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) } // client is the concrete implementation of Client. @@ -261,3 +265,21 @@ func (c *client) CreateMessage(ctx context.Context, input gen.CreateMessageInput } return gen.CreateMessage(ctx, gql, input) } + +// Policy operations + +func (c *client) ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.ApproveLogEventPolicy(ctx, gql, id) +} + +func (c *client) DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) { + gql, err := c.gql(ctx) + if err != nil { + return nil, err + } + return gen.DismissLogEventPolicy(ctx, gql, id) +} diff --git a/internal/api/policy_service.go b/internal/api/policy_service.go new file mode 100644 index 00000000..cdaacfed --- /dev/null +++ b/internal/api/policy_service.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "fmt" + + "github.com/usetero/cli/internal/log" +) + +// LogEventPolicies provides access to log event policy operations. +type LogEventPolicies interface { + Approve(ctx context.Context, id string) error + Dismiss(ctx context.Context, id string) error +} + +// PolicyService handles policy-related API operations. +type PolicyService struct { + client Client + scope log.Scope +} + +// Ensure PolicyService implements LogEventPolicies. +var _ LogEventPolicies = (*PolicyService)(nil) + +// NewPolicyService creates a new policy service. +func NewPolicyService(client Client, scope log.Scope) *PolicyService { + return &PolicyService{ + client: client, + scope: scope.Child("policies"), + } +} + +// Approve approves a log event policy. +func (s *PolicyService) Approve(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 +} + +// Dismiss dismisses a log event policy. +func (s *PolicyService) Dismiss(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 +} diff --git a/internal/api/services.go b/internal/api/services.go index 95dfff9d..5de4ce06 100644 --- a/internal/api/services.go +++ b/internal/api/services.go @@ -18,6 +18,7 @@ type APIServices struct { DatadogAccounts DatadogAccounts Conversations Conversations Messages Messages + Policies LogEventPolicies } // NewServices creates APIServices with an internally-managed client. @@ -43,6 +44,7 @@ func newAPIServices(client Client, scope log.Scope) APIServices { DatadogAccounts: NewDatadogAccountService(client, scope), Conversations: NewConversationService(client, scope), Messages: NewMessageService(client, scope), + Policies: NewPolicyService(client, scope), } } diff --git a/internal/app/app.go b/internal/app/app.go index 86c1648e..b6dd1cea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -489,7 +489,7 @@ func (m *Model) startSync(accountID string) error { psClient := psapi.NewClient(m.cfg.PowerSyncEndpoint) // Create and start uploader - m.uploader = upload.New(m.db, psClient, m.authService, m.services.Conversations, m.services.Messages, m.scope) + m.uploader = upload.New(m.db, psClient, m.authService, m.services.Conversations, m.services.Messages, m.services.Policies, m.scope) go func() { if err := m.uploader.Run(sessionCtx); err != nil && !errors.Is(err, context.Canceled) { m.scope.Error("uploader error", "error", err) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index a329150e..cfa68fe7 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -12,8 +12,8 @@ import ( type LogEventPolicies interface { Count(ctx context.Context) (int64, error) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) - Approve(ctx context.Context, arg gen.ApproveLogEventPolicyParams) error - Dismiss(ctx context.Context, arg gen.DismissLogEventPolicyParams) error + Approve(ctx context.Context, id, userID string) error + Dismiss(ctx context.Context, id, userID string) error } // logEventPoliciesImpl implements LogEventPolicies. @@ -54,12 +54,12 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma return result, nil } -func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userId string) error { +func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) error { now := time.Now().UTC().Format(time.RFC3339) err := l.queries.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ ID: &id, ApprovedAt: &now, - ApprovedBy: &userId, + ApprovedBy: &userID, }) if err != nil { return WrapSQLiteError(err, "approve log event policy") @@ -67,12 +67,12 @@ func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userId string) e return nil } -func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userId string) error { +func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userID string) error { now := time.Now().UTC().Format(time.RFC3339) err := l.queries.DismissLogEventPolicy(ctx, gen.DismissLogEventPolicyParams{ ID: &id, DismissedAt: &now, - DismissedBy: &userId, + DismissedBy: &userID, }) if err != nil { return WrapSQLiteError(err, "dismiss log event policy") diff --git a/internal/upload/log_event_policy_handler.go b/internal/upload/log_event_policy_handler.go new file mode 100644 index 00000000..db723284 --- /dev/null +++ b/internal/upload/log_event_policy_handler.go @@ -0,0 +1,62 @@ +package upload + +import ( + "context" + "fmt" + + "github.com/usetero/cli/internal/api" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/powersync/db" +) + +// policyHandler handles uploading policy changes to the GraphQL API. +type policyHandler struct { + policies api.LogEventPolicies + scope log.Scope +} + +func newPolicyHandler(policies api.LogEventPolicies, 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 // Policy handler doesn't emit events currently + + switch entry.Op { + case db.OpPatch: + return h.handlePatch(ctx, entry) + default: + // We only handle PATCH (updates) for policies - they're created server-side + h.scope.Debug("ignoring policy op", "op", entry.Op, "id", entry.RowID) + return nil + } +} + +func (h *policyHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) error { + // Check if this is an approval (approved_at is set) + if approvedAt, ok := entry.Data["approved_at"]; ok && approvedAt != nil { + err := h.policies.Approve(ctx, entry.RowID) + if err != nil { + return fmt.Errorf("approve policy: %w", err) + } + h.scope.Debug("approved policy", "id", entry.RowID) + return nil + } + + // Check if this is a dismissal (dismissed_at is set) + if dismissedAt, ok := entry.Data["dismissed_at"]; ok && dismissedAt != nil { + err := h.policies.Dismiss(ctx, entry.RowID) + if err != nil { + return fmt.Errorf("dismiss policy: %w", err) + } + h.scope.Debug("dismissed policy", "id", entry.RowID) + return nil + } + + // If neither, just log and skip + h.scope.Debug("policy patch with no approve/dismiss", "id", entry.RowID, "data", entry.Data) + return nil +} diff --git a/internal/upload/uploader.go b/internal/upload/uploader.go index 55968bd4..f4b1d395 100644 --- a/internal/upload/uploader.go +++ b/internal/upload/uploader.go @@ -59,6 +59,7 @@ func New( tokenRefresher TokenRefresher, conversations api.Conversations, messages api.Messages, + policies api.LogEventPolicies, scope log.Scope, ) Uploader { scope = scope.Child("upload") @@ -68,8 +69,9 @@ func New( client: client, tokenRefresher: tokenRefresher, handlers: map[sqlite.Table]Handler{ - sqlite.TableConversations: newConversationHandler(conversations, scope), - sqlite.TableMessages: newMessageHandler(messages, database, scope), + sqlite.TableConversations: newConversationHandler(conversations, scope), + sqlite.TableMessages: newMessageHandler(messages, database, scope), + sqlite.TableLogEventPolicies: newPolicyHandler(policies, scope), }, scope: scope, pollInterval: defaultPollInterval, diff --git a/internal/upload/uploader_integration_test.go b/internal/upload/uploader_integration_test.go index e3a2f028..b53e7a6c 100644 --- a/internal/upload/uploader_integration_test.go +++ b/internal/upload/uploader_integration_test.go @@ -122,7 +122,7 @@ func TestIntegration_Upload(t *testing.T) { // Start upload loop powersyncClient := powersync.NewClient(cliConfig.PowerSyncEndpoint) - uploader := upload.New(db, powersyncClient, authSvc, services.Conversations, messagesSvc, logger) + uploader := upload.New(db, powersyncClient, authSvc, services.Conversations, messagesSvc, services.Policies, logger) uploadCtx, uploadCancel := context.WithCancel(ctx) defer uploadCancel() diff --git a/internal/upload/uploader_test.go b/internal/upload/uploader_test.go index 6e68cf55..ca600fa3 100644 --- a/internal/upload/uploader_test.go +++ b/internal/upload/uploader_test.go @@ -32,6 +32,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -55,6 +56,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -103,6 +105,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), conversations, apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -158,6 +161,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), apitest.NewMockConversations(), apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) @@ -222,6 +226,7 @@ func TestUploader_Run(t *testing.T) { powersynctest.NewMockTokenRefresher("token"), conversations, apitest.NewMockMessages(), + apitest.NewMockPolicies(), logtest.NewScope(t), ) From 5bdecdbf937f6030726836487dd05ac111fd1fd8 Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 14:50:59 -0500 Subject: [PATCH 05/13] feat: add gql and tests --- internal/api/apitest/mock_policies.go | 30 +++ internal/api/gen/generated.go | 164 ++++++++++++++++ internal/api/gen/queries/policies.graphql | 17 ++ .../upload/log_event_policy_handler_test.go | 182 ++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 internal/api/apitest/mock_policies.go create mode 100644 internal/api/gen/queries/policies.graphql create mode 100644 internal/upload/log_event_policy_handler_test.go diff --git a/internal/api/apitest/mock_policies.go b/internal/api/apitest/mock_policies.go new file mode 100644 index 00000000..dd24f357 --- /dev/null +++ b/internal/api/apitest/mock_policies.go @@ -0,0 +1,30 @@ +package apitest + +import ( + "context" +) + +// MockPolicies implements api.LogEventPolicies for testing. +type MockPolicies struct { + ApproveFunc func(ctx context.Context, id string) error + DismissFunc func(ctx context.Context, id string) error +} + +// NewMockPolicies creates a MockPolicies with sensible defaults. +func NewMockPolicies() *MockPolicies { + return &MockPolicies{} +} + +func (m *MockPolicies) Approve(ctx context.Context, id string) error { + if m.ApproveFunc != nil { + return m.ApproveFunc(ctx, id) + } + return nil +} + +func (m *MockPolicies) Dismiss(ctx context.Context, id string) error { + if m.DismissFunc != nil { + return m.DismissFunc(ctx, id) + } + return nil +} diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index 6c5dafca..70addef0 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -11,6 +11,42 @@ import ( "github.com/Khan/genqlient/graphql" ) +// ApproveLogEventPolicyApproveLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. +type ApproveLogEventPolicyApproveLogEventPolicy struct { + // Unique identifier + Id string `json:"id"` + // Quality issue category this policy addresses, e.g. health_checks, bot_traffic, pii_leakage, duplicate_fields + Category string `json:"category"` + // 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 ApproveLogEventPolicyApproveLogEventPolicy.Id, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetId() string { return v.Id } + +// GetCategory returns ApproveLogEventPolicyApproveLogEventPolicy.Category, and is useful for accessing the field via an interface. +func (v *ApproveLogEventPolicyApproveLogEventPolicy) GetCategory() string { return v.Category } + +// 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 } + +// 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"` @@ -527,6 +563,44 @@ type DeleteConversationResponse struct { // GetDeleteConversation returns DeleteConversationResponse.DeleteConversation, and is useful for accessing the field via an interface. func (v *DeleteConversationResponse) GetDeleteConversation() bool { return v.DeleteConversation } +// DismissLogEventPolicyDismissLogEventPolicy includes the requested fields of the GraphQL type LogEventPolicy. +type DismissLogEventPolicyDismissLogEventPolicy struct { + // Unique identifier + Id string `json:"id"` + // Quality issue category this policy addresses, e.g. health_checks, bot_traffic, pii_leakage, duplicate_fields + Category string `json:"category"` + // 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 DismissLogEventPolicyDismissLogEventPolicy.Id, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetId() string { return v.Id } + +// GetCategory returns DismissLogEventPolicyDismissLogEventPolicy.Category, and is useful for accessing the field via an interface. +func (v *DismissLogEventPolicyDismissLogEventPolicy) GetCategory() string { return v.Category } + +// 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 } + +// 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 +} + // EnableServiceResponse is returned by EnableService on success. type EnableServiceResponse struct { UpdateService EnableServiceUpdateService `json:"updateService"` @@ -1956,6 +2030,14 @@ var AllWorkspacePurpose = []WorkspacePurpose{ 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"` @@ -2006,6 +2088,14 @@ type __DeleteConversationInput struct { // GetId returns __DeleteConversationInput.Id, and is useful for accessing the field via an interface. func (v *__DeleteConversationInput) GetId() string { return v.Id } +// __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"` @@ -2082,6 +2172,43 @@ 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 + category + approvedAt + approvedBy + } +} +` + +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!) { @@ -2310,6 +2437,43 @@ func DeleteConversation( return data_, err_ } +// The mutation executed by DismissLogEventPolicy. +const DismissLogEventPolicy_Operation = ` +mutation DismissLogEventPolicy ($id: ID!) { + dismissLogEventPolicy(id: $id) { + id + category + dismissedAt + dismissedBy + } +} +` + +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!) { diff --git a/internal/api/gen/queries/policies.graphql b/internal/api/gen/queries/policies.graphql new file mode 100644 index 00000000..9c127705 --- /dev/null +++ b/internal/api/gen/queries/policies.graphql @@ -0,0 +1,17 @@ +mutation ApproveLogEventPolicy($id: ID!) { + approveLogEventPolicy(id: $id) { + id + category + approvedAt + approvedBy + } +} + +mutation DismissLogEventPolicy($id: ID!) { + dismissLogEventPolicy(id: $id) { + id + category + dismissedAt + dismissedBy + } +} diff --git a/internal/upload/log_event_policy_handler_test.go b/internal/upload/log_event_policy_handler_test.go new file mode 100644 index 00000000..656cea91 --- /dev/null +++ b/internal/upload/log_event_policy_handler_test.go @@ -0,0 +1,182 @@ +package upload + +import ( + "context" + "errors" + "testing" + + "github.com/usetero/cli/internal/log/logtest" + "github.com/usetero/cli/internal/powersync/db" +) + +// mockPolicies implements api.LogEventPolicies for testing. +type mockPolicies struct { + approveFunc func(ctx context.Context, id string) error + dismissFunc func(ctx context.Context, id string) error +} + +func (m *mockPolicies) Approve(ctx context.Context, id string) error { + if m.approveFunc != nil { + return m.approveFunc(ctx, id) + } + return nil +} + +func (m *mockPolicies) Dismiss(ctx context.Context, id string) error { + if m.dismissFunc != nil { + return m.dismissFunc(ctx, id) + } + return nil +} + +func TestPolicyHandler_Handle(t *testing.T) { + t.Parallel() + + t.Run("PATCH with approved_at calls Approve", func(t *testing.T) { + t.Parallel() + + var approvedID string + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + approvedID = id + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "approved_at": "2024-01-15T10:00:00Z", + "approved_by": "user-456", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + if approvedID != "policy-123" { + t.Errorf("Approve called with id = %q, want %q", approvedID, "policy-123") + } + }) + + t.Run("PATCH with dismissed_at calls Dismiss", func(t *testing.T) { + t.Parallel() + + var dismissedID string + policies := &mockPolicies{ + dismissFunc: func(ctx context.Context, id string) error { + dismissedID = id + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-789", + Op: db.OpPatch, + Data: map[string]any{ + "dismissed_at": "2024-01-15T10:00:00Z", + "dismissed_by": "user-456", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + if dismissedID != "policy-789" { + t.Errorf("Dismiss called with id = %q, want %q", dismissedID, "policy-789") + } + }) + + t.Run("PATCH returns error on Approve failure", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + return errors.New("api error") + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "approved_at": "2024-01-15T10:00:00Z", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err == nil { + t.Fatal("Handle() expected error, got nil") + } + }) + + t.Run("ignores PUT operations", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + t.Error("Approve should not be called") + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPut, + Data: map[string]any{}, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + }) + + t.Run("ignores PATCH without approve/dismiss data", func(t *testing.T) { + t.Parallel() + + policies := &mockPolicies{ + approveFunc: func(ctx context.Context, id string) error { + t.Error("Approve should not be called") + return nil + }, + dismissFunc: func(ctx context.Context, id string) error { + t.Error("Dismiss should not be called") + return nil + }, + } + + handler := newPolicyHandler(policies, logtest.NewScope(t)) + + entry := &db.CrudEntry{ + Table: "log_event_policies", + RowID: "policy-123", + Op: db.OpPatch, + Data: map[string]any{ + "some_other_field": "value", + }, + } + + err := handler.Handle(context.Background(), entry, nil) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + }) +} From 55b73492d306a7fff319c7398d8542cc6e83a5e1 Mon Sep 17 00:00:00 2001 From: Divya Date: Wed, 11 Feb 2026 19:02:42 -0500 Subject: [PATCH 06/13] feat: first iteration of approval --- internal/api/apitest/mock_client.go | 8 ---- internal/api/apitest/mock_policies.go | 8 ---- internal/api/client.go | 9 ---- internal/api/policy_service.go | 18 ------- internal/app/app.go | 12 +++-- .../round/turn/assistant/assistant.go | 3 ++ internal/app/chat/msgs/tools.go | 20 ++++++++ internal/app/onboarding/auth/check.go | 18 +++++-- internal/app/onboarding/msgs/msgs.go | 2 + internal/app/onboarding/onboarding.go | 4 ++ internal/chat/tools/registry.go | 10 ++-- internal/domain/tools/tools.go | 13 +++-- internal/sqlite/database.go | 2 +- internal/sqlite/gen/log_event_policies.sql.go | 17 ------- internal/sqlite/gen/querier.go | 1 - internal/sqlite/log_event_policies.go | 23 ++------- .../sqlite/queries/log_event_policies.sql | 5 -- internal/upload/log_event_policy_handler.go | 12 +---- .../upload/log_event_policy_handler_test.go | 47 +------------------ 19 files changed, 76 insertions(+), 156 deletions(-) diff --git a/internal/api/apitest/mock_client.go b/internal/api/apitest/mock_client.go index a7204eec..f16d2b34 100644 --- a/internal/api/apitest/mock_client.go +++ b/internal/api/apitest/mock_client.go @@ -25,7 +25,6 @@ type MockClient struct { DeleteConversationFunc func(ctx context.Context, id string) (*gen.DeleteConversationResponse, error) CreateMessageFunc func(ctx context.Context, input gen.CreateMessageInput) (*gen.CreateMessageResponse, error) ApproveLogEventPolicyFunc func(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicyFunc func(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) } // NewMockClient creates a MockClient with sensible defaults. @@ -143,10 +142,3 @@ func (m *MockClient) ApproveLogEventPolicy(ctx context.Context, id string) (*gen } return nil, nil } - -func (m *MockClient) DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) { - if m.DismissLogEventPolicyFunc != nil { - return m.DismissLogEventPolicyFunc(ctx, id) - } - return nil, nil -} diff --git a/internal/api/apitest/mock_policies.go b/internal/api/apitest/mock_policies.go index dd24f357..1d4ab62c 100644 --- a/internal/api/apitest/mock_policies.go +++ b/internal/api/apitest/mock_policies.go @@ -7,7 +7,6 @@ import ( // MockPolicies implements api.LogEventPolicies for testing. type MockPolicies struct { ApproveFunc func(ctx context.Context, id string) error - DismissFunc func(ctx context.Context, id string) error } // NewMockPolicies creates a MockPolicies with sensible defaults. @@ -21,10 +20,3 @@ func (m *MockPolicies) Approve(ctx context.Context, id string) error { } return nil } - -func (m *MockPolicies) Dismiss(ctx context.Context, id string) error { - if m.DismissFunc != nil { - return m.DismissFunc(ctx, id) - } - return nil -} diff --git a/internal/api/client.go b/internal/api/client.go index 1fac96ec..242b6a14 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -54,7 +54,6 @@ type Client interface { // Policy operations ApproveLogEventPolicy(ctx context.Context, id string) (*gen.ApproveLogEventPolicyResponse, error) - DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) } // client is the concrete implementation of Client. @@ -275,11 +274,3 @@ func (c *client) ApproveLogEventPolicy(ctx context.Context, id string) (*gen.App } return gen.ApproveLogEventPolicy(ctx, gql, id) } - -func (c *client) DismissLogEventPolicy(ctx context.Context, id string) (*gen.DismissLogEventPolicyResponse, error) { - gql, err := c.gql(ctx) - if err != nil { - return nil, err - } - return gen.DismissLogEventPolicy(ctx, gql, id) -} diff --git a/internal/api/policy_service.go b/internal/api/policy_service.go index cdaacfed..3f7f6e64 100644 --- a/internal/api/policy_service.go +++ b/internal/api/policy_service.go @@ -10,7 +10,6 @@ import ( // LogEventPolicies provides access to log event policy operations. type LogEventPolicies interface { Approve(ctx context.Context, id string) error - Dismiss(ctx context.Context, id string) error } // PolicyService handles policy-related API operations. @@ -46,20 +45,3 @@ func (s *PolicyService) Approve(ctx context.Context, id string) error { s.scope.Debug("approved policy via API", "id", id) return nil } - -// Dismiss dismisses a log event policy. -func (s *PolicyService) Dismiss(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 -} diff --git a/internal/app/app.go b/internal/app/app.go index b6dd1cea..ed51494e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -291,9 +291,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create tool registry with executors m.toolRegistry = &chattools.Registry{ - Query: chattools.NewQueryTool(m.db), - StartJourney: chattools.NewStartJourneyTool(), - EndJourney: chattools.NewEndJourneyTool(), + Query: chattools.NewQueryTool(m.db), + PolicyApprove: chattools.NewPolicyApproveTool(m.db.LogEventPolicies(), func() string { + if m.user == nil { + return "" + } + return m.user.ID + }), + StartJourney: chattools.NewStartJourneyTool(), + EndJourney: chattools.NewEndJourneyTool(), } // Create chat client with tool definitions diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go index c10dca8b..7fd08c52 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant.go @@ -5,6 +5,7 @@ import ( "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/policyapprove" "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/query" "github.com/usetero/cli/internal/app/chat/msgs" chattools "github.com/usetero/cli/internal/chat/tools" @@ -153,6 +154,8 @@ func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *too switch toolUse.Name { case m.toolRegistry.Query.Name(): child = query.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.Query, m.scope) + case m.toolRegistry.PolicyApprove.Name(): + child = policyapprove.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.PolicyApprove, m.scope) default: child = query.New(m.blockTheme, index, toolUse.ID, width, nil, m.scope) } diff --git a/internal/app/chat/msgs/tools.go b/internal/app/chat/msgs/tools.go index b15669f6..240c0e2f 100644 --- a/internal/app/chat/msgs/tools.go +++ b/internal/app/chat/msgs/tools.go @@ -67,6 +67,25 @@ func (m EndJourneyCompleted) GetResult() domaintools.Result { } func (m EndJourneyCompleted) toolCompleted() {} +// PolicyApproveCompleted is fired when a policy approve tool finishes executing. +type PolicyApproveCompleted struct { + ToolUseID string + PolicyID string + Approved bool + Error error +} + +func (m PolicyApproveCompleted) GetToolUseID() string { return m.ToolUseID } +func (m PolicyApproveCompleted) GetError() error { return m.Error } +func (m PolicyApproveCompleted) GetResult() domaintools.Result { + return domaintools.Result{ + ToolUseID: m.ToolUseID, + PolicyApprove: &domaintools.PolicyApproveResult{Approved: m.Approved}, + Error: errorResultFromErr(m.Error), + } +} +func (m PolicyApproveCompleted) toolCompleted() {} + func errorResultFromErr(err error) *domaintools.ErrorResult { if err == nil { return nil @@ -79,4 +98,5 @@ var ( _ ToolCompleted = QueryCompleted{} _ ToolCompleted = StartJourneyCompleted{} _ ToolCompleted = EndJourneyCompleted{} + _ ToolCompleted = PolicyApproveCompleted{} ) diff --git a/internal/app/onboarding/auth/check.go b/internal/app/onboarding/auth/check.go index cef79d73..47e7f1ec 100644 --- a/internal/app/onboarding/auth/check.go +++ b/internal/app/onboarding/auth/check.go @@ -17,6 +17,7 @@ import ( // checkResultMsg is the internal message for auth check completion. type checkResultMsg struct { hasValidAuth bool + userID string err error } @@ -63,7 +64,14 @@ func (m *CheckModel) checkAuth() tea.Cmd { return checkResultMsg{hasValidAuth: false} } - return checkResultMsg{hasValidAuth: true} + // Get user ID for the authenticated user + userID, err := m.auth.GetUserID(m.ctx) + if err != nil { + _ = m.auth.ClearTokens() + return checkResultMsg{hasValidAuth: false} + } + + return checkResultMsg{hasValidAuth: true, userID: userID} } } @@ -76,12 +84,16 @@ func (m *CheckModel) Update(msg tea.Msg) tea.Cmd { return appmsg.ErrorCmd("Authentication check failed", msg.err, false) } if msg.hasValidAuth { - m.scope.Info("auth valid") + m.scope.Info("auth valid", "user_id", msg.userID) } else { m.scope.Info("auth required") } return func() tea.Msg { - return msgs.AuthChecked{NeedsAuth: !msg.hasValidAuth} + result := msgs.AuthChecked{NeedsAuth: !msg.hasValidAuth} + if msg.hasValidAuth && msg.userID != "" { + result.User = &auth.User{ID: msg.userID} + } + return result } } return nil diff --git a/internal/app/onboarding/msgs/msgs.go b/internal/app/onboarding/msgs/msgs.go index 01b84e9f..49994d1c 100644 --- a/internal/app/onboarding/msgs/msgs.go +++ b/internal/app/onboarding/msgs/msgs.go @@ -16,8 +16,10 @@ const ( // Auth messages. // AuthChecked is emitted when the auth check completes. +// When NeedsAuth is false, User contains the authenticated user. type AuthChecked struct { NeedsAuth bool + User *auth.User } // Authenticated is emitted when authentication succeeds. diff --git a/internal/app/onboarding/onboarding.go b/internal/app/onboarding/onboarding.go index 7735b4c8..c0a596b3 100644 --- a/internal/app/onboarding/onboarding.go +++ b/internal/app/onboarding/onboarding.go @@ -131,6 +131,10 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { if msg.NeedsAuth { return m.setStep(auth.NewAuthenticate(m.ctx, m.theme, m.auth, m.scope)) } + // Set user from existing session + if msg.User != nil { + m.user = msg.User + } return m.setStep(role.New(m.theme, m.userPrefs, m.scope)) case msgs.Authenticated: diff --git a/internal/chat/tools/registry.go b/internal/chat/tools/registry.go index dc6fc874..f9f8679c 100644 --- a/internal/chat/tools/registry.go +++ b/internal/chat/tools/registry.go @@ -4,9 +4,10 @@ import "github.com/usetero/cli/internal/chat" // Registry holds tool instances and provides definitions. type Registry struct { - Query *QueryTool - StartJourney *StartJourneyTool - EndJourney *EndJourneyTool + Query *QueryTool + PolicyApprove *PolicyApproveTool + StartJourney *StartJourneyTool + EndJourney *EndJourneyTool } // Definitions returns tool definitions for the chat API. @@ -15,6 +16,9 @@ func (r *Registry) Definitions() []chat.Tool { if r.Query != nil { defs = append(defs, r.Query.Definition()) } + if r.PolicyApprove != nil { + defs = append(defs, r.PolicyApprove.Definition()) + } if r.StartJourney != nil { defs = append(defs, r.StartJourney.Definition()) } diff --git a/internal/domain/tools/tools.go b/internal/domain/tools/tools.go index 2df21d0a..a82ad6a2 100644 --- a/internal/domain/tools/tools.go +++ b/internal/domain/tools/tools.go @@ -11,11 +11,12 @@ type Tool interface { // Result holds a typed tool result. Exactly one field is set. type Result struct { - ToolUseID string - Query *QueryResult - StartJourney *StartJourneyResult - EndJourney *EndJourneyResult - Error *ErrorResult + ToolUseID string + Query *QueryResult + PolicyApprove *PolicyApproveResult + StartJourney *StartJourneyResult + EndJourney *EndJourneyResult + Error *ErrorResult } // ToMap serializes the result for the GraphQL API. @@ -27,6 +28,8 @@ func (r Result) ToMap() map[string]any { return r.StartJourney.ToMap() case r.EndJourney != nil: return r.EndJourney.ToMap() + case r.PolicyApprove != nil: + return r.PolicyApprove.ToMap() case r.Error != nil: return map[string]any{"error": r.Error.Message} default: diff --git a/internal/sqlite/database.go b/internal/sqlite/database.go index 0b7a443f..49f86349 100644 --- a/internal/sqlite/database.go +++ b/internal/sqlite/database.go @@ -264,7 +264,7 @@ func (d *database) LogEvents() LogEvents { // LogEventPolicies returns type-safe log event policy operations. func (d *database) LogEventPolicies() LogEventPolicies { - return &logEventPoliciesImpl{queries: d.ReadQueries()} + return &logEventPoliciesImpl{read: d.ReadQueries(), write: d.WriteQueries()} } // --------------------------------------------------------------------------- diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go index 5dc5d78d..ee8edc08 100644 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ b/internal/sqlite/gen/log_event_policies.sql.go @@ -37,23 +37,6 @@ func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { 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 -} - const listPolicyCategoryStatuses = `-- name: ListPolicyCategoryStatuses :many SELECT COALESCE(category, '') AS category, diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go index 9a52dd4f..ab7e3b74 100644 --- a/internal/sqlite/gen/querier.go +++ b/internal/sqlite/gen/querier.go @@ -17,7 +17,6 @@ type Querier interface { CountMessagesByConversation(ctx context.Context, conversationID *string) (int64, 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) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index cfa68fe7..2b693e7e 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -13,17 +13,17 @@ type LogEventPolicies interface { Count(ctx context.Context) (int64, error) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) Approve(ctx context.Context, id, userID string) error - Dismiss(ctx context.Context, id, userID string) error } // logEventPoliciesImpl implements LogEventPolicies. type logEventPoliciesImpl struct { - queries *gen.Queries + 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.queries.CountLogEventPolicies(ctx) + count, err := l.read.CountLogEventPolicies(ctx) if err != nil { return 0, WrapSQLiteError(err, "count log event policies") } @@ -32,7 +32,7 @@ func (l *logEventPoliciesImpl) Count(ctx context.Context) (int64, error) { // ListCategoryStatuses returns policy counts and impact grouped by category. func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) { - rows, err := l.queries.ListPolicyCategoryStatuses(ctx) + rows, err := l.read.ListPolicyCategoryStatuses(ctx) if err != nil { return nil, WrapSQLiteError(err, "list policy category statuses") } @@ -56,7 +56,7 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) error { now := time.Now().UTC().Format(time.RFC3339) - err := l.queries.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ + err := l.write.ApproveLogEventPolicy(ctx, gen.ApproveLogEventPolicyParams{ ID: &id, ApprovedAt: &now, ApprovedBy: &userID, @@ -67,19 +67,6 @@ func (l *logEventPoliciesImpl) Approve(ctx context.Context, id, userID string) e return nil } -func (l *logEventPoliciesImpl) Dismiss(ctx context.Context, id, userID string) error { - now := time.Now().UTC().Format(time.RFC3339) - err := l.queries.DismissLogEventPolicy(ctx, gen.DismissLogEventPolicyParams{ - ID: &id, - DismissedAt: &now, - DismissedBy: &userID, - }) - if err != nil { - return WrapSQLiteError(err, "dismiss log event policy") - } - return nil -} - func derefFloat(p *float64) float64 { if p == nil { return 0 diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql index 6783ad5e..7189fd33 100644 --- a/internal/sqlite/queries/log_event_policies.sql +++ b/internal/sqlite/queries/log_event_policies.sql @@ -32,8 +32,3 @@ ORDER BY 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 = ?; \ No newline at end of file diff --git a/internal/upload/log_event_policy_handler.go b/internal/upload/log_event_policy_handler.go index db723284..c980d838 100644 --- a/internal/upload/log_event_policy_handler.go +++ b/internal/upload/log_event_policy_handler.go @@ -46,17 +46,7 @@ func (h *policyHandler) handlePatch(ctx context.Context, entry *db.CrudEntry) er return nil } - // Check if this is a dismissal (dismissed_at is set) - if dismissedAt, ok := entry.Data["dismissed_at"]; ok && dismissedAt != nil { - err := h.policies.Dismiss(ctx, entry.RowID) - if err != nil { - return fmt.Errorf("dismiss policy: %w", err) - } - h.scope.Debug("dismissed policy", "id", entry.RowID) - return nil - } - // If neither, just log and skip - h.scope.Debug("policy patch with no approve/dismiss", "id", entry.RowID, "data", entry.Data) + h.scope.Debug("policy patch with no approve", "id", entry.RowID, "data", entry.Data) return nil } diff --git a/internal/upload/log_event_policy_handler_test.go b/internal/upload/log_event_policy_handler_test.go index 656cea91..fb3800c0 100644 --- a/internal/upload/log_event_policy_handler_test.go +++ b/internal/upload/log_event_policy_handler_test.go @@ -12,7 +12,6 @@ import ( // mockPolicies implements api.LogEventPolicies for testing. type mockPolicies struct { approveFunc func(ctx context.Context, id string) error - dismissFunc func(ctx context.Context, id string) error } func (m *mockPolicies) Approve(ctx context.Context, id string) error { @@ -22,13 +21,6 @@ func (m *mockPolicies) Approve(ctx context.Context, id string) error { return nil } -func (m *mockPolicies) Dismiss(ctx context.Context, id string) error { - if m.dismissFunc != nil { - return m.dismissFunc(ctx, id) - } - return nil -} - func TestPolicyHandler_Handle(t *testing.T) { t.Parallel() @@ -65,39 +57,6 @@ func TestPolicyHandler_Handle(t *testing.T) { } }) - t.Run("PATCH with dismissed_at calls Dismiss", func(t *testing.T) { - t.Parallel() - - var dismissedID string - policies := &mockPolicies{ - dismissFunc: func(ctx context.Context, id string) error { - dismissedID = id - return nil - }, - } - - handler := newPolicyHandler(policies, logtest.NewScope(t)) - - entry := &db.CrudEntry{ - Table: "log_event_policies", - RowID: "policy-789", - Op: db.OpPatch, - Data: map[string]any{ - "dismissed_at": "2024-01-15T10:00:00Z", - "dismissed_by": "user-456", - }, - } - - err := handler.Handle(context.Background(), entry, nil) - if err != nil { - t.Fatalf("Handle() error = %v", err) - } - - if dismissedID != "policy-789" { - t.Errorf("Dismiss called with id = %q, want %q", dismissedID, "policy-789") - } - }) - t.Run("PATCH returns error on Approve failure", func(t *testing.T) { t.Parallel() @@ -149,7 +108,7 @@ func TestPolicyHandler_Handle(t *testing.T) { } }) - t.Run("ignores PATCH without approve/dismiss data", func(t *testing.T) { + t.Run("ignores PATCH without approve data", func(t *testing.T) { t.Parallel() policies := &mockPolicies{ @@ -157,10 +116,6 @@ func TestPolicyHandler_Handle(t *testing.T) { t.Error("Approve should not be called") return nil }, - dismissFunc: func(ctx context.Context, id string) error { - t.Error("Dismiss should not be called") - return nil - }, } handler := newPolicyHandler(policies, logtest.NewScope(t)) From ec416a50bcd5b5aa1e231a28fefff467f27cdd49 Mon Sep 17 00:00:00 2001 From: Divya Date: Thu, 12 Feb 2026 14:58:00 -0500 Subject: [PATCH 07/13] feat: enable workflow for approve flow that is a separate view from the main chat --- internal/app/app.go | 88 ++++++++-- .../round/turn/assistant/assistant.go | 3 + .../tools/policyapprove/policyapprove.go | 165 ++++++++++++++++++ .../startpolicyapproval.go | 144 +++++++++++++++ internal/app/chat/msgs/tools.go | 19 ++ internal/app/policyapproval/msgs/msgs.go | 31 ++++ internal/app/policyapproval/policyapproval.go | 122 +++++++++++++ internal/app/policyapproval/select/select.go | 102 +++++++++++ internal/app/policyapproval/step.go | 25 +++ internal/chat/tools/policy_approve.go | 73 ++++++++ internal/chat/tools/registry.go | 12 +- internal/chat/tools/start_policy_approval.go | 51 ++++++ internal/domain/tools/policy.go | 13 ++ .../domain/tools/start_policy_approval.go | 11 ++ internal/domain/tools/tools.go | 15 +- 15 files changed, 847 insertions(+), 27 deletions(-) create mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go create mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go create mode 100644 internal/app/policyapproval/msgs/msgs.go create mode 100644 internal/app/policyapproval/policyapproval.go create mode 100644 internal/app/policyapproval/select/select.go create mode 100644 internal/app/policyapproval/step.go create mode 100644 internal/chat/tools/policy_approve.go create mode 100644 internal/chat/tools/start_policy_approval.go create mode 100644 internal/domain/tools/policy.go create mode 100644 internal/domain/tools/start_policy_approval.go diff --git a/internal/app/app.go b/internal/app/app.go index ed51494e..2c446c23 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,6 +19,8 @@ import ( "github.com/usetero/cli/internal/app/onboarding" onboardingmsg "github.com/usetero/cli/internal/app/onboarding/msgs" "github.com/usetero/cli/internal/app/palette" + "github.com/usetero/cli/internal/app/policyapproval" + policyapprovalmsg "github.com/usetero/cli/internal/app/policyapproval/msgs" "github.com/usetero/cli/internal/app/statusbar" "github.com/usetero/cli/internal/app/toast" "github.com/usetero/cli/internal/auth" @@ -44,6 +46,7 @@ type state int const ( stateOnboarding state = iota stateChat + statePolicyApproval ) // Layout constants. @@ -84,14 +87,15 @@ type Model struct { workspace domain.Workspace // Components - statusBar *statusbar.Model - toast *toast.Model - keyBar *keybar.Model - onboarding *onboarding.Model - chat *chat.Model - quitDlg *quitDialog - palette *palette.Model - state state + statusBar *statusbar.Model + toast *toast.Model + keyBar *keybar.Model + onboarding *onboarding.Model + policyApproval *policyapproval.Model + chat *chat.Model + quitDlg *quitDialog + palette *palette.Model + state state // Dimensions width int @@ -222,6 +226,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } + if key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+p"))) { + m.scope.Info("DEBUG: triggering policy approval") + return m, func() tea.Msg { + return policyapprovalmsg.Start{ + ToolUseID: "debug-test", + Policies: []policyapprovalmsg.Policy{{ID: "test-1", ServiceName: "Test Service"}}, + } + } + } + // When quit dialog is open, forward keys to it and consume if m.quitDlg != nil { return m, m.quitDlg.Update(msg) @@ -291,15 +305,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create tool registry with executors m.toolRegistry = &chattools.Registry{ - Query: chattools.NewQueryTool(m.db), - PolicyApprove: chattools.NewPolicyApproveTool(m.db.LogEventPolicies(), func() string { - if m.user == nil { - return "" - } - return m.user.ID - }), - StartJourney: chattools.NewStartJourneyTool(), - EndJourney: chattools.NewEndJourneyTool(), + Query: chattools.NewQueryTool(m.db), + // PolicyApprove: chattools.NewPolicyApproveTool(m.db.LogEventPolicies(), func() string { + // if m.user == nil { + // return "" + // } + // return m.user.ID + // }), + StartPolicyApproval: chattools.NewStartPolicyApprovalTool(), + StartJourney: chattools.NewStartJourneyTool(), + EndJourney: chattools.NewEndJourneyTool(), } // Create chat client with tool definitions @@ -335,6 +350,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.chat.Init() + case policyapprovalmsg.Start: + m.state = statePolicyApproval + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, msg.ToolUseID, m.scope) + // m.policyApproval.SetPolicies(msg.Policies) + m.updateLayout() + return m, m.policyApproval.Init() + + case policyapprovalmsg.PolicyApprovalComplete: + m.scope.Info("policy approval complete", + "msg", "TODO: add details from message", + ) + + m.state = stateChat + m.policyApproval = nil + + // Create chat model (sizing happens via updateLayout) + m.chat = m.newChat() + + // Size the new chat component + m.updateLayout() + + return m, m.chat.Init() + case msgs.StreamCompleted: if msg.Title != "" && m.db != nil { m.statusBar.SetTitle(msg.Title) @@ -381,6 +419,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.chat != nil { cmds = append(cmds, m.chat.Update(msg)) } + case statePolicyApproval: + if m.policyApproval != nil { + cmds = append(cmds, m.policyApproval.Update(msg)) + } } // Update keybar after page updates (bindings may have changed) @@ -411,6 +453,10 @@ func (m *Model) updateLayout() { if m.onboarding != nil { m.onboarding.SetSize(contentWidth, pageHeight) } + case statePolicyApproval: + if m.policyApproval != nil { + m.policyApproval.SetSize(contentWidth, pageHeight) + } case stateChat: if m.chat != nil { m.chat.SetSize(contentWidth, pageHeight) @@ -442,6 +488,10 @@ func (m *Model) updateKeyBar() { if m.onboarding != nil { bindings = m.onboarding.ShortHelp() } + case statePolicyApproval: + if m.policyApproval != nil { + bindings = m.policyApproval.ShortHelp() + } case stateChat: if m.chat != nil { bindings = m.chat.ShortHelp() @@ -578,6 +628,10 @@ func (m *Model) renderContent() string { switch m.state { case stateOnboarding: pageView = m.onboarding.View() + case statePolicyApproval: + if m.policyApproval != nil { + pageView = m.policyApproval.View() + } case stateChat: if m.chat != nil { pageView = m.chat.View() diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go index 7fd08c52..52bf1597 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant.go @@ -7,6 +7,7 @@ import ( "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/policyapprove" "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/startpolicyapproval" "github.com/usetero/cli/internal/app/chat/msgs" chattools "github.com/usetero/cli/internal/chat/tools" "github.com/usetero/cli/internal/domain" @@ -156,6 +157,8 @@ func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *too child = query.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.Query, m.scope) case m.toolRegistry.PolicyApprove.Name(): child = policyapprove.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.PolicyApprove, m.scope) + case m.toolRegistry.StartPolicyApproval.Name(): + child = startpolicyapproval.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.StartPolicyApproval, m.scope) default: child = query.New(m.blockTheme, index, toolUse.ID, width, nil, m.scope) } diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go new file mode 100644 index 00000000..831fd3d9 --- /dev/null +++ b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go @@ -0,0 +1,165 @@ +package policyapprove + +import ( + "encoding/json" + "fmt" + + tea "charm.land/bubbletea/v2" + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" + "github.com/usetero/cli/internal/app/chat/msgs" + chattools "github.com/usetero/cli/internal/chat/tools" + "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" +) + +// Model handles policy approve tool execution. +type Model struct { + theme styles.Theme + scope log.Scope + index int + toolID string + state tools.State + executor *chattools.PolicyApproveTool + width int + + // Input + input string + policyID string + + // Result + approved bool + err error +} + +// New creates a new policy approve tool model. +func New(theme styles.Theme, index int, toolID string, width int, executor *chattools.PolicyApproveTool, scope log.Scope) *Model { + return &Model{ + theme: theme, + scope: scope.Child("policyapprove"), + index: index, + 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) + } + return nil +} + +// handleContent finds this tool's data 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 +} + +func (m *Model) execute() tea.Cmd { + m.state = tools.StateExecuting + + // Parse input + var in domaintools.PolicyApproveInput + if err := json.Unmarshal([]byte(m.input), &in); err == nil { + m.policyID = in.PolicyID + } + + m.scope.Info("approving policy", "policy_id", m.policyID) + + if m.executor == nil { + m.err = fmt.Errorf("no executor") + m.state = tools.StateComplete + m.scope.Error("policy approve failed", "error", m.err) + return m.fireCompleted() + } + + result, err := m.executor.Execute(json.RawMessage(m.input)) + if err != nil { + m.err = err + m.state = tools.StateComplete + m.scope.Error("policy approve failed", "error", err) + return m.fireCompleted() + } + + m.approved = result.Approved + m.state = tools.StateComplete + m.scope.Info("policy approved", "policy_id", m.policyID) + return m.fireCompleted() +} + +func (m *Model) fireCompleted() tea.Cmd { + return func() tea.Msg { + return msgs.PolicyApproveCompleted{ + ToolUseID: m.toolID, + PolicyID: m.policyID, + Approved: m.approved, + Error: m.err, + } + } +} + +// Name returns the tool's display name. +func (m *Model) Name() string { + return "Approve Policy" +} + +// Status returns the status message shown while executing. +func (m *Model) Status() string { + if m.policyID != "" { + return "Approving policy " + m.policyID + } + return "Approving policy" +} + +// Result returns the result message. +func (m *Model) Result() string { + if m.approved { + return "Policy approved" + } + return "Policy approval failed" +} + +// View renders content (empty for this tool). +func (m *Model) View() string { + return "" +} + +// SetWidth sets the width. +func (m *Model) SetWidth(width int) { + m.width = width +} + +// 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/startpolicyapproval/startpolicyapproval.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go new file mode 100644 index 00000000..78aee3da --- /dev/null +++ b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/startpolicyapproval/startpolicyapproval.go @@ -0,0 +1,144 @@ +package startpolicyapproval + +import ( + tea "charm.land/bubbletea/v2" + "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" + "github.com/usetero/cli/internal/app/chat/msgs" + policyapprovalmsg "github.com/usetero/cli/internal/app/policyapproval/msgs" + chattools "github.com/usetero/cli/internal/chat/tools" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles start_policy_approval tool execution. +// Unlike other tools, this emits a message to open the wizard UI. +type Model struct { + theme styles.Theme + scope log.Scope + index int + toolID string + state tools.State + executor *chattools.StartPolicyApprovalTool + width int + + // Result + started bool + err error +} + +// New creates a new start policy approval tool model. +func New(theme styles.Theme, index int, toolID string, width int, executor *chattools.StartPolicyApprovalTool, scope log.Scope) *Model { + return &Model{ + theme: theme, + scope: scope.Child("startpolicyapproval"), + index: index, + 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) + } + return nil +} + +// handleContent finds this tool's data and triggers execution. +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 { + if b.ToolUse.InputComplete { + return m.execute() + } + return nil + } + } + return nil +} + +func (m *Model) execute() tea.Cmd { + m.state = tools.StateExecuting + m.scope.Info("starting policy approval wizard") + + // Emit message to open the wizard UI + // The wizard will query the DB for pending policies + m.started = true + m.state = tools.StateComplete + + return tea.Batch( + // Open the wizard + func() tea.Msg { + return policyapprovalmsg.Start{ + ToolUseID: m.toolID, + Policies: nil, // Wizard will query DB + } + }, + // Signal tool completion for chat flow + m.fireCompleted(), + ) +} + +func (m *Model) fireCompleted() tea.Cmd { + return func() tea.Msg { + return msgs.StartPolicyApprovalCompleted{ + ToolUseID: m.toolID, + Started: m.started, + Error: m.err, + } + } +} + +// Name returns the tool's display name. +func (m *Model) Name() string { + return "Policy Approval" +} + +// Status returns the status message shown while executing. +func (m *Model) Status() string { + return "Opening policy approval wizard" +} + +// Result returns the result message. +func (m *Model) Result() string { + if m.started { + return "Wizard opened" + } + return "Failed to open wizard" +} + +// View renders content (empty for this tool). +func (m *Model) View() string { + return "" +} + +// SetWidth sets the width. +func (m *Model) SetWidth(width int) { + m.width = width +} + +// 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/msgs/tools.go b/internal/app/chat/msgs/tools.go index 240c0e2f..b4fec043 100644 --- a/internal/app/chat/msgs/tools.go +++ b/internal/app/chat/msgs/tools.go @@ -86,6 +86,24 @@ func (m PolicyApproveCompleted) GetResult() domaintools.Result { } func (m PolicyApproveCompleted) toolCompleted() {} +// StartPolicyApprovalCompleted is fired when the policy approval wizard is triggered. +type StartPolicyApprovalCompleted struct { + ToolUseID string + Started bool + Error error +} + +func (m StartPolicyApprovalCompleted) GetToolUseID() string { return m.ToolUseID } +func (m StartPolicyApprovalCompleted) GetError() error { return m.Error } +func (m StartPolicyApprovalCompleted) GetResult() domaintools.Result { + return domaintools.Result{ + ToolUseID: m.ToolUseID, + StartPolicyApproval: &domaintools.StartPolicyApprovalResult{Started: m.Started}, + Error: errorResultFromErr(m.Error), + } +} +func (m StartPolicyApprovalCompleted) toolCompleted() {} + func errorResultFromErr(err error) *domaintools.ErrorResult { if err == nil { return nil @@ -99,4 +117,5 @@ var ( _ ToolCompleted = StartJourneyCompleted{} _ ToolCompleted = EndJourneyCompleted{} _ ToolCompleted = PolicyApproveCompleted{} + _ ToolCompleted = StartPolicyApprovalCompleted{} ) diff --git a/internal/app/policyapproval/msgs/msgs.go b/internal/app/policyapproval/msgs/msgs.go new file mode 100644 index 00000000..2f011efa --- /dev/null +++ b/internal/app/policyapproval/msgs/msgs.go @@ -0,0 +1,31 @@ +package msgs + +type Policy struct { + ID string + ServiceName string + Category string + EstimatedVolumePerHour float64 + EstimatedCostPerHour float64 +} + +type Start struct { + ToolUseID string + Policies []Policy +} + +type PoliciesSelected struct { + PolicyIDs []string +} + +type Confirmed struct{} + +type Cancelled struct { + ToolUseID string +} + +type PolicyApprovalComplete struct { + ToolUseID string + ApprovedCount int + FailedCount int + TotalSavings float64 +} diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go new file mode 100644 index 00000000..2de70b4f --- /dev/null +++ b/internal/app/policyapproval/policyapproval.go @@ -0,0 +1,122 @@ +// Package policyapproval provides a wizard for bulk policy approval. +package policyapproval + +import ( + "context" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + policyselect "github.com/usetero/cli/internal/app/policyapproval/select" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" +) + +// Model is the policy approval wizard orchestrator. +type Model struct { + // Dependencies + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + + // Input from tool + toolUseID string + + // Accumulated state from step completions + selectedIDs []string + approvedCount int + failedCount int + + // Current step + step Step + width int + height int +} + +// New creates a new policy approval wizard. +func New( + ctx context.Context, + theme styles.Theme, + db sqlite.DB, + toolUseID string, + scope log.Scope, +) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + toolUseID: toolUseID, + scope: scope.Child("policyapproval"), + } +} + +// Init starts the wizard with the select step. +func (m *Model) Init() tea.Cmd { + m.scope.Info("policy approval wizard started") + // TODO: return m.setStep(select.New(...)) + return m.setStep(policyselect.New(m.ctx, m.theme, nil, m.scope)) +} + +// Update handles messages and orchestrates step transitions. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.step != nil { + m.step.SetSize(m.width, m.height) + } + return nil + + // TODO: Handle step completion messages + // case msgs.PoliciesSelected: + // case msgs.ApprovalConfirmed: + // case msgs.ExecutionComplete: + } + + // Delegate to current step + if m.step != nil { + return m.step.Update(msg) + } + return nil +} + +// setStep sets the current step and initializes it. +func (m *Model) setStep(step Step) tea.Cmd { + m.step = step + m.step.SetSize(m.width, m.height) + return m.step.Init() +} + +// View renders the current step. +func (m *Model) View() string { + if m.step == nil { + return "" + } + + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + AlignVertical(lipgloss.Bottom). + Render(m.step.View()) +} + +// SetSize updates the model's dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height + if m.step != nil { + m.step.SetSize(width, height) + } +} + +// ShortHelp returns the key bindings for the short help view. +func (m *Model) ShortHelp() []key.Binding { + if m.step != nil { + return m.step.ShortHelp() + } + return nil +} diff --git a/internal/app/policyapproval/select/select.go b/internal/app/policyapproval/select/select.go new file mode 100644 index 00000000..edfa8fcd --- /dev/null +++ b/internal/app/policyapproval/select/select.go @@ -0,0 +1,102 @@ +// Package select provides the policy selection step. +package policyselect + +import ( + "context" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles policy selection with multiselect. +type Model struct { + ctx context.Context + theme styles.Theme + scope log.Scope + policies []msgs.Policy + selected map[string]bool // policy ID -> selected + cursor int + width int + height int +} + +// New creates a new policy selection step. +func New(ctx context.Context, theme styles.Theme, policies []msgs.Policy, scope log.Scope) *Model { + if ctx == nil { + panic("ctx is nil") + } + return &Model{ + ctx: ctx, + theme: theme, + scope: scope.Child("select"), + policies: policies, + selected: make(map[string]bool), + } +} + +// Init returns the initial command. +func (m *Model) Init() tea.Cmd { + m.scope.Info("select step started", "policy_count", len(m.policies)) + return nil +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // TODO: handle keys + // space - toggle current + // a - select all + // n - select none + // j/down - cursor down + // k/up - cursor up + // enter - confirm selection + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + return m.confirm() + } + } + return nil +} + +// confirm emits PoliciesSelected with the selected IDs. +func (m *Model) confirm() tea.Cmd { + var ids []string + for id, selected := range m.selected { + if selected { + ids = append(ids, id) + } + } + m.scope.Info("policies selected", "count", len(ids)) + return func() tea.Msg { + return msgs.PoliciesSelected{PolicyIDs: ids} + } +} + +// View renders the step. +func (m *Model) View() string { + // TODO: render policy list with checkboxes + return lipgloss.NewStyle(). + Foreground(m.theme.Text). + Render("Select policies to approve (TODO: render list)") +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + } +} diff --git a/internal/app/policyapproval/step.go b/internal/app/policyapproval/step.go new file mode 100644 index 00000000..23edb0b1 --- /dev/null +++ b/internal/app/policyapproval/step.go @@ -0,0 +1,25 @@ +package policyapproval + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// Step represents a single step in the onboarding flow. +type Step interface { + // Init returns the initial command for the step. + Init() tea.Cmd + + // Update handles a message and returns a command. + // Steps emit completion messages (from msgs package) when done. + Update(msg tea.Msg) tea.Cmd + + // View renders the step. + View() string + + // SetSize updates the step's dimensions. + SetSize(width, height int) + + // ShortHelp returns the key bindings for the short help view. + ShortHelp() []key.Binding +} diff --git a/internal/chat/tools/policy_approve.go b/internal/chat/tools/policy_approve.go new file mode 100644 index 00000000..479975f3 --- /dev/null +++ b/internal/chat/tools/policy_approve.go @@ -0,0 +1,73 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/usetero/cli/internal/chat" + domaintools "github.com/usetero/cli/internal/domain/tools" + "github.com/usetero/cli/internal/sqlite" +) + +// PolicyApproveTool approves log event policies. +type PolicyApproveTool struct { + policies sqlite.LogEventPolicies + getUserID func() string +} + +// NewPolicyApproveTool creates a new policy approve tool. +func NewPolicyApproveTool(policies sqlite.LogEventPolicies, getUserID func() string) *PolicyApproveTool { + return &PolicyApproveTool{policies: policies, getUserID: getUserID} +} + +// Name returns the tool name. +func (t *PolicyApproveTool) Name() string { + return "approve_policy" +} + +// Definition returns the tool definition for the chat API. +func (t *PolicyApproveTool) Definition() chat.Tool { + return chat.Tool{ + Name: t.Name(), + Description: `Approve a log event policy for enforcement. + +When approved, the policy will create exclusion filters in Datadog to reduce log volume. + +Use this when the user wants to: +- Approve a specific policy by ID +- Enable a policy recommendation +- Accept a suggested optimization`, + InputSchema: chat.NewObjectSchema( + map[string]chat.Property{ + "policy_id": { + Type: "string", + Description: "The ID of the policy to approve", + }, + }, + []string{"policy_id"}, + ), + } +} + +// Execute approves the policy and returns the result. +func (t *PolicyApproveTool) Execute(input json.RawMessage) (domaintools.PolicyApproveResult, error) { + var in domaintools.PolicyApproveInput + if err := json.Unmarshal(input, &in); err != nil { + return domaintools.PolicyApproveResult{}, err + } + + userID := t.getUserID() + if userID == "" { + return domaintools.PolicyApproveResult{}, fmt.Errorf("user not authenticated") + } + + ctx := context.Background() + + err := t.policies.Approve(ctx, in.PolicyID, userID) + if err != nil { + return domaintools.PolicyApproveResult{Approved: false}, err + } + + return domaintools.PolicyApproveResult{Approved: true}, nil +} diff --git a/internal/chat/tools/registry.go b/internal/chat/tools/registry.go index f9f8679c..7a0cac95 100644 --- a/internal/chat/tools/registry.go +++ b/internal/chat/tools/registry.go @@ -4,10 +4,11 @@ import "github.com/usetero/cli/internal/chat" // Registry holds tool instances and provides definitions. type Registry struct { - Query *QueryTool - PolicyApprove *PolicyApproveTool - StartJourney *StartJourneyTool - EndJourney *EndJourneyTool + Query *QueryTool + StartPolicyApproval *StartPolicyApprovalTool + PolicyApprove *PolicyApproveTool + StartJourney *StartJourneyTool + EndJourney *EndJourneyTool } // Definitions returns tool definitions for the chat API. @@ -16,6 +17,9 @@ func (r *Registry) Definitions() []chat.Tool { if r.Query != nil { defs = append(defs, r.Query.Definition()) } + if r.StartPolicyApproval != nil { + defs = append(defs, r.StartPolicyApproval.Definition()) + } if r.PolicyApprove != nil { defs = append(defs, r.PolicyApprove.Definition()) } diff --git a/internal/chat/tools/start_policy_approval.go b/internal/chat/tools/start_policy_approval.go new file mode 100644 index 00000000..c08d3cc0 --- /dev/null +++ b/internal/chat/tools/start_policy_approval.go @@ -0,0 +1,51 @@ +package tools + +import ( + "encoding/json" + + "github.com/usetero/cli/internal/chat" + "github.com/usetero/cli/internal/domain/tools" +) + +// StartPolicyApprovalTool triggers the interactive policy approval wizard. +// Unlike other tools, this doesn't execute immediately - the UI block +// emits a message to open the wizard, then waits for completion. +type StartPolicyApprovalTool struct{} + +// NewStartPolicyApprovalTool creates a new start_policy_approval tool. +func NewStartPolicyApprovalTool() *StartPolicyApprovalTool { + return &StartPolicyApprovalTool{} +} + +// Name returns the tool name. +func (t *StartPolicyApprovalTool) Name() string { + return "start_policy_approval" +} + +// Definition returns the tool definition for the chat API. +func (t *StartPolicyApprovalTool) Definition() chat.Tool { + return chat.Tool{ + Name: t.Name(), + Description: `Start an interactive policy approval flow. + +This opens a wizard where the user can: +- Review all pending log event policies +- Select which policies to approve +- Confirm the approval with a summary of impact + +Use this when the user wants to: +- Review and approve pending policies +- Bulk approve multiple policies +- See what policies are available for approval + +The tool returns after the user completes or cancels the wizard.`, + InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), + } +} + +// Execute is a no-op for this tool. The actual execution happens in the UI block +// which emits StartPolicyApprovalMsg to trigger the wizard. +func (t *StartPolicyApprovalTool) Execute(input json.RawMessage) (tools.StartPolicyApprovalResult, error) { + // This is never called directly - the UI block handles everything + return tools.StartPolicyApprovalResult{}, nil +} diff --git a/internal/domain/tools/policy.go b/internal/domain/tools/policy.go new file mode 100644 index 00000000..f20fe31d --- /dev/null +++ b/internal/domain/tools/policy.go @@ -0,0 +1,13 @@ +package tools + +type PolicyApproveInput struct { + PolicyID string `json:"policy_id"` +} + +type PolicyApproveResult struct { + Approved bool `json:"approved"` +} + +func (r PolicyApproveResult) ToMap() map[string]any { + return map[string]any{"approved": r.Approved} +} diff --git a/internal/domain/tools/start_policy_approval.go b/internal/domain/tools/start_policy_approval.go new file mode 100644 index 00000000..caa0a2a1 --- /dev/null +++ b/internal/domain/tools/start_policy_approval.go @@ -0,0 +1,11 @@ +package tools + +// StartPolicyApprovalResult holds the result of starting a policy approval wizard. +type StartPolicyApprovalResult struct { + Started bool +} + +// ToMap serializes the result for the GraphQL API. +func (r StartPolicyApprovalResult) ToMap() map[string]any { + return map[string]any{"started": r.Started} +} diff --git a/internal/domain/tools/tools.go b/internal/domain/tools/tools.go index a82ad6a2..501cbf23 100644 --- a/internal/domain/tools/tools.go +++ b/internal/domain/tools/tools.go @@ -11,12 +11,13 @@ type Tool interface { // Result holds a typed tool result. Exactly one field is set. type Result struct { - ToolUseID string - Query *QueryResult - PolicyApprove *PolicyApproveResult - StartJourney *StartJourneyResult - EndJourney *EndJourneyResult - Error *ErrorResult + ToolUseID string + Query *QueryResult + StartPolicyApproval *StartPolicyApprovalResult + PolicyApprove *PolicyApproveResult + StartJourney *StartJourneyResult + EndJourney *EndJourneyResult + Error *ErrorResult } // ToMap serializes the result for the GraphQL API. @@ -24,6 +25,8 @@ func (r Result) ToMap() map[string]any { switch { case r.Query != nil: return r.Query.ToMap() + case r.StartPolicyApproval != nil: + return r.StartPolicyApproval.ToMap() case r.StartJourney != nil: return r.StartJourney.ToMap() case r.EndJourney != nil: From 4631ce9a7a08e54a3a2e79adcc076e08ec27d042 Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 12:27:42 -0500 Subject: [PATCH 08/13] fix: table select; now approving a policy opens up a list category view --- internal/app/app.go | 14 + .../categorysummary/categorysummary.go | 247 ++++++++++++++++++ internal/app/policyapproval/msgs/msgs.go | 8 + internal/app/policyapproval/policyapproval.go | 7 +- internal/chat/tools/start_policy_approval.go | 2 + .../tea/components/tableselect/tableselect.go | 104 ++++++++ 6 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 internal/app/policyapproval/categorysummary/categorysummary.go create mode 100644 internal/tea/components/tableselect/tableselect.go diff --git a/internal/app/app.go b/internal/app/app.go index 2c446c23..75bf3ce4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -251,6 +251,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusBar.CloseDrawer() return m, nil } + // Esc in policy approval returns to chat + if m.state == statePolicyApproval { + m.scope.Info("policy approval cancelled") + m.state = stateChat + m.policyApproval = nil + return m, nil + } // Esc cancels the active round first; only show dialog if nothing to cancel if m.chat != nil && m.chat.CancelActiveRound() { return m, nil @@ -279,6 +286,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitDlg = newQuitDialog(m.theme) return m, nil } + // Intercept "approve", "approval", etc. to open wizard directly + if strings.HasPrefix(strings.ToLower(text), "approv") { + m.state = statePolicyApproval + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, "", m.scope) + m.updateLayout() + return m, m.policyApproval.Init() + } case onboardingmsg.OrgSelected: return m, m.activateOrg(msg.Org.ID, msg) diff --git a/internal/app/policyapproval/categorysummary/categorysummary.go b/internal/app/policyapproval/categorysummary/categorysummary.go new file mode 100644 index 00000000..6176cb8a --- /dev/null +++ b/internal/app/policyapproval/categorysummary/categorysummary.go @@ -0,0 +1,247 @@ +// Package categorysummary provides the category summary step for the policy approval wizard. +package categorysummary + +import ( + "context" + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "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/tableselect" +) + +// Model handles the category summary view — the first step of the approval wizard. +// Shows policy categories grouped from log_event_policy_statuses_cache, sorted by +// pending count. User can drill into a category or approve all low-risk at once. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + categories []domain.PolicyCategoryStatus + tbl *tableselect.Model + width int + height int + err error +} + +// New creates a new category summary step. +func New(ctx context.Context, theme styles.Theme, db sqlite.DB, scope log.Scope) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + scope: scope.Child("categorysummary"), + } +} + +// categoriesLoadedMsg is sent when categories are loaded from the database. +type categoriesLoadedMsg struct { + categories []domain.PolicyCategoryStatus + err error +} + +// Init loads category data from the local SQLite database. +func (m *Model) Init() tea.Cmd { + m.scope.Info("category summary step started") + return m.loadCategories() +} + +func (m *Model) loadCategories() tea.Cmd { + return func() tea.Msg { + categories, err := m.db.LogEventPolicies().ListCategoryStatuses(m.ctx) + return categoriesLoadedMsg{categories: categories, err: err} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case categoriesLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.scope.Error("failed to load categories", "error", msg.err) + return nil + } + // Filter to categories with pending policies only. + var pending []domain.PolicyCategoryStatus + for _, c := range msg.categories { + if c.PendingCount > 0 { + pending = append(pending, c) + } + } + m.categories = pending + m.scope.Info("categories loaded", "count", len(m.categories)) + + if len(m.categories) == 0 { + // Nothing to approve — cancel the wizard. + return func() tea.Msg { return msgs.Cancelled{} } + } + + m.buildTable() + return nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keyEnter): + if m.tbl != nil && len(m.categories) > 0 { + selected := m.categories[m.tbl.Cursor()] + m.scope.Info("category selected", "category", selected.Category) + return func() tea.Msg { + return msgs.CategorySelected{Category: selected.Category} + } + } + case key.Matches(msg, keyApproveAllLowRisk): + return m.approveAllLowRisk() + case key.Matches(msg, keyEscape): + return func() tea.Msg { return msgs.Cancelled{} } + default: + // Delegate navigation to the table. + if m.tbl != nil { + return m.tbl.Update(msg) + } + } + } + return nil +} + +// buildTable creates the selectable table from loaded categories. +func (m *Model) buildTable() { + cols := []tableselect.Column{ + {Title: "Category", Width: 25}, + {Title: "Risk", Width: 8}, + {Title: "Pending", Width: 8}, + {Title: "Savings", Width: 12}, + } + + rows := make([]tableselect.Row, len(m.categories)) + for i, c := range m.categories { + rows[i] = tableselect.Row{ + c.Category, + c.RiskLevel.String(), + fmt.Sprintf("%d", c.PendingCount), + formatSavings(c), + } + } + + m.tbl = tableselect.New(m.theme, cols, rows) +} + +// approveAllLowRisk collects all policy IDs from low-risk categories. +func (m *Model) approveAllLowRisk() tea.Cmd { + var lowRiskCategories []string + for _, c := range m.categories { + if c.RiskLevel == domain.RiskLevelLow && c.PendingCount > 0 { + lowRiskCategories = append(lowRiskCategories, c.Category) + } + } + if len(lowRiskCategories) == 0 { + return nil + } + m.scope.Info("approve all low-risk", "categories", len(lowRiskCategories)) + return func() tea.Msg { + return msgs.ApproveAllLowRisk{Categories: lowRiskCategories} + } +} + +// View renders the category summary table. +func (m *Model) View() string { + if m.err != nil { + errStyle := lipgloss.NewStyle().Foreground(m.theme.ErrorBg) + return errStyle.Render(fmt.Sprintf("Error loading policies: %v", m.err)) + } + + if m.tbl == nil { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("Loading policies...") + } + + if len(m.categories) == 0 { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("No pending policies.") + } + + var lines []string + + // Header. + header := lipgloss.NewStyle().Foreground(m.theme.Text).Bold(true) + lines = append(lines, header.Render("Review policy categories")) + lines = append(lines, "") + + // Category table. + lines = append(lines, m.tbl.View()) + lines = append(lines, "") + + // Footer: totals. + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + totalPending, totalCost := m.totals() + footer := fmt.Sprintf("Total: %d pending", totalPending) + if totalCost > 0 { + footer += fmt.Sprintf(" · Est. savings: ~%s/yr", formatCost(totalCost*8760)) + } + lines = append(lines, muted.Render(footer)) + + return strings.Join(lines, "\n") +} + +// totals returns aggregate pending count and hourly cost across all categories. +func (m *Model) totals() (int64, float64) { + var pending int64 + var cost float64 + for _, c := range m.categories { + pending += c.PendingCount + cost += c.EstimatedCostPerHour + } + return pending, cost +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyEnter, keyApproveAllLowRisk, keyEscape} +} + +// Key bindings. +var ( + keyEnter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")) + keyApproveAllLowRisk = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "approve all low-risk")) + keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) +) + +// formatSavings returns estimated yearly cost savings for a category. +func formatSavings(c domain.PolicyCategoryStatus) string { + if c.EstimatedCostPerHour > 0 { + yearly := c.EstimatedCostPerHour * 8760 + if yearly >= 1 { + return "~" + formatCost(yearly) + "/yr" + } + } + return "—" +} + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} diff --git a/internal/app/policyapproval/msgs/msgs.go b/internal/app/policyapproval/msgs/msgs.go index 2f011efa..9b743c55 100644 --- a/internal/app/policyapproval/msgs/msgs.go +++ b/internal/app/policyapproval/msgs/msgs.go @@ -19,6 +19,14 @@ type PoliciesSelected struct { type Confirmed struct{} +type CategorySelected struct { + Category string +} + +type ApproveAllLowRisk struct { + Categories []string +} + type Cancelled struct { ToolUseID string } diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go index 2de70b4f..a8b85171 100644 --- a/internal/app/policyapproval/policyapproval.go +++ b/internal/app/policyapproval/policyapproval.go @@ -8,7 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - policyselect "github.com/usetero/cli/internal/app/policyapproval/select" + "github.com/usetero/cli/internal/app/policyapproval/categorysummary" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" @@ -53,11 +53,10 @@ func New( } } -// Init starts the wizard with the select step. +// Init starts the wizard with the category summary step. func (m *Model) Init() tea.Cmd { m.scope.Info("policy approval wizard started") - // TODO: return m.setStep(select.New(...)) - return m.setStep(policyselect.New(m.ctx, m.theme, nil, m.scope)) + return m.setStep(categorysummary.New(m.ctx, m.theme, m.db, m.scope)) } // Update handles messages and orchestrates step transitions. diff --git a/internal/chat/tools/start_policy_approval.go b/internal/chat/tools/start_policy_approval.go index c08d3cc0..46466425 100644 --- a/internal/chat/tools/start_policy_approval.go +++ b/internal/chat/tools/start_policy_approval.go @@ -38,6 +38,8 @@ Use this when the user wants to: - Bulk approve multiple policies - See what policies are available for approval +IMPORTANT: Call this tool immediately without any preceding text. Do not explain what you're about to do - just call the tool directly. + The tool returns after the user completes or cancels the wizard.`, InputSchema: chat.NewObjectSchema(map[string]chat.Property{}, nil), } diff --git a/internal/tea/components/tableselect/tableselect.go b/internal/tea/components/tableselect/tableselect.go new file mode 100644 index 00000000..0729251b --- /dev/null +++ b/internal/tea/components/tableselect/tableselect.go @@ -0,0 +1,104 @@ +// Package tableselect wraps bubbles/table with theme-aware selection styling. +package tableselect + +import ( + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/styles" +) + +// Re-export types for convenience. +type ( + Column = table.Column + Row = table.Row +) + +// Model wraps bubbles/table with themed selection highlight. +type Model struct { + theme styles.Theme + table table.Model +} + +// New creates a new selectable table with themed styles. +func New(theme styles.Theme, cols []Column, rows []Row) *Model { + s := table.DefaultStyles() + s.Header = lipgloss.NewStyle(). + Foreground(theme.Text). + Bold(true). + Padding(0, 1) + s.Cell = lipgloss.NewStyle(). + Padding(0, 1) + // Auto-size width from column definitions + cell padding. + w := 0 + for _, col := range cols { + w += col.Width + 2 // +2 for Padding(0, 1) + } + + s.Selected = lipgloss.NewStyle(). + Background(theme.SelectionBg). + Foreground(theme.SelectionFg). + Bold(true). + Width(w) + + t := table.New( + table.WithColumns(cols), + table.WithRows(rows), + table.WithStyles(s), + table.WithFocused(true), + table.WithWidth(w), + table.WithHeight(len(rows)+1), + ) + + return &Model{ + theme: theme, + table: t, + } +} + +// Update handles key events for table navigation. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return cmd +} + +// View renders the table with a themed border. +func (m *Model) View() string { + border := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.Border) + return border.Render(m.table.View()) +} + +// Cursor returns the index of the selected row. +func (m *Model) Cursor() int { + return m.table.Cursor() +} + +// SelectedRow returns the selected row data. +func (m *Model) SelectedRow() Row { + return m.table.SelectedRow() +} + +// SetRows replaces the table rows. +func (m *Model) SetRows(rows []Row) { + m.table.SetRows(rows) +} + +// SetHeight sets the table height. +func (m *Model) SetHeight(h int) { + m.table.SetHeight(h) +} + +// SetWidth sets the table width. +func (m *Model) SetWidth(w int) { + m.table.SetWidth(w) +} + +// ShortHelp returns key bindings for table navigation. +func (m *Model) ShortHelp() []key.Binding { + return m.table.KeyMap.ShortHelp() +} From 03c09d99f68b9a5a4a07ccb12c37e9934364d7b3 Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 12:47:56 -0500 Subject: [PATCH 09/13] feat: add policy details view so you can dig into a category --- .../categorydetail/categorydetail.go | 274 ++++++++++++++++++ internal/app/policyapproval/msgs/msgs.go | 2 + internal/app/policyapproval/policyapproval.go | 13 +- internal/domain/policy_detail.go | 11 + internal/sqlite/gen/log_event_policies.sql.go | 53 ++++ internal/sqlite/gen/querier.go | 1 + internal/sqlite/log_event_policies.go | 29 ++ .../sqlite/queries/log_event_policies.sql | 13 + 8 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 internal/app/policyapproval/categorydetail/categorydetail.go create mode 100644 internal/domain/policy_detail.go diff --git a/internal/app/policyapproval/categorydetail/categorydetail.go b/internal/app/policyapproval/categorydetail/categorydetail.go new file mode 100644 index 00000000..d2fdafb7 --- /dev/null +++ b/internal/app/policyapproval/categorydetail/categorydetail.go @@ -0,0 +1,274 @@ +// Package categorydetail provides the category detail step for the policy approval wizard. +package categorydetail + +import ( + "context" + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "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/tableselect" +) + +// Model handles the category detail view — the second step of the approval wizard. +// Shows individual pending policies within a selected category. User can toggle +// selection with space, select all/none, then press enter to proceed. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + category string + policies []domain.PolicyDetail + selected map[string]bool + tbl *tableselect.Model + width int + height int + err error +} + +// New creates a new category detail step. +func New(ctx context.Context, theme styles.Theme, db sqlite.DB, category string, scope log.Scope) *Model { + return &Model{ + ctx: ctx, + theme: theme, + db: db, + category: category, + scope: scope.Child("categorydetail"), + selected: make(map[string]bool), + } +} + +// policiesLoadedMsg is sent when policies are loaded from the database. +type policiesLoadedMsg struct { + policies []domain.PolicyDetail + err error +} + +// Init loads policies for the selected category. +func (m *Model) Init() tea.Cmd { + m.scope.Info("category detail step started", "category", m.category) + return m.loadPolicies() +} + +func (m *Model) loadPolicies() tea.Cmd { + return func() tea.Msg { + policies, err := m.db.LogEventPolicies().ListPendingByCategory(m.ctx, m.category) + return policiesLoadedMsg{policies: policies, err: err} + } +} + +// Update handles messages. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case policiesLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.scope.Error("failed to load policies", "error", msg.err) + return nil + } + m.policies = msg.policies + m.scope.Info("policies loaded", "count", len(m.policies)) + + if len(m.policies) == 0 { + return func() tea.Msg { return msgs.BackToSummary{} } + } + + // Select all by default. + for _, p := range m.policies { + m.selected[p.PolicyID] = true + } + m.buildTable() + return nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, keySpace): + if m.tbl != nil && len(m.policies) > 0 { + p := m.policies[m.tbl.Cursor()] + m.selected[p.PolicyID] = !m.selected[p.PolicyID] + m.rebuildRows() + } + case key.Matches(msg, keySelectAll): + for _, p := range m.policies { + m.selected[p.PolicyID] = true + } + m.rebuildRows() + case key.Matches(msg, keySelectNone): + for _, p := range m.policies { + m.selected[p.PolicyID] = false + } + m.rebuildRows() + case key.Matches(msg, keyEnter): + return m.submit() + case key.Matches(msg, keyEscape): + return func() tea.Msg { return msgs.BackToSummary{} } + default: + if m.tbl != nil { + return m.tbl.Update(msg) + } + } + } + return nil +} + +// submit emits PoliciesSelected with the currently selected policy IDs. +func (m *Model) submit() tea.Cmd { + var ids []string + for _, p := range m.policies { + if m.selected[p.PolicyID] { + ids = append(ids, p.PolicyID) + } + } + if len(ids) == 0 { + return nil + } + m.scope.Info("policies selected", "count", len(ids)) + return func() tea.Msg { + return msgs.PoliciesSelected{PolicyIDs: ids} + } +} + +// buildTable creates the selectable table from loaded policies. +func (m *Model) buildTable() { + cols := []tableselect.Column{ + {Title: " ", Width: 3}, + {Title: "Log Event", Width: 30}, + {Title: "Risk", Width: 8}, + {Title: "Savings", Width: 12}, + } + + rows := m.makeRows() + m.tbl = tableselect.New(m.theme, cols, rows) +} + +// rebuildRows updates table rows with current checkbox state. +func (m *Model) rebuildRows() { + if m.tbl != nil { + m.tbl.SetRows(m.makeRows()) + } +} + +// makeRows builds table rows from policies with checkbox state. +func (m *Model) makeRows() []tableselect.Row { + rows := make([]tableselect.Row, len(m.policies)) + for i, p := range m.policies { + check := "☐" + if m.selected[p.PolicyID] { + check = "☑" + } + rows[i] = tableselect.Row{ + check, + truncate(p.LogEventName, 28), + p.RiskLevel.String(), + formatSavings(p.EstimatedCostPerHour), + } + } + return rows +} + +// View renders the category detail table. +func (m *Model) View() string { + if m.err != nil { + errStyle := lipgloss.NewStyle().Foreground(m.theme.ErrorBg) + return errStyle.Render(fmt.Sprintf("Error loading policies: %v", m.err)) + } + + if m.tbl == nil { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("Loading policies...") + } + + if len(m.policies) == 0 { + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + return muted.Render("No pending policies in this category.") + } + + var lines []string + + // Header. + header := lipgloss.NewStyle().Foreground(m.theme.Text).Bold(true) + lines = append(lines, header.Render(m.category)) + lines = append(lines, "") + + // Policy table. + lines = append(lines, m.tbl.View()) + lines = append(lines, "") + + // Footer: selection count. + muted := lipgloss.NewStyle().Foreground(m.theme.TextMuted) + selectedCount := 0 + for _, sel := range m.selected { + if sel { + selectedCount++ + } + } + footer := fmt.Sprintf("%d of %d selected", selectedCount, len(m.policies)) + lines = append(lines, muted.Render(footer)) + + return strings.Join(lines, "\n") +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keySpace, keySelectAll, keySelectNone, keyEnter, keyEscape} +} + +// Key bindings. +var ( + keySpace = key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")) + keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) + keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) + keyEnter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")) + keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")) +) + +// formatSavings returns estimated yearly cost savings. +func formatSavings(costPerHour float64) string { + if costPerHour > 0 { + yearly := costPerHour * 8760 + if yearly >= 1 { + return "~" + formatCost(yearly) + "/yr" + } + } + return "—" +} + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} + +// truncate shortens a string to max length, adding ellipsis if needed. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/internal/app/policyapproval/msgs/msgs.go b/internal/app/policyapproval/msgs/msgs.go index 9b743c55..d6daef6e 100644 --- a/internal/app/policyapproval/msgs/msgs.go +++ b/internal/app/policyapproval/msgs/msgs.go @@ -27,6 +27,8 @@ type ApproveAllLowRisk struct { Categories []string } +type BackToSummary struct{} + type Cancelled struct { ToolUseID string } diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go index a8b85171..27a18c82 100644 --- a/internal/app/policyapproval/policyapproval.go +++ b/internal/app/policyapproval/policyapproval.go @@ -8,7 +8,9 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/usetero/cli/internal/app/policyapproval/categorydetail" "github.com/usetero/cli/internal/app/policyapproval/categorysummary" + "github.com/usetero/cli/internal/app/policyapproval/msgs" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" @@ -70,10 +72,15 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } return nil - // TODO: Handle step completion messages + case msgs.CategorySelected: + return m.setStep(categorydetail.New(m.ctx, m.theme, m.db, msg.Category, m.scope)) + + case msgs.BackToSummary: + return m.setStep(categorysummary.New(m.ctx, m.theme, m.db, m.scope)) + + // TODO: Handle remaining step completion messages // case msgs.PoliciesSelected: - // case msgs.ApprovalConfirmed: - // case msgs.ExecutionComplete: + // case msgs.Confirmed: } // Delegate to current step diff --git a/internal/domain/policy_detail.go b/internal/domain/policy_detail.go new file mode 100644 index 00000000..3a5829ef --- /dev/null +++ b/internal/domain/policy_detail.go @@ -0,0 +1,11 @@ +package domain + +// PolicyDetail represents an individual pending policy within a category. +type PolicyDetail struct { + PolicyID string + LogEventName string + RiskLevel RiskLevel + Benefits string + EstimatedCostPerHour float64 + EstimatedVolumePerHour float64 +} diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go index ee8edc08..e24a9c98 100644 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ b/internal/sqlite/gen/log_event_policies.sql.go @@ -37,6 +37,59 @@ func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { return count, err } +const listPendingPoliciesByCategory = `-- name: ListPendingPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'PENDING' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC +` + +type ListPendingPoliciesByCategoryRow struct { + PolicyID *string + RiskLevel *string + Benefits *string + EstimatedCostReductionPerHourUsd *float64 + EstimatedVolumeReductionPerHour *float64 + LogEventName string +} + +func (q *Queries) ListPendingPoliciesByCategory(ctx context.Context, category *string) ([]ListPendingPoliciesByCategoryRow, error) { + rows, err := q.db.QueryContext(ctx, listPendingPoliciesByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPendingPoliciesByCategoryRow + for rows.Next() { + var i ListPendingPoliciesByCategoryRow + if err := rows.Scan( + &i.PolicyID, + &i.RiskLevel, + &i.Benefits, + &i.EstimatedCostReductionPerHourUsd, + &i.EstimatedVolumeReductionPerHour, + &i.LogEventName, + ); 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 listPolicyCategoryStatuses = `-- name: ListPolicyCategoryStatuses :many SELECT COALESCE(category, '') AS category, diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go index ab7e3b74..ce1172a5 100644 --- a/internal/sqlite/gen/querier.go +++ b/internal/sqlite/gen/querier.go @@ -28,6 +28,7 @@ type Querier interface { ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) + ListPendingPoliciesByCategory(ctx context.Context, category *string) ([]ListPendingPoliciesByCategoryRow, error) ListPolicyCategoryStatuses(ctx context.Context) ([]ListPolicyCategoryStatusesRow, error) ListServiceStatuses(ctx context.Context) ([]ListServiceStatusesRow, error) ListServices(ctx context.Context) ([]Service, error) diff --git a/internal/sqlite/log_event_policies.go b/internal/sqlite/log_event_policies.go index 2b693e7e..3b1ac574 100644 --- a/internal/sqlite/log_event_policies.go +++ b/internal/sqlite/log_event_policies.go @@ -12,6 +12,7 @@ import ( type LogEventPolicies interface { Count(ctx context.Context) (int64, error) ListCategoryStatuses(ctx context.Context) ([]domain.PolicyCategoryStatus, error) + ListPendingByCategory(ctx context.Context, category string) ([]domain.PolicyDetail, error) Approve(ctx context.Context, id, userID string) error } @@ -54,6 +55,27 @@ func (l *logEventPoliciesImpl) ListCategoryStatuses(ctx context.Context) ([]doma return result, nil } +// ListPendingByCategory returns individual pending policies for a category. +func (l *logEventPoliciesImpl) ListPendingByCategory(ctx context.Context, category string) ([]domain.PolicyDetail, error) { + rows, err := l.read.ListPendingPoliciesByCategory(ctx, &category) + if err != nil { + return nil, WrapSQLiteError(err, "list pending policies by category") + } + + result := make([]domain.PolicyDetail, len(rows)) + for i, row := range rows { + result[i] = domain.PolicyDetail{ + PolicyID: derefString(row.PolicyID), + LogEventName: row.LogEventName, + RiskLevel: domain.RiskLevel(derefString(row.RiskLevel)), + Benefits: derefString(row.Benefits), + EstimatedCostPerHour: derefFloat(row.EstimatedCostReductionPerHourUsd), + EstimatedVolumePerHour: derefFloat(row.EstimatedVolumeReductionPerHour), + } + } + return result, 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{ @@ -73,3 +95,10 @@ func derefFloat(p *float64) float64 { } return *p } + +func derefString(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql index 7189fd33..e456e558 100644 --- a/internal/sqlite/queries/log_event_policies.sql +++ b/internal/sqlite/queries/log_event_policies.sql @@ -28,6 +28,19 @@ GROUP BY category ORDER BY SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) DESC; +-- name: ListPendingPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'PENDING' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC; + -- name: ApproveLogEventPolicy :exec UPDATE log_event_policies SET approved_at = ?, approved_by = ? From 029610302e0d1c9677df711fb63b0ad29f70b792 Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 13:01:15 -0500 Subject: [PATCH 10/13] feat: enable b to go back to last viewed page and enter to select and esc to return to home --- .../categorydetail/categorydetail.go | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/app/policyapproval/categorydetail/categorydetail.go b/internal/app/policyapproval/categorydetail/categorydetail.go index d2fdafb7..956564c9 100644 --- a/internal/app/policyapproval/categorydetail/categorydetail.go +++ b/internal/app/policyapproval/categorydetail/categorydetail.go @@ -92,12 +92,6 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { case tea.KeyPressMsg: switch { - case key.Matches(msg, keySpace): - if m.tbl != nil && len(m.policies) > 0 { - p := m.policies[m.tbl.Cursor()] - m.selected[p.PolicyID] = !m.selected[p.PolicyID] - m.rebuildRows() - } case key.Matches(msg, keySelectAll): for _, p := range m.policies { m.selected[p.PolicyID] = true @@ -109,9 +103,15 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { } m.rebuildRows() case key.Matches(msg, keyEnter): - return m.submit() - case key.Matches(msg, keyEscape): + if m.tbl != nil && len(m.policies) > 0 { + p := m.policies[m.tbl.Cursor()] + m.selected[p.PolicyID] = !m.selected[p.PolicyID] + m.rebuildRows() + } + case key.Matches(msg, keyBack): return func() tea.Msg { return msgs.BackToSummary{} } + case key.Matches(msg, keyEscape): + return func() tea.Msg { return msgs.Cancelled{} } default: if m.tbl != nil { return m.tbl.Update(msg) @@ -226,16 +226,16 @@ func (m *Model) SetSize(width, height int) { // ShortHelp returns key bindings. func (m *Model) ShortHelp() []key.Binding { - return []key.Binding{keySpace, keySelectAll, keySelectNone, keyEnter, keyEscape} + return []key.Binding{keyEnter, keySelectAll, keySelectNone, keyBack, keyEscape} } // Key bindings. var ( - keySpace = key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")) + keyEnter = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter", "toggle")) keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) - keyEnter = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")) - keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")) + keyBack = key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")) + keyEscape = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "home")) ) // formatSavings returns estimated yearly cost savings. From 44ee4a87b57df8777eb7d3a606f871ac449fa4ba Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 15:32:18 -0500 Subject: [PATCH 11/13] feat: responsive viewport --- .../categorydetail/categorydetail.go | 21 +++++- internal/app/policyapproval/policyapproval.go | 75 ++++++++++++++----- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/internal/app/policyapproval/categorydetail/categorydetail.go b/internal/app/policyapproval/categorydetail/categorydetail.go index 956564c9..36a3570d 100644 --- a/internal/app/policyapproval/categorydetail/categorydetail.go +++ b/internal/app/policyapproval/categorydetail/categorydetail.go @@ -149,6 +149,24 @@ func (m *Model) buildTable() { rows := m.makeRows() m.tbl = tableselect.New(m.theme, cols, rows) + m.updateTableHeight() +} + +// updateTableHeight constrains the table to the available viewport. +// View chrome: header (1) + blank (1) + border top/bottom (2) + blank (1) + footer (1) = 6 lines. +func (m *Model) updateTableHeight() { + if m.tbl == nil { + return + } + const chrome = 6 + maxRows := m.height - chrome + if maxRows < 2 { + maxRows = 2 // header + at least 1 row + } + total := len(m.policies) + 1 // +1 for table header + if total > maxRows { + m.tbl.SetHeight(maxRows) + } } // rebuildRows updates table rows with current checkbox state. @@ -218,10 +236,11 @@ func (m *Model) View() string { return strings.Join(lines, "\n") } -// SetSize updates dimensions. +// SetSize updates dimensions and constrains the table to fit. func (m *Model) SetSize(width, height int) { m.width = width m.height = height + m.updateTableHeight() } // ShortHelp returns key bindings. diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go index 27a18c82..cbe9442d 100644 --- a/internal/app/policyapproval/policyapproval.go +++ b/internal/app/policyapproval/policyapproval.go @@ -3,6 +3,7 @@ package policyapproval import ( "context" + "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -16,6 +17,11 @@ import ( "github.com/usetero/cli/internal/styles" ) +const ( + sideBySideMinWidth = 125 // ~61 per table + 3 gap + sideBySideGap = 3 +) + // Model is the policy approval wizard orchestrator. type Model struct { // Dependencies @@ -32,8 +38,11 @@ type Model struct { approvedCount int failedCount int - // Current step - step Step + // Steps + summary *categorysummary.Model // persistent, created once + detail Step // nil when not drilled in + step Step // active step for input (summary or detail) + width int height int } @@ -58,7 +67,10 @@ func New( // Init starts the wizard with the category summary step. func (m *Model) Init() tea.Cmd { m.scope.Info("policy approval wizard started") - return m.setStep(categorysummary.New(m.ctx, m.theme, m.db, m.scope)) + m.summary = categorysummary.New(m.ctx, m.theme, m.db, m.scope) + m.step = m.summary + m.propagateSize() + return m.summary.Init() } // Update handles messages and orchestrates step transitions. @@ -67,16 +79,20 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - if m.step != nil { - m.step.SetSize(m.width, m.height) - } + m.propagateSize() return nil case msgs.CategorySelected: - return m.setStep(categorydetail.New(m.ctx, m.theme, m.db, msg.Category, m.scope)) + m.detail = categorydetail.New(m.ctx, m.theme, m.db, msg.Category, m.scope) + m.step = m.detail + m.propagateSize() + return m.detail.Init() case msgs.BackToSummary: - return m.setStep(categorysummary.New(m.ctx, m.theme, m.db, m.scope)) + m.detail = nil + m.step = m.summary + m.propagateSize() + return nil // summary already loaded, no re-init // TODO: Handle remaining step completion messages // case msgs.PoliciesSelected: @@ -90,33 +106,56 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { return nil } -// setStep sets the current step and initializes it. -func (m *Model) setStep(step Step) tea.Cmd { - m.step = step - m.step.SetSize(m.width, m.height) - return m.step.Init() +// isWide returns true if the viewport is wide enough for side-by-side layout. +func (m *Model) isWide() bool { + return m.width >= sideBySideMinWidth +} + +// propagateSize distributes width/height to summary and detail based on layout. +func (m *Model) propagateSize() { + if m.summary == nil { + return + } + if m.detail != nil && m.isWide() { + half := (m.width - sideBySideGap) / 2 + m.summary.SetSize(half, m.height) + m.detail.SetSize(m.width-half-sideBySideGap, m.height) + } else if m.detail != nil { + m.detail.SetSize(m.width, m.height) + } else { + m.summary.SetSize(m.width, m.height) + } } // View renders the current step. func (m *Model) View() string { - if m.step == nil { + if m.summary == nil { return "" } + var content string + if m.detail != nil && m.isWide() { + gap := strings.Repeat(" ", sideBySideGap) + content = lipgloss.JoinHorizontal(lipgloss.Bottom, + m.summary.View(), gap, m.detail.View()) + } else if m.detail != nil { + content = m.detail.View() + } else { + content = m.summary.View() + } + return lipgloss.NewStyle(). Width(m.width). Height(m.height). AlignVertical(lipgloss.Bottom). - Render(m.step.View()) + Render(content) } // SetSize updates the model's dimensions. func (m *Model) SetSize(width, height int) { m.width = width m.height = height - if m.step != nil { - m.step.SetSize(width, height) - } + m.propagateSize() } // ShortHelp returns the key bindings for the short help view. From a3390195a13d39e8e8297f176f3c4c16ed861c66 Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 16:27:00 -0500 Subject: [PATCH 12/13] feat: enable confirm/execute step by pressing enter, space selects/deselects --- internal/app/app.go | 12 +- .../categorydetail/categorydetail.go | 24 +- .../app/policyapproval/confirm/confirm.go | 162 ++++++++++++ .../app/policyapproval/execute/execute.go | 233 ++++++++++++++++++ internal/app/policyapproval/msgs/msgs.go | 5 +- internal/app/policyapproval/policyapproval.go | 31 ++- internal/app/policyapproval/select/select.go | 13 +- internal/sqlite/gen/log_event_policies.sql.go | 53 ++++ .../tea/components/tableselect/tableselect.go | 3 + 9 files changed, 508 insertions(+), 28 deletions(-) create mode 100644 internal/app/policyapproval/confirm/confirm.go create mode 100644 internal/app/policyapproval/execute/execute.go diff --git a/internal/app/app.go b/internal/app/app.go index 75bf3ce4..25ed38d4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -289,7 +289,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Intercept "approve", "approval", etc. to open wizard directly if strings.HasPrefix(strings.ToLower(text), "approv") { m.state = statePolicyApproval - m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, "", m.scope) + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, "", m.getUserID(), m.scope) m.updateLayout() return m, m.policyApproval.Init() } @@ -366,7 +366,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case policyapprovalmsg.Start: m.state = statePolicyApproval - m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, msg.ToolUseID, m.scope) + m.policyApproval = policyapproval.New(m.ctx, m.theme, m.db, msg.ToolUseID, m.getUserID(), m.scope) // m.policyApproval.SetPolicies(msg.Policies) m.updateLayout() return m, m.policyApproval.Init() @@ -445,6 +445,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// getUserID returns the authenticated user's ID, or empty string if not yet available. +func (m *Model) getUserID() string { + if m.user != nil { + return m.user.ID + } + return "" +} + // updateLayout propagates sizes to children based on current dimensions. func (m *Model) updateLayout() { contentWidth, contentHeight := m.contentSize() diff --git a/internal/app/policyapproval/categorydetail/categorydetail.go b/internal/app/policyapproval/categorydetail/categorydetail.go index 36a3570d..184dbd22 100644 --- a/internal/app/policyapproval/categorydetail/categorydetail.go +++ b/internal/app/policyapproval/categorydetail/categorydetail.go @@ -102,12 +102,14 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { m.selected[p.PolicyID] = false } m.rebuildRows() - case key.Matches(msg, keyEnter): + case key.Matches(msg, keyToggle): if m.tbl != nil && len(m.policies) > 0 { p := m.policies[m.tbl.Cursor()] m.selected[p.PolicyID] = !m.selected[p.PolicyID] m.rebuildRows() } + case key.Matches(msg, keySubmit): + return m.submit() case key.Matches(msg, keyBack): return func() tea.Msg { return msgs.BackToSummary{} } case key.Matches(msg, keyEscape): @@ -121,20 +123,23 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { return nil } -// submit emits PoliciesSelected with the currently selected policy IDs. +// submit emits PoliciesSelected with the currently selected policies. func (m *Model) submit() tea.Cmd { - var ids []string + var selected []domain.PolicyDetail for _, p := range m.policies { if m.selected[p.PolicyID] { - ids = append(ids, p.PolicyID) + selected = append(selected, p) } } - if len(ids) == 0 { + if len(selected) == 0 { return nil } - m.scope.Info("policies selected", "count", len(ids)) + m.scope.Info("policies selected", "count", len(selected)) return func() tea.Msg { - return msgs.PoliciesSelected{PolicyIDs: ids} + return msgs.PoliciesSelected{ + Category: m.category, + Policies: selected, + } } } @@ -245,12 +250,13 @@ func (m *Model) SetSize(width, height int) { // ShortHelp returns key bindings. func (m *Model) ShortHelp() []key.Binding { - return []key.Binding{keyEnter, keySelectAll, keySelectNone, keyBack, keyEscape} + return []key.Binding{keyToggle, keySubmit, keySelectAll, keySelectNone, keyBack, keyEscape} } // Key bindings. var ( - keyEnter = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter", "toggle")) + keyToggle = key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")) + keySubmit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")) keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) keyBack = key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")) diff --git a/internal/app/policyapproval/confirm/confirm.go b/internal/app/policyapproval/confirm/confirm.go new file mode 100644 index 00000000..980eb05d --- /dev/null +++ b/internal/app/policyapproval/confirm/confirm.go @@ -0,0 +1,162 @@ +// Package confirm provides the confirmation dialog step for the policy approval wizard. +package confirm + +import ( + "fmt" + "math" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/styles" +) + +// Model handles the confirmation dialog — the third step of the approval wizard. +type Model struct { + theme styles.Theme + scope log.Scope + category string + policies []domain.PolicyDetail + selectedApprove bool // which button is focused (true = Approve, false = Cancel) + width int + height int +} + +// New creates a new confirm step. +func New(theme styles.Theme, category string, policies []domain.PolicyDetail, scope log.Scope) *Model { + return &Model{ + theme: theme, + scope: scope.Child("confirm"), + category: category, + policies: policies, + selectedApprove: true, // default to Approve + } +} + +// Init returns nil — no async work needed. +func (m *Model) Init() tea.Cmd { + m.scope.Info("confirm step started", "category", m.category, "count", len(m.policies)) + return nil +} + +// Update handles key presses. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + if msg, ok := msg.(tea.KeyPressMsg); ok { + switch { + case key.Matches(msg, keyToggle): + m.selectedApprove = !m.selectedApprove + case key.Matches(msg, keyConfirm): + if m.selectedApprove { + m.scope.Info("approval confirmed") + return func() tea.Msg { return msgs.Confirmed{} } + } + m.scope.Info("approval cancelled from confirm dialog") + return func() tea.Msg { return msgs.BackToSummary{} } + case key.Matches(msg, keyCancel): + m.scope.Info("confirm cancelled via esc") + return func() tea.Msg { return msgs.BackToSummary{} } + } + } + return nil +} + +// View renders the confirmation dialog. +func (m *Model) View() string { + colors := m.theme + count := len(m.policies) + savings := m.estimatedSavings() + + // Title + title := lipgloss.NewStyle(). + Foreground(colors.Text). + Bold(true). + Render(fmt.Sprintf("Approve %d policies in %s?", count, m.category)) + + // Description + muted := lipgloss.NewStyle().Foreground(colors.TextMuted) + var details []string + details = append(details, muted.Render("This will create Datadog exclusion filters for:")) + details = append(details, muted.Render(fmt.Sprintf(" • %d log events", count))) + if savings > 0 { + details = append(details, muted.Render(fmt.Sprintf(" • Estimated savings: ~%s/yr", formatCost(savings)))) + } + description := strings.Join(details, "\n") + + // Buttons + cancelBtn := m.renderButton("Cancel", !m.selectedApprove) + approveBtn := m.renderButton("Approve", m.selectedApprove) + buttons := cancelBtn + " " + approveBtn + + content := lipgloss.JoinVertical(lipgloss.Center, title, "", description, "", buttons) + + return lipgloss.NewStyle(). + Foreground(colors.Text). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(colors.Accent). + Padding(1, 3). + Render(content) +} + +// renderButton renders a single button with selected/unselected styling. +func (m *Model) renderButton(label string, selected bool) string { + colors := m.theme + + if selected { + return lipgloss.NewStyle(). + Background(colors.Accent). + Foreground(colors.Bg). + Padding(0, 3). + Bold(true). + Render(label) + } + + return lipgloss.NewStyle(). + Foreground(colors.TextMuted). + Padding(0, 3). + Render(label) +} + +// estimatedSavings calculates total yearly savings for selected policies. +func (m *Model) estimatedSavings() float64 { + var total float64 + for _, p := range m.policies { + total += p.EstimatedCostPerHour + } + return total * 8760 // hours per year +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings. +func (m *Model) ShortHelp() []key.Binding { + return []key.Binding{keyToggle, keyConfirm, keyCancel} +} + +// Key bindings. +var ( + keyToggle = key.NewBinding(key.WithKeys("tab", "left", "right"), key.WithHelp("tab", "switch")) + keyConfirm = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")) + keyCancel = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")) +) + +// formatCost formats a dollar amount: $142, $9.4k, $1.2M. +func formatCost(dollars float64) string { + abs := math.Abs(dollars) + switch { + case abs >= 1_000_000: + return fmt.Sprintf("$%.1fM", dollars/1_000_000) + case abs >= 1_000: + return fmt.Sprintf("$%.1fk", dollars/1_000) + default: + return fmt.Sprintf("$%.0f", dollars) + } +} diff --git a/internal/app/policyapproval/execute/execute.go b/internal/app/policyapproval/execute/execute.go new file mode 100644 index 00000000..e8b44a20 --- /dev/null +++ b/internal/app/policyapproval/execute/execute.go @@ -0,0 +1,233 @@ +// Package execute provides the sequential policy approval execution step. +package execute + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" + "github.com/usetero/cli/internal/log" + "github.com/usetero/cli/internal/sqlite" + "github.com/usetero/cli/internal/styles" +) + +// Model handles sequential policy approval — the final step of the wizard. +type Model struct { + ctx context.Context + theme styles.Theme + db sqlite.DB + scope log.Scope + userID string + policies []domain.PolicyDetail + current int // index of policy being approved + results []result // per-policy outcome + done bool + width int + height int + + // Accumulated for completion message + toolUseID string + totalSavings float64 +} + +type result struct { + err error +} + +// policyApprovedMsg is sent when a single policy approval completes. +type policyApprovedMsg struct { + index int + err error +} + +// New creates a new execute step. +func New( + ctx context.Context, + theme styles.Theme, + db sqlite.DB, + userID string, + toolUseID string, + policies []domain.PolicyDetail, + scope log.Scope, +) *Model { + // Pre-calculate total savings. + var totalSavings float64 + for _, p := range policies { + totalSavings += p.EstimatedCostPerHour * 8760 + } + + return &Model{ + ctx: ctx, + theme: theme, + db: db, + userID: userID, + toolUseID: toolUseID, + policies: policies, + results: make([]result, len(policies)), + scope: scope.Child("execute"), + totalSavings: totalSavings, + } +} + +// Init starts approving the first policy. +func (m *Model) Init() tea.Cmd { + m.scope.Info("execute step started", "count", len(m.policies)) + if len(m.policies) == 0 { + return m.complete() + } + return m.approveNext() +} + +// Update handles approval results and progresses through policies. +func (m *Model) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case policyApprovedMsg: + m.results[msg.index] = result{err: msg.err} + if msg.err != nil { + m.scope.Error("policy approval failed", + "index", msg.index, + "policy_id", m.policies[msg.index].PolicyID, + "error", msg.err, + ) + } else { + m.scope.Info("policy approved", + "index", msg.index, + "policy_id", m.policies[msg.index].PolicyID, + ) + } + + m.current++ + if m.current < len(m.policies) { + return m.approveNext() + } + m.done = true + return m.complete() + } + return nil +} + +// approveNext returns a command that approves the current policy. +func (m *Model) approveNext() tea.Cmd { + idx := m.current + policy := m.policies[idx] + return func() tea.Msg { + err := m.db.LogEventPolicies().Approve(m.ctx, policy.PolicyID, m.userID) + return policyApprovedMsg{index: idx, err: err} + } +} + +// complete emits the PolicyApprovalComplete message with counts. +func (m *Model) complete() tea.Cmd { + var approved, failed int + for _, r := range m.results { + if r.err != nil { + failed++ + } else { + approved++ + } + } + m.scope.Info("execution complete", "approved", approved, "failed", failed) + return func() tea.Msg { + return msgs.PolicyApprovalComplete{ + ToolUseID: m.toolUseID, + ApprovedCount: approved, + FailedCount: failed, + TotalSavings: m.totalSavings, + } + } +} + +// View renders the progress display. +func (m *Model) View() string { + colors := m.theme + + header := lipgloss.NewStyle(). + Foreground(colors.Text). + Bold(true). + Render("Approving policies...") + + // Determine visible window based on height. + const chrome = 5 // header + blank + blank + counter + margin + maxVisible := m.height - chrome + if maxVisible < 3 { + maxVisible = 3 + } + + // Scroll window: keep current item visible with some context. + start := 0 + end := len(m.policies) + if end > maxVisible { + // Center around current item. + start = m.current - maxVisible/2 + if start < 0 { + start = 0 + } + end = start + maxVisible + if end > len(m.policies) { + end = len(m.policies) + start = end - maxVisible + } + } + + var lines []string + for i := start; i < end; i++ { + line := m.renderPolicyLine(i) + lines = append(lines, line) + } + + policyList := strings.Join(lines, "\n") + + // Counter + muted := lipgloss.NewStyle().Foreground(colors.TextMuted) + counter := muted.Render(fmt.Sprintf("%d of %d", m.current, len(m.policies))) + + return lipgloss.JoinVertical(lipgloss.Left, header, "", policyList, "", counter) +} + +// renderPolicyLine renders a single policy line with status symbol. +func (m *Model) renderPolicyLine(index int) string { + colors := m.theme + name := m.policies[index].LogEventName + + var symbol string + var style lipgloss.Style + + switch { + case index < m.current: + // Completed + if m.results[index].err != nil { + symbol = "✗" + style = lipgloss.NewStyle().Foreground(colors.ErrorFg) + } else { + symbol = "✓" + style = lipgloss.NewStyle().Foreground(colors.SuccessFg) + } + case index == m.current && !m.done: + // In progress + symbol = "◐" + style = lipgloss.NewStyle().Foreground(colors.Accent) + default: + // Queued + symbol = "○" + style = lipgloss.NewStyle().Foreground(colors.TextMuted) + } + + return style.Render(fmt.Sprintf("%s %s", symbol, name)) +} + +// SetSize updates dimensions. +func (m *Model) SetSize(width, height int) { + m.width = width + m.height = height +} + +// ShortHelp returns key bindings (none during execution). +func (m *Model) ShortHelp() []key.Binding { + return nil +} diff --git a/internal/app/policyapproval/msgs/msgs.go b/internal/app/policyapproval/msgs/msgs.go index d6daef6e..cea126ce 100644 --- a/internal/app/policyapproval/msgs/msgs.go +++ b/internal/app/policyapproval/msgs/msgs.go @@ -1,5 +1,7 @@ package msgs +import "github.com/usetero/cli/internal/domain" + type Policy struct { ID string ServiceName string @@ -14,7 +16,8 @@ type Start struct { } type PoliciesSelected struct { - PolicyIDs []string + Category string + Policies []domain.PolicyDetail } type Confirmed struct{} diff --git a/internal/app/policyapproval/policyapproval.go b/internal/app/policyapproval/policyapproval.go index cbe9442d..f23a6a1d 100644 --- a/internal/app/policyapproval/policyapproval.go +++ b/internal/app/policyapproval/policyapproval.go @@ -11,7 +11,10 @@ import ( "github.com/usetero/cli/internal/app/policyapproval/categorydetail" "github.com/usetero/cli/internal/app/policyapproval/categorysummary" + "github.com/usetero/cli/internal/app/policyapproval/confirm" + "github.com/usetero/cli/internal/app/policyapproval/execute" "github.com/usetero/cli/internal/app/policyapproval/msgs" + "github.com/usetero/cli/internal/domain" "github.com/usetero/cli/internal/log" "github.com/usetero/cli/internal/sqlite" "github.com/usetero/cli/internal/styles" @@ -32,11 +35,11 @@ type Model struct { // Input from tool toolUseID string + userID string - // Accumulated state from step completions - selectedIDs []string - approvedCount int - failedCount int + // Accumulated from step completions + selectedCategory string + selectedPolicies []domain.PolicyDetail // Steps summary *categorysummary.Model // persistent, created once @@ -53,6 +56,7 @@ func New( theme styles.Theme, db sqlite.DB, toolUseID string, + userID string, scope log.Scope, ) *Model { return &Model{ @@ -60,6 +64,7 @@ func New( theme: theme, db: db, toolUseID: toolUseID, + userID: userID, scope: scope.Child("policyapproval"), } } @@ -94,9 +99,21 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { m.propagateSize() return nil // summary already loaded, no re-init - // TODO: Handle remaining step completion messages - // case msgs.PoliciesSelected: - // case msgs.Confirmed: + case msgs.PoliciesSelected: + m.selectedCategory = msg.Category + m.selectedPolicies = msg.Policies + c := confirm.New(m.theme, msg.Category, msg.Policies, m.scope) + m.detail = c + m.step = c + m.propagateSize() + return c.Init() + + case msgs.Confirmed: + exec := execute.New(m.ctx, m.theme, m.db, m.userID, m.toolUseID, m.selectedPolicies, m.scope) + m.detail = exec + m.step = exec + m.propagateSize() + return exec.Init() } // Delegate to current step diff --git a/internal/app/policyapproval/select/select.go b/internal/app/policyapproval/select/select.go index edfa8fcd..b2eaa7a4 100644 --- a/internal/app/policyapproval/select/select.go +++ b/internal/app/policyapproval/select/select.go @@ -64,17 +64,12 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { return nil } -// confirm emits PoliciesSelected with the selected IDs. +// confirm emits PoliciesSelected with the selected policies. func (m *Model) confirm() tea.Cmd { - var ids []string - for id, selected := range m.selected { - if selected { - ids = append(ids, id) - } - } - m.scope.Info("policies selected", "count", len(ids)) + // TODO: convert msgs.Policy to domain.PolicyDetail when this step is fully implemented + m.scope.Info("policies selected (stub)") return func() tea.Msg { - return msgs.PoliciesSelected{PolicyIDs: ids} + return msgs.PoliciesSelected{} } } diff --git a/internal/sqlite/gen/log_event_policies.sql.go b/internal/sqlite/gen/log_event_policies.sql.go index e24a9c98..714bfbb3 100644 --- a/internal/sqlite/gen/log_event_policies.sql.go +++ b/internal/sqlite/gen/log_event_policies.sql.go @@ -37,6 +37,59 @@ func (q *Queries) CountLogEventPolicies(ctx context.Context) (int64, error) { return count, err } +const listApprovedPoliciesByCategory = `-- name: ListApprovedPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'APPROVED' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC +` + +type ListApprovedPoliciesByCategoryRow struct { + PolicyID *string + RiskLevel *string + Benefits *string + EstimatedCostReductionPerHourUsd *float64 + EstimatedVolumeReductionPerHour *float64 + LogEventName string +} + +func (q *Queries) ListApprovedPoliciesByCategory(ctx context.Context, category *string) ([]ListApprovedPoliciesByCategoryRow, error) { + rows, err := q.db.QueryContext(ctx, listApprovedPoliciesByCategory, category) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListApprovedPoliciesByCategoryRow + for rows.Next() { + var i ListApprovedPoliciesByCategoryRow + if err := rows.Scan( + &i.PolicyID, + &i.RiskLevel, + &i.Benefits, + &i.EstimatedCostReductionPerHourUsd, + &i.EstimatedVolumeReductionPerHour, + &i.LogEventName, + ); 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 listPendingPoliciesByCategory = `-- name: ListPendingPoliciesByCategory :many SELECT c.policy_id, diff --git a/internal/tea/components/tableselect/tableselect.go b/internal/tea/components/tableselect/tableselect.go index 0729251b..6f87cc3e 100644 --- a/internal/tea/components/tableselect/tableselect.go +++ b/internal/tea/components/tableselect/tableselect.go @@ -52,6 +52,9 @@ func New(theme styles.Theme, cols []Column, rows []Row) *Model { table.WithHeight(len(rows)+1), ) + // Remove space from PageDown so consuming components can use it for toggle/selection. + t.KeyMap.PageDown.SetKeys("f", "pgdown") + return &Model{ theme: theme, table: t, From bc7623a9e21c1584dd58d07e9a081cbeea988636 Mon Sep 17 00:00:00 2001 From: Divya Date: Fri, 13 Feb 2026 16:34:17 -0500 Subject: [PATCH 13/13] chore: remove code for initial policy approve --- internal/app/app.go | 8 +- .../round/turn/assistant/assistant.go | 3 - .../tools/policyapprove/policyapprove.go | 165 ------------------ internal/app/chat/msgs/tools.go | 20 --- internal/chat/tools/policy_approve.go | 73 -------- internal/chat/tools/registry.go | 4 - internal/domain/tools/policy.go | 13 -- internal/domain/tools/tools.go | 3 - internal/sqlite/gen/querier.go | 1 + .../sqlite/queries/log_event_policies.sql | 13 ++ 10 files changed, 15 insertions(+), 288 deletions(-) delete mode 100644 internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go delete mode 100644 internal/chat/tools/policy_approve.go delete mode 100644 internal/domain/tools/policy.go diff --git a/internal/app/app.go b/internal/app/app.go index 25ed38d4..0a1e0a50 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -319,13 +319,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Create tool registry with executors m.toolRegistry = &chattools.Registry{ - Query: chattools.NewQueryTool(m.db), - // PolicyApprove: chattools.NewPolicyApproveTool(m.db.LogEventPolicies(), func() string { - // if m.user == nil { - // return "" - // } - // return m.user.ID - // }), + Query: chattools.NewQueryTool(m.db), StartPolicyApproval: chattools.NewStartPolicyApprovalTool(), StartJourney: chattools.NewStartJourneyTool(), EndJourney: chattools.NewEndJourneyTool(), diff --git a/internal/app/chat/messagelist/round/turn/assistant/assistant.go b/internal/app/chat/messagelist/round/turn/assistant/assistant.go index 52bf1597..81a9d5ec 100644 --- a/internal/app/chat/messagelist/round/turn/assistant/assistant.go +++ b/internal/app/chat/messagelist/round/turn/assistant/assistant.go @@ -5,7 +5,6 @@ import ( "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/policyapprove" "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/startpolicyapproval" "github.com/usetero/cli/internal/app/chat/msgs" @@ -155,8 +154,6 @@ func (m *Model) newToolBlock(index int, toolUse *domain.ToolUse, width int) *too switch toolUse.Name { case m.toolRegistry.Query.Name(): child = query.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.Query, m.scope) - case m.toolRegistry.PolicyApprove.Name(): - child = policyapprove.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.PolicyApprove, m.scope) case m.toolRegistry.StartPolicyApproval.Name(): child = startpolicyapproval.New(m.blockTheme, index, toolUse.ID, width, m.toolRegistry.StartPolicyApproval, m.scope) default: diff --git a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go b/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go deleted file mode 100644 index 831fd3d9..00000000 --- a/internal/app/chat/messagelist/round/turn/assistant/blocks/tools/policyapprove/policyapprove.go +++ /dev/null @@ -1,165 +0,0 @@ -package policyapprove - -import ( - "encoding/json" - "fmt" - - tea "charm.land/bubbletea/v2" - "github.com/usetero/cli/internal/app/chat/messagelist/round/turn/assistant/blocks/tools" - "github.com/usetero/cli/internal/app/chat/msgs" - chattools "github.com/usetero/cli/internal/chat/tools" - "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" -) - -// Model handles policy approve tool execution. -type Model struct { - theme styles.Theme - scope log.Scope - index int - toolID string - state tools.State - executor *chattools.PolicyApproveTool - width int - - // Input - input string - policyID string - - // Result - approved bool - err error -} - -// New creates a new policy approve tool model. -func New(theme styles.Theme, index int, toolID string, width int, executor *chattools.PolicyApproveTool, scope log.Scope) *Model { - return &Model{ - theme: theme, - scope: scope.Child("policyapprove"), - index: index, - 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) - } - return nil -} - -// handleContent finds this tool's data 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 -} - -func (m *Model) execute() tea.Cmd { - m.state = tools.StateExecuting - - // Parse input - var in domaintools.PolicyApproveInput - if err := json.Unmarshal([]byte(m.input), &in); err == nil { - m.policyID = in.PolicyID - } - - m.scope.Info("approving policy", "policy_id", m.policyID) - - if m.executor == nil { - m.err = fmt.Errorf("no executor") - m.state = tools.StateComplete - m.scope.Error("policy approve failed", "error", m.err) - return m.fireCompleted() - } - - result, err := m.executor.Execute(json.RawMessage(m.input)) - if err != nil { - m.err = err - m.state = tools.StateComplete - m.scope.Error("policy approve failed", "error", err) - return m.fireCompleted() - } - - m.approved = result.Approved - m.state = tools.StateComplete - m.scope.Info("policy approved", "policy_id", m.policyID) - return m.fireCompleted() -} - -func (m *Model) fireCompleted() tea.Cmd { - return func() tea.Msg { - return msgs.PolicyApproveCompleted{ - ToolUseID: m.toolID, - PolicyID: m.policyID, - Approved: m.approved, - Error: m.err, - } - } -} - -// Name returns the tool's display name. -func (m *Model) Name() string { - return "Approve Policy" -} - -// Status returns the status message shown while executing. -func (m *Model) Status() string { - if m.policyID != "" { - return "Approving policy " + m.policyID - } - return "Approving policy" -} - -// Result returns the result message. -func (m *Model) Result() string { - if m.approved { - return "Policy approved" - } - return "Policy approval failed" -} - -// View renders content (empty for this tool). -func (m *Model) View() string { - return "" -} - -// SetWidth sets the width. -func (m *Model) SetWidth(width int) { - m.width = width -} - -// 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/msgs/tools.go b/internal/app/chat/msgs/tools.go index b4fec043..ad3cbaba 100644 --- a/internal/app/chat/msgs/tools.go +++ b/internal/app/chat/msgs/tools.go @@ -67,25 +67,6 @@ func (m EndJourneyCompleted) GetResult() domaintools.Result { } func (m EndJourneyCompleted) toolCompleted() {} -// PolicyApproveCompleted is fired when a policy approve tool finishes executing. -type PolicyApproveCompleted struct { - ToolUseID string - PolicyID string - Approved bool - Error error -} - -func (m PolicyApproveCompleted) GetToolUseID() string { return m.ToolUseID } -func (m PolicyApproveCompleted) GetError() error { return m.Error } -func (m PolicyApproveCompleted) GetResult() domaintools.Result { - return domaintools.Result{ - ToolUseID: m.ToolUseID, - PolicyApprove: &domaintools.PolicyApproveResult{Approved: m.Approved}, - Error: errorResultFromErr(m.Error), - } -} -func (m PolicyApproveCompleted) toolCompleted() {} - // StartPolicyApprovalCompleted is fired when the policy approval wizard is triggered. type StartPolicyApprovalCompleted struct { ToolUseID string @@ -116,6 +97,5 @@ var ( _ ToolCompleted = QueryCompleted{} _ ToolCompleted = StartJourneyCompleted{} _ ToolCompleted = EndJourneyCompleted{} - _ ToolCompleted = PolicyApproveCompleted{} _ ToolCompleted = StartPolicyApprovalCompleted{} ) diff --git a/internal/chat/tools/policy_approve.go b/internal/chat/tools/policy_approve.go deleted file mode 100644 index 479975f3..00000000 --- a/internal/chat/tools/policy_approve.go +++ /dev/null @@ -1,73 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/usetero/cli/internal/chat" - domaintools "github.com/usetero/cli/internal/domain/tools" - "github.com/usetero/cli/internal/sqlite" -) - -// PolicyApproveTool approves log event policies. -type PolicyApproveTool struct { - policies sqlite.LogEventPolicies - getUserID func() string -} - -// NewPolicyApproveTool creates a new policy approve tool. -func NewPolicyApproveTool(policies sqlite.LogEventPolicies, getUserID func() string) *PolicyApproveTool { - return &PolicyApproveTool{policies: policies, getUserID: getUserID} -} - -// Name returns the tool name. -func (t *PolicyApproveTool) Name() string { - return "approve_policy" -} - -// Definition returns the tool definition for the chat API. -func (t *PolicyApproveTool) Definition() chat.Tool { - return chat.Tool{ - Name: t.Name(), - Description: `Approve a log event policy for enforcement. - -When approved, the policy will create exclusion filters in Datadog to reduce log volume. - -Use this when the user wants to: -- Approve a specific policy by ID -- Enable a policy recommendation -- Accept a suggested optimization`, - InputSchema: chat.NewObjectSchema( - map[string]chat.Property{ - "policy_id": { - Type: "string", - Description: "The ID of the policy to approve", - }, - }, - []string{"policy_id"}, - ), - } -} - -// Execute approves the policy and returns the result. -func (t *PolicyApproveTool) Execute(input json.RawMessage) (domaintools.PolicyApproveResult, error) { - var in domaintools.PolicyApproveInput - if err := json.Unmarshal(input, &in); err != nil { - return domaintools.PolicyApproveResult{}, err - } - - userID := t.getUserID() - if userID == "" { - return domaintools.PolicyApproveResult{}, fmt.Errorf("user not authenticated") - } - - ctx := context.Background() - - err := t.policies.Approve(ctx, in.PolicyID, userID) - if err != nil { - return domaintools.PolicyApproveResult{Approved: false}, err - } - - return domaintools.PolicyApproveResult{Approved: true}, nil -} diff --git a/internal/chat/tools/registry.go b/internal/chat/tools/registry.go index 7a0cac95..73200f08 100644 --- a/internal/chat/tools/registry.go +++ b/internal/chat/tools/registry.go @@ -6,7 +6,6 @@ import "github.com/usetero/cli/internal/chat" type Registry struct { Query *QueryTool StartPolicyApproval *StartPolicyApprovalTool - PolicyApprove *PolicyApproveTool StartJourney *StartJourneyTool EndJourney *EndJourneyTool } @@ -20,9 +19,6 @@ func (r *Registry) Definitions() []chat.Tool { if r.StartPolicyApproval != nil { defs = append(defs, r.StartPolicyApproval.Definition()) } - if r.PolicyApprove != nil { - defs = append(defs, r.PolicyApprove.Definition()) - } if r.StartJourney != nil { defs = append(defs, r.StartJourney.Definition()) } diff --git a/internal/domain/tools/policy.go b/internal/domain/tools/policy.go deleted file mode 100644 index f20fe31d..00000000 --- a/internal/domain/tools/policy.go +++ /dev/null @@ -1,13 +0,0 @@ -package tools - -type PolicyApproveInput struct { - PolicyID string `json:"policy_id"` -} - -type PolicyApproveResult struct { - Approved bool `json:"approved"` -} - -func (r PolicyApproveResult) ToMap() map[string]any { - return map[string]any{"approved": r.Approved} -} diff --git a/internal/domain/tools/tools.go b/internal/domain/tools/tools.go index 501cbf23..cc7de037 100644 --- a/internal/domain/tools/tools.go +++ b/internal/domain/tools/tools.go @@ -14,7 +14,6 @@ type Result struct { ToolUseID string Query *QueryResult StartPolicyApproval *StartPolicyApprovalResult - PolicyApprove *PolicyApproveResult StartJourney *StartJourneyResult EndJourney *EndJourneyResult Error *ErrorResult @@ -31,8 +30,6 @@ func (r Result) ToMap() map[string]any { return r.StartJourney.ToMap() case r.EndJourney != nil: return r.EndJourney.ToMap() - case r.PolicyApprove != nil: - return r.PolicyApprove.ToMap() case r.Error != nil: return map[string]any{"error": r.Error.Message} default: diff --git a/internal/sqlite/gen/querier.go b/internal/sqlite/gen/querier.go index ce1172a5..6ffa98a3 100644 --- a/internal/sqlite/gen/querier.go +++ b/internal/sqlite/gen/querier.go @@ -25,6 +25,7 @@ type Querier interface { GetService(ctx context.Context, id *string) (Service, error) InsertConversation(ctx context.Context, arg InsertConversationParams) error InsertMessage(ctx context.Context, arg InsertMessageParams) error + ListApprovedPoliciesByCategory(ctx context.Context, category *string) ([]ListApprovedPoliciesByCategoryRow, error) ListConversationsByAccount(ctx context.Context, accountID *string) ([]Conversation, error) ListMessagesByConversation(ctx context.Context, conversationID *string) ([]Message, error) ListMessagesByConversationDesc(ctx context.Context, conversationID *string) ([]Message, error) diff --git a/internal/sqlite/queries/log_event_policies.sql b/internal/sqlite/queries/log_event_policies.sql index e456e558..eca0c42d 100644 --- a/internal/sqlite/queries/log_event_policies.sql +++ b/internal/sqlite/queries/log_event_policies.sql @@ -41,6 +41,19 @@ LEFT JOIN log_events e ON c.log_event_id = e.id WHERE c.category = ? AND c.status = 'PENDING' ORDER BY c.estimated_cost_reduction_per_hour_usd DESC; +-- name: ListApprovedPoliciesByCategory :many +SELECT + c.policy_id, + c.risk_level, + c.benefits, + c.estimated_cost_reduction_per_hour_usd, + c.estimated_volume_reduction_per_hour, + COALESCE(e.name, '') AS log_event_name +FROM log_event_policy_statuses_cache c +LEFT JOIN log_events e ON c.log_event_id = e.id +WHERE c.category = ? AND c.status = 'APPROVED' +ORDER BY c.estimated_cost_reduction_per_hour_usd DESC; + -- name: ApproveLogEventPolicy :exec UPDATE log_event_policies SET approved_at = ?, approved_by = ?