feat: add app status tracking to the backend (#17163)

This does ~95% of the backend work required to integrate the AI work.

Most left to integrate from the tasks branch is just frontend, which
will be a lot smaller I believe.

The real difference between this branch and that one is the abstraction
-- this now attaches statuses to apps, and returns the latest status
reported as part of a workspace.

This change enables us to have a similar UX to in the tasks branch, but
for agents other than Claude Code as well. Any app can report status
now.
This commit is contained in:
Kyle Carberry
2025-03-31 10:55:44 -04:00
committed by GitHub
parent 489641d0be
commit 8ea956fc11
35 changed files with 1668 additions and 69 deletions

View File

@ -487,7 +487,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa
}.String()
}
func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp {
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp {
sort.Slice(dbApps, func(i, j int) bool {
if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder {
return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder
@ -498,8 +498,14 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa
return dbApps[i].Slug < dbApps[j].Slug
})
statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{}
for _, status := range statuses {
statusesByAppID[status.AppID] = append(statusesByAppID[status.AppID], status)
}
apps := make([]codersdk.WorkspaceApp, 0)
for _, dbApp := range dbApps {
statuses := statusesByAppID[dbApp.ID]
apps = append(apps, codersdk.WorkspaceApp{
ID: dbApp.ID,
URL: dbApp.Url.String,
@ -516,14 +522,33 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa
Interval: dbApp.HealthcheckInterval,
Threshold: dbApp.HealthcheckThreshold,
},
Health: codersdk.WorkspaceAppHealth(dbApp.Health),
Hidden: dbApp.Hidden,
OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn),
Health: codersdk.WorkspaceAppHealth(dbApp.Health),
Hidden: dbApp.Hidden,
OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn),
Statuses: WorkspaceAppStatuses(statuses),
})
}
return apps
}
func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus {
return List(statuses, WorkspaceAppStatus)
}
func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus {
return codersdk.WorkspaceAppStatus{
ID: status.ID,
CreatedAt: status.CreatedAt,
AgentID: status.AgentID,
AppID: status.AppID,
NeedsUserAttention: status.NeedsUserAttention,
URI: status.Uri.String,
Icon: status.Icon.String,
Message: status.Message,
State: codersdk.WorkspaceAppStatusState(status.State),
}
}
func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon {
result := codersdk.ProvisionerDaemon{
ID: dbDaemon.ID,

View File

@ -1840,6 +1840,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab
return q.db.GetLatestCryptoKeyByFeature(ctx, feature)
}
func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids)
}
func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) {
if _, err := q.GetWorkspaceByID(ctx, workspaceID); err != nil {
return database.WorkspaceBuild{}, err
@ -2854,6 +2861,13 @@ func (q *querier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg datab
return q.db.GetWorkspaceAppByAgentIDAndSlug(ctx, arg)
}
func (q *querier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetWorkspaceAppStatusesByAppIDs(ctx, ids)
}
func (q *querier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) {
if _, err := q.GetWorkspaceByAgentID(ctx, agentID); err != nil {
return nil, err
@ -3547,6 +3561,13 @@ func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.Inse
return q.db.InsertWorkspaceAppStats(ctx, arg)
}
func (q *querier) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return database.WorkspaceAppStatus{}, err
}
return q.db.InsertWorkspaceAppStatus(ctx, arg)
}
func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {

View File

@ -3706,6 +3706,12 @@ func (s *MethodTestSuite) TestSystemFunctions() {
LoginType: database.LoginTypeGithub,
}).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l)
}))
s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) {
check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetWorkspaceAppStatusesByAppIDs", s.Subtest(func(db database.Store, check *expects) {
check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{})
@ -4135,6 +4141,13 @@ func (s *MethodTestSuite) TestSystemFunctions() {
Options: json.RawMessage("{}"),
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("InsertWorkspaceAppStatus", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
check.Args(database.InsertWorkspaceAppStatusParams{
ID: uuid.New(),
State: "working",
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("InsertWorkspaceResource", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
check.Args(database.InsertWorkspaceResourceParams{

View File

@ -259,6 +259,7 @@ type data struct {
workspaceAgentVolumeResourceMonitors []database.WorkspaceAgentVolumeResourceMonitor
workspaceAgentDevcontainers []database.WorkspaceAgentDevcontainer
workspaceApps []database.WorkspaceApp
workspaceAppStatuses []database.WorkspaceAppStatus
workspaceAppAuditSessions []database.WorkspaceAppAuditSession
workspaceAppStatsLastInsertID int64
workspaceAppStats []database.WorkspaceAppStat
@ -3697,6 +3698,34 @@ func (q *FakeQuerier) GetLatestCryptoKeyByFeature(_ context.Context, feature dat
return latestKey, nil
}
func (q *FakeQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
// Map to track latest status per workspace ID
latestByWorkspace := make(map[uuid.UUID]database.WorkspaceAppStatus)
// Find latest status for each workspace ID
for _, appStatus := range q.workspaceAppStatuses {
if !slices.Contains(ids, appStatus.WorkspaceID) {
continue
}
current, exists := latestByWorkspace[appStatus.WorkspaceID]
if !exists || appStatus.CreatedAt.After(current.CreatedAt) {
latestByWorkspace[appStatus.WorkspaceID] = appStatus
}
}
// Convert map to slice
appStatuses := make([]database.WorkspaceAppStatus, 0, len(latestByWorkspace))
for _, status := range latestByWorkspace {
appStatuses = append(appStatuses, status)
}
return appStatuses, nil
}
func (q *FakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -7488,6 +7517,21 @@ func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg d
return q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, arg)
}
func (q *FakeQuerier) GetWorkspaceAppStatusesByAppIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
statuses := make([]database.WorkspaceAppStatus, 0)
for _, status := range q.workspaceAppStatuses {
for _, id := range ids {
if status.AppID == id {
statuses = append(statuses, status)
}
}
}
return statuses, nil
}
func (q *FakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) ([]database.WorkspaceApp, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -9584,6 +9628,31 @@ InsertWorkspaceAppStatsLoop:
return nil
}
func (q *FakeQuerier) InsertWorkspaceAppStatus(_ context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.WorkspaceAppStatus{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
status := database.WorkspaceAppStatus{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
WorkspaceID: arg.WorkspaceID,
AgentID: arg.AgentID,
AppID: arg.AppID,
NeedsUserAttention: arg.NeedsUserAttention,
State: arg.State,
Message: arg.Message,
Uri: arg.Uri,
Icon: arg.Icon,
}
q.workspaceAppStatuses = append(q.workspaceAppStatuses, status)
return status, nil
}
func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.InsertWorkspaceBuildParams) error {
if err := validateDatabaseType(arg); err != nil {
return err

View File

@ -858,6 +858,13 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat
return r0, r1
}
func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
start := time.Now()
r0, r1 := m.s.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids)
m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusesByWorkspaceIDs").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) {
start := time.Now()
build, err := m.s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID)
@ -1670,6 +1677,13 @@ func (m queryMetricsStore) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context,
return app, err
}
func (m queryMetricsStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
start := time.Now()
r0, r1 := m.s.GetWorkspaceAppStatusesByAppIDs(ctx, ids)
m.queryLatencies.WithLabelValues("GetWorkspaceAppStatusesByAppIDs").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) {
start := time.Now()
apps, err := m.s.GetWorkspaceAppsByAgentID(ctx, agentID)
@ -2265,6 +2279,13 @@ func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg data
return r0
}
func (m queryMetricsStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) {
start := time.Now()
r0, r1 := m.s.InsertWorkspaceAppStatus(ctx, arg)
m.queryLatencies.WithLabelValues("InsertWorkspaceAppStatus").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
start := time.Now()
err := m.s.InsertWorkspaceBuild(ctx, arg)

View File

@ -1729,6 +1729,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature)
}
// GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method.
func (m *MockStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", ctx, ids)
ret0, _ := ret[0].([]database.WorkspaceAppStatus)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetLatestWorkspaceAppStatusesByWorkspaceIDs indicates an expected call of GetLatestWorkspaceAppStatusesByWorkspaceIDs.
func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusesByWorkspaceIDs), ctx, ids)
}
// GetLatestWorkspaceBuildByWorkspaceID mocks base method.
func (m *MockStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) {
m.ctrl.T.Helper()
@ -3499,6 +3514,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAppByAgentIDAndSlug(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppByAgentIDAndSlug", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppByAgentIDAndSlug), ctx, arg)
}
// GetWorkspaceAppStatusesByAppIDs mocks base method.
func (m *MockStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetWorkspaceAppStatusesByAppIDs", ctx, ids)
ret0, _ := ret[0].([]database.WorkspaceAppStatus)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetWorkspaceAppStatusesByAppIDs indicates an expected call of GetWorkspaceAppStatusesByAppIDs.
func (mr *MockStoreMockRecorder) GetWorkspaceAppStatusesByAppIDs(ctx, ids any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppStatusesByAppIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppStatusesByAppIDs), ctx, ids)
}
// GetWorkspaceAppsByAgentID mocks base method.
func (m *MockStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) {
m.ctrl.T.Helper()
@ -4776,6 +4806,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAppStats(ctx, arg any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStats), ctx, arg)
}
// InsertWorkspaceAppStatus mocks base method.
func (m *MockStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertWorkspaceAppStatus", ctx, arg)
ret0, _ := ret[0].(database.WorkspaceAppStatus)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertWorkspaceAppStatus indicates an expected call of InsertWorkspaceAppStatus.
func (mr *MockStoreMockRecorder) InsertWorkspaceAppStatus(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStatus", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStatus), ctx, arg)
}
// InsertWorkspaceBuild mocks base method.
func (m *MockStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
m.ctrl.T.Helper()

View File

@ -293,6 +293,12 @@ CREATE TYPE workspace_app_open_in AS ENUM (
'slim-window'
);
CREATE TYPE workspace_app_status_state AS ENUM (
'working',
'complete',
'failure'
);
CREATE TYPE workspace_transition AS ENUM (
'start',
'stop',
@ -1896,6 +1902,19 @@ CREATE SEQUENCE workspace_app_stats_id_seq
ALTER SEQUENCE workspace_app_stats_id_seq OWNED BY workspace_app_stats.id;
CREATE TABLE workspace_app_statuses (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
agent_id uuid NOT NULL,
app_id uuid NOT NULL,
workspace_id uuid NOT NULL,
state workspace_app_status_state NOT NULL,
needs_user_attention boolean NOT NULL,
message text NOT NULL,
uri text,
icon text
);
CREATE TABLE workspace_apps (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@ -2359,6 +2378,9 @@ ALTER TABLE ONLY workspace_app_stats
ALTER TABLE ONLY workspace_app_stats
ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id);
ALTER TABLE ONLY workspace_app_statuses
ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id);
ALTER TABLE ONLY workspace_apps
ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug);
@ -2451,6 +2473,8 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses USING btree (workspace_id, created_at DESC);
CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
@ -2802,6 +2826,15 @@ ALTER TABLE ONLY workspace_app_stats
ALTER TABLE ONLY workspace_app_stats
ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id);
ALTER TABLE ONLY workspace_app_statuses
ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id);
ALTER TABLE ONLY workspace_app_statuses
ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id);
ALTER TABLE ONLY workspace_app_statuses
ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id);
ALTER TABLE ONLY workspace_apps
ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;

View File

@ -73,6 +73,9 @@ const (
ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id);
ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id);
ForeignKeyWorkspaceAppStatusesAgentID ForeignKeyConstraint = "workspace_app_statuses_agent_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id);
ForeignKeyWorkspaceAppStatusesAppID ForeignKeyConstraint = "workspace_app_statuses_app_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id);
ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id);
ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE;
ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;

View File

@ -0,0 +1,3 @@
DROP TABLE workspace_app_statuses;
DROP TYPE workspace_app_status_state;

View File

@ -0,0 +1,28 @@
CREATE TYPE workspace_app_status_state AS ENUM ('working', 'complete', 'failure');
-- Workspace app statuses allow agents to report statuses per-app in the UI.
CREATE TABLE workspace_app_statuses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- The agent that the status is for.
agent_id UUID NOT NULL REFERENCES workspace_agents(id),
-- The slug of the app that the status is for. This will be used
-- to reference the app in the UI - with an icon.
app_id UUID NOT NULL REFERENCES workspace_apps(id),
-- workspace_id is the workspace that the status is for.
workspace_id UUID NOT NULL REFERENCES workspaces(id),
-- The status determines how the status is displayed in the UI.
state workspace_app_status_state NOT NULL,
-- Whether the status needs user attention.
needs_user_attention BOOLEAN NOT NULL,
-- The message is the main text that will be displayed in the UI.
message TEXT NOT NULL,
-- The URI of the resource that the status is for.
-- e.g. https://github.com/org/repo/pull/123
-- e.g. file:///path/to/file
uri TEXT,
-- Icon is an external URL to an icon that will be rendered in the UI.
icon TEXT
);
CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses(workspace_id, created_at DESC);

View File

@ -0,0 +1,19 @@
INSERT INTO workspace_app_statuses (
id,
created_at,
agent_id,
app_id,
workspace_id,
state,
needs_user_attention,
message
) VALUES (
gen_random_uuid(),
NOW(),
'7a1ce5f8-8d00-431c-ad1b-97a846512804',
'36b65d0c-042b-4653-863a-655ee739861c',
'3a9a1feb-e89d-457c-9d53-ac751b198ebe',
'working',
false,
'Creating SQL queries for test data!'
);

View File

@ -2414,6 +2414,67 @@ func AllWorkspaceAppOpenInValues() []WorkspaceAppOpenIn {
}
}
type WorkspaceAppStatusState string
const (
WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working"
WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete"
WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure"
)
func (e *WorkspaceAppStatusState) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WorkspaceAppStatusState(s)
case string:
*e = WorkspaceAppStatusState(s)
default:
return fmt.Errorf("unsupported scan type for WorkspaceAppStatusState: %T", src)
}
return nil
}
type NullWorkspaceAppStatusState struct {
WorkspaceAppStatusState WorkspaceAppStatusState `json:"workspace_app_status_state"`
Valid bool `json:"valid"` // Valid is true if WorkspaceAppStatusState is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWorkspaceAppStatusState) Scan(value interface{}) error {
if value == nil {
ns.WorkspaceAppStatusState, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WorkspaceAppStatusState.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWorkspaceAppStatusState) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WorkspaceAppStatusState), nil
}
func (e WorkspaceAppStatusState) Valid() bool {
switch e {
case WorkspaceAppStatusStateWorking,
WorkspaceAppStatusStateComplete,
WorkspaceAppStatusStateFailure:
return true
}
return false
}
func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState {
return []WorkspaceAppStatusState{
WorkspaceAppStatusStateWorking,
WorkspaceAppStatusStateComplete,
WorkspaceAppStatusStateFailure,
}
}
type WorkspaceTransition string
const (
@ -3515,6 +3576,19 @@ type WorkspaceAppStat struct {
Requests int32 `db:"requests" json:"requests"`
}
type WorkspaceAppStatus struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
State WorkspaceAppStatusState `db:"state" json:"state"`
NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"`
Message string `db:"message" json:"message"`
Uri sql.NullString `db:"uri" json:"uri"`
Icon sql.NullString `db:"icon" json:"icon"`
}
// Joins in the username + avatar url of the initiated by user.
type WorkspaceBuild struct {
ID uuid.UUID `db:"id" json:"id"`

View File

@ -198,6 +198,7 @@ type sqlcQuerier interface {
GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error)
GetLastUpdateCheck(ctx context.Context) (string, error)
GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error)
GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error)
GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error)
GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error)
GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error)
@ -369,6 +370,7 @@ type sqlcQuerier interface {
GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error)
GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgent, error)
GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error)
GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error)
GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error)
GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error)
GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error)
@ -474,6 +476,7 @@ type sqlcQuerier interface {
InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error
InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error)
InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error
InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error)
InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error
InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error
InsertWorkspaceModule(ctx context.Context, arg InsertWorkspaceModuleParams) (WorkspaceModule, error)

View File

@ -15108,6 +15108,48 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups
return new_or_stale, err
}
const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many
SELECT DISTINCT ON (workspace_id)
id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon
FROM workspace_app_statuses
WHERE workspace_id = ANY($1 :: uuid[])
ORDER BY workspace_id, created_at DESC
`
func (q *sqlQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) {
rows, err := q.db.QueryContext(ctx, getLatestWorkspaceAppStatusesByWorkspaceIDs, pq.Array(ids))
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceAppStatus
for rows.Next() {
var i WorkspaceAppStatus
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.AppID,
&i.WorkspaceID,
&i.State,
&i.NeedsUserAttention,
&i.Message,
&i.Uri,
&i.Icon,
); 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 getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 AND slug = $2
`
@ -15143,6 +15185,44 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg Ge
return i, err
}
const getWorkspaceAppStatusesByAppIDs = `-- name: GetWorkspaceAppStatusesByAppIDs :many
SELECT id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon FROM workspace_app_statuses WHERE app_id = ANY($1 :: uuid [ ])
`
func (q *sqlQuerier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaceAppStatusesByAppIDs, pq.Array(ids))
if err != nil {
return nil, err
}
defer rows.Close()
var items []WorkspaceAppStatus
for rows.Next() {
var i WorkspaceAppStatus
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.AppID,
&i.WorkspaceID,
&i.State,
&i.NeedsUserAttention,
&i.Message,
&i.Uri,
&i.Icon,
); 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 getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 ORDER BY slug ASC
`
@ -15373,6 +15453,54 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
return i, err
}
const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one
INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon
`
type InsertWorkspaceAppStatusParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
State WorkspaceAppStatusState `db:"state" json:"state"`
Message string `db:"message" json:"message"`
NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"`
Uri sql.NullString `db:"uri" json:"uri"`
Icon sql.NullString `db:"icon" json:"icon"`
}
func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) {
row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus,
arg.ID,
arg.CreatedAt,
arg.WorkspaceID,
arg.AgentID,
arg.AppID,
arg.State,
arg.Message,
arg.NeedsUserAttention,
arg.Uri,
arg.Icon,
)
var i WorkspaceAppStatus
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.AppID,
&i.WorkspaceID,
&i.State,
&i.NeedsUserAttention,
&i.Message,
&i.Uri,
&i.Icon,
)
return i, err
}
const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec
UPDATE
workspace_apps

View File

@ -42,3 +42,18 @@ SET
health = $2
WHERE
id = $1;
-- name: InsertWorkspaceAppStatus :one
INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
-- name: GetWorkspaceAppStatusesByAppIDs :many
SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]);
-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many
SELECT DISTINCT ON (workspace_id)
*
FROM workspace_app_statuses
WHERE workspace_id = ANY(@ids :: uuid[])
ORDER BY workspace_id, created_at DESC;

View File

@ -86,6 +86,7 @@ const (
UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id);
UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id);
UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id);
UniqueWorkspaceAppStatusesPkey UniqueConstraint = "workspace_app_statuses_pkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id);
UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug);
UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id);
UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name);