Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adae949525 | |||
| 934c2ac0c5 | |||
| 799b3e0c59 | |||
| ae7674e44c | |||
| e09c9aa4ba | |||
| edcbed8a0e | |||
| b241a780f9 |
@@ -1461,6 +1461,7 @@ func New(options *Options) *API {
|
||||
r.Route("/tasks/{task}", func(r chi.Router) {
|
||||
r.Post("/log-snapshot", api.postWorkspaceAgentTaskLogSnapshot)
|
||||
})
|
||||
r.Post("/git-events", api.postWorkspaceAgentGitEvent)
|
||||
})
|
||||
r.Route("/{workspaceagent}", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
||||
@@ -1677,6 +1677,10 @@ func (q *querier) CountUnreadInboxNotificationsByUserID(ctx context.Context, use
|
||||
return q.db.CountUnreadInboxNotificationsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) CountWorkspaceGitEvents(ctx context.Context, arg database.CountWorkspaceGitEventsParams) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
obj := rbac.ResourceUserSecret.WithOwner(arg.UserID.String())
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
|
||||
@@ -1911,6 +1915,10 @@ func (q *querier) DeleteOldWorkspaceAgentStats(ctx context.Context) error {
|
||||
return q.db.DeleteOldWorkspaceAgentStats(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) DeleteOldWorkspaceGitEvents(ctx context.Context, before time.Time) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error {
|
||||
return deleteQ[database.OrganizationMember](q.log, q.auth, func(ctx context.Context, arg database.DeleteOrganizationMemberParams) (database.OrganizationMember, error) {
|
||||
member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||
@@ -3996,6 +4004,14 @@ func (q *querier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceApp
|
||||
return fetch(q.log, q.auth, q.db.GetWorkspaceByWorkspaceAppID)(ctx, workspaceAppID)
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceGitEventByID(ctx context.Context, id uuid.UUID) (database.WorkspaceGitEvent, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceGitEventsBySessionID(ctx context.Context, sessionID string) ([]database.WorkspaceGitEvent, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) GetWorkspaceModulesByJobID(ctx context.Context, jobID uuid.UUID) ([]database.WorkspaceModule, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
@@ -4744,6 +4760,12 @@ func (q *querier) InsertWorkspaceBuildParameters(ctx context.Context, arg databa
|
||||
return q.db.InsertWorkspaceBuildParameters(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWorkspaceGitEvent(ctx context.Context, arg database.InsertWorkspaceGitEventParams) (database.WorkspaceGitEvent, error) {
|
||||
// This is called from the workspace agent, which is already authenticated
|
||||
// via agent token middleware. No additional RBAC check is required.
|
||||
return q.db.InsertWorkspaceGitEvent(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) InsertWorkspaceModule(ctx context.Context, arg database.InsertWorkspaceModuleParams) (database.WorkspaceModule, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return database.WorkspaceModule{}, err
|
||||
@@ -4849,6 +4871,14 @@ func (q *querier) ListWorkspaceAgentPortShares(ctx context.Context, workspaceID
|
||||
return q.db.ListWorkspaceAgentPortShares(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (q *querier) ListWorkspaceGitEventSessions(ctx context.Context, arg database.ListWorkspaceGitEventSessionsParams) ([]database.ListWorkspaceGitEventSessionsRow, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) ListWorkspaceGitEvents(ctx context.Context, arg database.ListWorkspaceGitEventsParams) ([]database.WorkspaceGitEvent, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (q *querier) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
|
||||
resource := rbac.ResourceInboxNotification.WithOwner(arg.UserID.String())
|
||||
|
||||
|
||||
@@ -279,6 +279,14 @@ func (m queryMetricsStore) CountUnreadInboxNotificationsByUserID(ctx context.Con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CountWorkspaceGitEvents(ctx context.Context, arg database.CountWorkspaceGitEventsParams) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CountWorkspaceGitEvents(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("CountWorkspaceGitEvents").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountWorkspaceGitEvents").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.CreateUserSecret(ctx, arg)
|
||||
@@ -519,6 +527,14 @@ func (m queryMetricsStore) DeleteOldWorkspaceAgentStats(ctx context.Context) err
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteOldWorkspaceGitEvents(ctx context.Context, before time.Time) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.DeleteOldWorkspaceGitEvents(ctx, before)
|
||||
m.queryLatencies.WithLabelValues("DeleteOldWorkspaceGitEvents").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldWorkspaceGitEvents").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.DeleteOrganizationMember(ctx, arg)
|
||||
@@ -2502,6 +2518,22 @@ func (m queryMetricsStore) GetWorkspaceByWorkspaceAppID(ctx context.Context, wor
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceGitEventByID(ctx context.Context, id uuid.UUID) (database.WorkspaceGitEvent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceGitEventByID(ctx, id)
|
||||
m.queryLatencies.WithLabelValues("GetWorkspaceGitEventByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceGitEventByID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceGitEventsBySessionID(ctx context.Context, sessionID string) ([]database.WorkspaceGitEvent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceGitEventsBySessionID(ctx, sessionID)
|
||||
m.queryLatencies.WithLabelValues("GetWorkspaceGitEventsBySessionID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetWorkspaceGitEventsBySessionID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetWorkspaceModulesByJobID(ctx context.Context, jobID uuid.UUID) ([]database.WorkspaceModule, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetWorkspaceModulesByJobID(ctx, jobID)
|
||||
@@ -3158,6 +3190,14 @@ func (m queryMetricsStore) InsertWorkspaceBuildParameters(ctx context.Context, a
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertWorkspaceGitEvent(ctx context.Context, arg database.InsertWorkspaceGitEventParams) (database.WorkspaceGitEvent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertWorkspaceGitEvent(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("InsertWorkspaceGitEvent").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertWorkspaceGitEvent").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) InsertWorkspaceModule(ctx context.Context, arg database.InsertWorkspaceModuleParams) (database.WorkspaceModule, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.InsertWorkspaceModule(ctx, arg)
|
||||
@@ -3270,6 +3310,22 @@ func (m queryMetricsStore) ListWorkspaceAgentPortShares(ctx context.Context, wor
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ListWorkspaceGitEventSessions(ctx context.Context, arg database.ListWorkspaceGitEventSessionsParams) ([]database.ListWorkspaceGitEventSessionsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ListWorkspaceGitEventSessions(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("ListWorkspaceGitEventSessions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListWorkspaceGitEventSessions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ListWorkspaceGitEvents(ctx context.Context, arg database.ListWorkspaceGitEventsParams) ([]database.WorkspaceGitEvent, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ListWorkspaceGitEvents(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("ListWorkspaceGitEvents").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListWorkspaceGitEvents").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.MarkAllInboxNotificationsAsRead(ctx, arg)
|
||||
|
||||
@@ -411,6 +411,21 @@ func (mr *MockStoreMockRecorder) CountUnreadInboxNotificationsByUserID(ctx, user
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountUnreadInboxNotificationsByUserID", reflect.TypeOf((*MockStore)(nil).CountUnreadInboxNotificationsByUserID), ctx, userID)
|
||||
}
|
||||
|
||||
// CountWorkspaceGitEvents mocks base method.
|
||||
func (m *MockStore) CountWorkspaceGitEvents(ctx context.Context, arg database.CountWorkspaceGitEventsParams) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CountWorkspaceGitEvents", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CountWorkspaceGitEvents indicates an expected call of CountWorkspaceGitEvents.
|
||||
func (mr *MockStoreMockRecorder) CountWorkspaceGitEvents(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountWorkspaceGitEvents", reflect.TypeOf((*MockStore)(nil).CountWorkspaceGitEvents), ctx, arg)
|
||||
}
|
||||
|
||||
// CreateUserSecret mocks base method.
|
||||
func (m *MockStore) CreateUserSecret(ctx context.Context, arg database.CreateUserSecretParams) (database.UserSecret, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -840,6 +855,21 @@ func (mr *MockStoreMockRecorder) DeleteOldWorkspaceAgentStats(ctx any) *gomock.C
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).DeleteOldWorkspaceAgentStats), ctx)
|
||||
}
|
||||
|
||||
// DeleteOldWorkspaceGitEvents mocks base method.
|
||||
func (m *MockStore) DeleteOldWorkspaceGitEvents(ctx context.Context, before time.Time) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteOldWorkspaceGitEvents", ctx, before)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteOldWorkspaceGitEvents indicates an expected call of DeleteOldWorkspaceGitEvents.
|
||||
func (mr *MockStoreMockRecorder) DeleteOldWorkspaceGitEvents(ctx, before any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldWorkspaceGitEvents", reflect.TypeOf((*MockStore)(nil).DeleteOldWorkspaceGitEvents), ctx, before)
|
||||
}
|
||||
|
||||
// DeleteOrganizationMember mocks base method.
|
||||
func (m *MockStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4679,6 +4709,36 @@ func (mr *MockStoreMockRecorder) GetWorkspaceByWorkspaceAppID(ctx, workspaceAppI
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceByWorkspaceAppID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceByWorkspaceAppID), ctx, workspaceAppID)
|
||||
}
|
||||
|
||||
// GetWorkspaceGitEventByID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceGitEventByID(ctx context.Context, id uuid.UUID) (database.WorkspaceGitEvent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWorkspaceGitEventByID", ctx, id)
|
||||
ret0, _ := ret[0].(database.WorkspaceGitEvent)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWorkspaceGitEventByID indicates an expected call of GetWorkspaceGitEventByID.
|
||||
func (mr *MockStoreMockRecorder) GetWorkspaceGitEventByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceGitEventByID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceGitEventByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetWorkspaceGitEventsBySessionID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceGitEventsBySessionID(ctx context.Context, sessionID string) ([]database.WorkspaceGitEvent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWorkspaceGitEventsBySessionID", ctx, sessionID)
|
||||
ret0, _ := ret[0].([]database.WorkspaceGitEvent)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWorkspaceGitEventsBySessionID indicates an expected call of GetWorkspaceGitEventsBySessionID.
|
||||
func (mr *MockStoreMockRecorder) GetWorkspaceGitEventsBySessionID(ctx, sessionID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceGitEventsBySessionID", reflect.TypeOf((*MockStore)(nil).GetWorkspaceGitEventsBySessionID), ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetWorkspaceModulesByJobID mocks base method.
|
||||
func (m *MockStore) GetWorkspaceModulesByJobID(ctx context.Context, jobID uuid.UUID) ([]database.WorkspaceModule, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -5907,6 +5967,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceBuildParameters(ctx, arg any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceBuildParameters), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertWorkspaceGitEvent mocks base method.
|
||||
func (m *MockStore) InsertWorkspaceGitEvent(ctx context.Context, arg database.InsertWorkspaceGitEventParams) (database.WorkspaceGitEvent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "InsertWorkspaceGitEvent", ctx, arg)
|
||||
ret0, _ := ret[0].(database.WorkspaceGitEvent)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// InsertWorkspaceGitEvent indicates an expected call of InsertWorkspaceGitEvent.
|
||||
func (mr *MockStoreMockRecorder) InsertWorkspaceGitEvent(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceGitEvent", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceGitEvent), ctx, arg)
|
||||
}
|
||||
|
||||
// InsertWorkspaceModule mocks base method.
|
||||
func (m *MockStore) InsertWorkspaceModule(ctx context.Context, arg database.InsertWorkspaceModuleParams) (database.WorkspaceModule, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -6132,6 +6207,36 @@ func (mr *MockStoreMockRecorder) ListWorkspaceAgentPortShares(ctx, workspaceID a
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceAgentPortShares", reflect.TypeOf((*MockStore)(nil).ListWorkspaceAgentPortShares), ctx, workspaceID)
|
||||
}
|
||||
|
||||
// ListWorkspaceGitEventSessions mocks base method.
|
||||
func (m *MockStore) ListWorkspaceGitEventSessions(ctx context.Context, arg database.ListWorkspaceGitEventSessionsParams) ([]database.ListWorkspaceGitEventSessionsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListWorkspaceGitEventSessions", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.ListWorkspaceGitEventSessionsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListWorkspaceGitEventSessions indicates an expected call of ListWorkspaceGitEventSessions.
|
||||
func (mr *MockStoreMockRecorder) ListWorkspaceGitEventSessions(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceGitEventSessions", reflect.TypeOf((*MockStore)(nil).ListWorkspaceGitEventSessions), ctx, arg)
|
||||
}
|
||||
|
||||
// ListWorkspaceGitEvents mocks base method.
|
||||
func (m *MockStore) ListWorkspaceGitEvents(ctx context.Context, arg database.ListWorkspaceGitEventsParams) ([]database.WorkspaceGitEvent, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListWorkspaceGitEvents", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.WorkspaceGitEvent)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListWorkspaceGitEvents indicates an expected call of ListWorkspaceGitEvents.
|
||||
func (mr *MockStoreMockRecorder) ListWorkspaceGitEvents(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkspaceGitEvents", reflect.TypeOf((*MockStore)(nil).ListWorkspaceGitEvents), ctx, arg)
|
||||
}
|
||||
|
||||
// MarkAllInboxNotificationsAsRead mocks base method.
|
||||
func (m *MockStore) MarkAllInboxNotificationsAsRead(ctx context.Context, arg database.MarkAllInboxNotificationsAsReadParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+45
@@ -2724,6 +2724,26 @@ CREATE VIEW workspace_build_with_user AS
|
||||
|
||||
COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.';
|
||||
|
||||
CREATE TABLE workspace_git_events (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
agent_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
organization_id uuid NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
session_id text,
|
||||
commit_sha text,
|
||||
commit_message text,
|
||||
branch text,
|
||||
repo_name text,
|
||||
files_changed text[],
|
||||
agent_name text,
|
||||
ai_bridge_interception_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE workspace_git_events IS 'Stores git events (commits, pushes, session boundaries) captured from AI coding sessions in workspaces.';
|
||||
|
||||
CREATE TABLE workspaces (
|
||||
id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
@@ -3254,6 +3274,9 @@ ALTER TABLE ONLY workspace_builds
|
||||
ALTER TABLE ONLY workspace_builds
|
||||
ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
|
||||
|
||||
ALTER TABLE ONLY workspace_git_events
|
||||
ADD CONSTRAINT workspace_git_events_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY workspace_proxies
|
||||
ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -3376,6 +3399,16 @@ CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app
|
||||
|
||||
CREATE INDEX idx_workspace_builds_initiator_id ON workspace_builds USING btree (initiator_id);
|
||||
|
||||
CREATE INDEX idx_workspace_git_events_org ON workspace_git_events USING btree (organization_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_workspace_git_events_owner ON workspace_git_events USING btree (owner_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_workspace_git_events_pagination ON workspace_git_events USING btree (created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX idx_workspace_git_events_session ON workspace_git_events USING btree (session_id) WHERE (session_id IS NOT NULL);
|
||||
|
||||
CREATE INDEX idx_workspace_git_events_workspace ON workspace_git_events 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);
|
||||
@@ -3827,6 +3860,18 @@ ALTER TABLE ONLY workspace_builds
|
||||
ALTER TABLE ONLY workspace_builds
|
||||
ADD CONSTRAINT workspace_builds_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_git_events
|
||||
ADD CONSTRAINT workspace_git_events_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_git_events
|
||||
ADD CONSTRAINT workspace_git_events_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_git_events
|
||||
ADD CONSTRAINT workspace_git_events_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_git_events
|
||||
ADD CONSTRAINT workspace_git_events_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY workspace_modules
|
||||
ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ const (
|
||||
ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL;
|
||||
ForeignKeyWorkspaceBuildsWorkspaceID ForeignKeyConstraint = "workspace_builds_workspace_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceGitEventsAgentID ForeignKeyConstraint = "workspace_git_events_agent_id_fkey" // ALTER TABLE ONLY workspace_git_events ADD CONSTRAINT workspace_git_events_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceGitEventsOrganizationID ForeignKeyConstraint = "workspace_git_events_organization_id_fkey" // ALTER TABLE ONLY workspace_git_events ADD CONSTRAINT workspace_git_events_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceGitEventsOwnerID ForeignKeyConstraint = "workspace_git_events_owner_id_fkey" // ALTER TABLE ONLY workspace_git_events ADD CONSTRAINT workspace_git_events_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceGitEventsWorkspaceID ForeignKeyConstraint = "workspace_git_events_workspace_id_fkey" // ALTER TABLE ONLY workspace_git_events ADD CONSTRAINT workspace_git_events_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceModulesJobID ForeignKeyConstraint = "workspace_modules_job_id_fkey" // ALTER TABLE ONLY workspace_modules ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceResourceMetadataWorkspaceResourceID ForeignKeyConstraint = "workspace_resource_metadata_workspace_resource_id_fkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE;
|
||||
ForeignKeyWorkspaceResourcesJobID ForeignKeyConstraint = "workspace_resources_job_id_fkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS workspace_git_events;
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE workspace_git_events (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES workspace_agents(id) ON DELETE CASCADE,
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
|
||||
-- Event data
|
||||
event_type TEXT NOT NULL, -- 'session_start', 'commit', 'push', 'session_end'
|
||||
session_id TEXT, -- links to agent session; nullable for orphan commits
|
||||
commit_sha TEXT, -- for commit/push events
|
||||
commit_message TEXT, -- for commit events
|
||||
branch TEXT,
|
||||
repo_name TEXT, -- repo basename or remote URL
|
||||
files_changed TEXT[], -- array of file paths
|
||||
agent_name TEXT, -- 'claude-code', 'gemini-cli', 'unknown'
|
||||
|
||||
-- AI Bridge join key (nullable — populated when AI Bridge is active)
|
||||
ai_bridge_interception_id UUID, -- nullable FK to aibridge_interceptions(id)
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_workspace_git_events_workspace ON workspace_git_events(workspace_id, created_at DESC);
|
||||
CREATE INDEX idx_workspace_git_events_owner ON workspace_git_events(owner_id, created_at DESC);
|
||||
CREATE INDEX idx_workspace_git_events_session ON workspace_git_events(session_id) WHERE session_id IS NOT NULL;
|
||||
CREATE INDEX idx_workspace_git_events_org ON workspace_git_events(organization_id, created_at DESC);
|
||||
-- Cursor pagination index (matching AI Bridge pattern)
|
||||
CREATE INDEX idx_workspace_git_events_pagination ON workspace_git_events(created_at DESC, id DESC);
|
||||
|
||||
COMMENT ON TABLE workspace_git_events IS 'Stores git events (commits, pushes, session boundaries) captured from AI coding sessions in workspaces.';
|
||||
@@ -5028,6 +5028,25 @@ type WorkspaceBuildTable struct {
|
||||
HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"`
|
||||
}
|
||||
|
||||
// Stores git events (commits, pushes, session boundaries) captured from AI coding sessions in workspaces.
|
||||
type WorkspaceGitEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SessionID sql.NullString `db:"session_id" json:"session_id"`
|
||||
CommitSha sql.NullString `db:"commit_sha" json:"commit_sha"`
|
||||
CommitMessage sql.NullString `db:"commit_message" json:"commit_message"`
|
||||
Branch sql.NullString `db:"branch" json:"branch"`
|
||||
RepoName sql.NullString `db:"repo_name" json:"repo_name"`
|
||||
FilesChanged []string `db:"files_changed" json:"files_changed"`
|
||||
AgentName sql.NullString `db:"agent_name" json:"agent_name"`
|
||||
AiBridgeInterceptionID uuid.NullUUID `db:"ai_bridge_interception_id" json:"ai_bridge_interception_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type WorkspaceLatestBuild struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
|
||||
@@ -77,6 +77,7 @@ type sqlcQuerier interface {
|
||||
// CountPendingNonActivePrebuilds returns the number of pending prebuilds for non-active template versions
|
||||
CountPendingNonActivePrebuilds(ctx context.Context) ([]CountPendingNonActivePrebuildsRow, error)
|
||||
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||
CountWorkspaceGitEvents(ctx context.Context, arg CountWorkspaceGitEventsParams) (int64, error)
|
||||
CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error)
|
||||
CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error)
|
||||
DeleteAPIKeyByID(ctx context.Context, id string) error
|
||||
@@ -124,6 +125,7 @@ type sqlcQuerier interface {
|
||||
// Logs can take up a lot of space, so it's important we clean up frequently.
|
||||
DeleteOldWorkspaceAgentLogs(ctx context.Context, threshold time.Time) (int64, error)
|
||||
DeleteOldWorkspaceAgentStats(ctx context.Context) error
|
||||
DeleteOldWorkspaceGitEvents(ctx context.Context, before time.Time) (int64, error)
|
||||
DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error
|
||||
DeleteProvisionerKey(ctx context.Context, id uuid.UUID) error
|
||||
DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error
|
||||
@@ -519,6 +521,8 @@ type sqlcQuerier interface {
|
||||
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
|
||||
GetWorkspaceByResourceID(ctx context.Context, resourceID uuid.UUID) (Workspace, error)
|
||||
GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error)
|
||||
GetWorkspaceGitEventByID(ctx context.Context, id uuid.UUID) (WorkspaceGitEvent, error)
|
||||
GetWorkspaceGitEventsBySessionID(ctx context.Context, sessionID string) ([]WorkspaceGitEvent, error)
|
||||
GetWorkspaceModulesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceModule, error)
|
||||
GetWorkspaceModulesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceModule, error)
|
||||
GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
|
||||
@@ -628,6 +632,7 @@ type sqlcQuerier interface {
|
||||
InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error)
|
||||
InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error
|
||||
InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error
|
||||
InsertWorkspaceGitEvent(ctx context.Context, arg InsertWorkspaceGitEventParams) (WorkspaceGitEvent, error)
|
||||
InsertWorkspaceModule(ctx context.Context, arg InsertWorkspaceModuleParams) (WorkspaceModule, error)
|
||||
InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error)
|
||||
InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error)
|
||||
@@ -644,6 +649,8 @@ type sqlcQuerier interface {
|
||||
ListTasks(ctx context.Context, arg ListTasksParams) ([]Task, error)
|
||||
ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]UserSecret, error)
|
||||
ListWorkspaceAgentPortShares(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgentPortShare, error)
|
||||
ListWorkspaceGitEventSessions(ctx context.Context, arg ListWorkspaceGitEventSessionsParams) ([]ListWorkspaceGitEventSessionsRow, error)
|
||||
ListWorkspaceGitEvents(ctx context.Context, arg ListWorkspaceGitEventsParams) ([]WorkspaceGitEvent, error)
|
||||
MarkAllInboxNotificationsAsRead(ctx context.Context, arg MarkAllInboxNotificationsAsReadParams) error
|
||||
OIDCClaimFieldValues(ctx context.Context, arg OIDCClaimFieldValuesParams) ([]string, error)
|
||||
// OIDCClaimFields returns a list of distinct keys in the the merged_claims fields.
|
||||
|
||||
@@ -21835,6 +21835,494 @@ func (q *sqlQuerier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Contex
|
||||
return err
|
||||
}
|
||||
|
||||
const countWorkspaceGitEvents = `-- name: CountWorkspaceGitEvents :one
|
||||
SELECT
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
CASE
|
||||
WHEN $1::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = $1::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = $2::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = $3::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $4::text != '' THEN wge.event_type = $4::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $5::text != '' THEN wge.session_id = $5::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $6::text != '' THEN wge.agent_name = $6::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $7::text != '' THEN wge.repo_name = $7::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $8::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= $8::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
`
|
||||
|
||||
type CountWorkspaceGitEventsParams struct {
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SessionID string `db:"session_id" json:"session_id"`
|
||||
AgentName string `db:"agent_name" json:"agent_name"`
|
||||
RepoName string `db:"repo_name" json:"repo_name"`
|
||||
Since time.Time `db:"since" json:"since"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) CountWorkspaceGitEvents(ctx context.Context, arg CountWorkspaceGitEventsParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countWorkspaceGitEvents,
|
||||
arg.OwnerID,
|
||||
arg.WorkspaceID,
|
||||
arg.OrganizationID,
|
||||
arg.EventType,
|
||||
arg.SessionID,
|
||||
arg.AgentName,
|
||||
arg.RepoName,
|
||||
arg.Since,
|
||||
)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const deleteOldWorkspaceGitEvents = `-- name: DeleteOldWorkspaceGitEvents :one
|
||||
WITH deleted AS (
|
||||
DELETE FROM workspace_git_events
|
||||
WHERE created_at < $1::timestamptz
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
deleted
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteOldWorkspaceGitEvents(ctx context.Context, before time.Time) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, deleteOldWorkspaceGitEvents, before)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getWorkspaceGitEventByID = `-- name: GetWorkspaceGitEventByID :one
|
||||
SELECT
|
||||
id, workspace_id, agent_id, owner_id, organization_id, event_type, session_id, commit_sha, commit_message, branch, repo_name, files_changed, agent_name, ai_bridge_interception_id, created_at
|
||||
FROM
|
||||
workspace_git_events
|
||||
WHERE
|
||||
id = $1::uuid
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceGitEventByID(ctx context.Context, id uuid.UUID) (WorkspaceGitEvent, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWorkspaceGitEventByID, id)
|
||||
var i WorkspaceGitEvent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.OwnerID,
|
||||
&i.OrganizationID,
|
||||
&i.EventType,
|
||||
&i.SessionID,
|
||||
&i.CommitSha,
|
||||
&i.CommitMessage,
|
||||
&i.Branch,
|
||||
&i.RepoName,
|
||||
pq.Array(&i.FilesChanged),
|
||||
&i.AgentName,
|
||||
&i.AiBridgeInterceptionID,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceGitEventsBySessionID = `-- name: GetWorkspaceGitEventsBySessionID :many
|
||||
SELECT
|
||||
id, workspace_id, agent_id, owner_id, organization_id, event_type, session_id, commit_sha, commit_message, branch, repo_name, files_changed, agent_name, ai_bridge_interception_id, created_at
|
||||
FROM
|
||||
workspace_git_events
|
||||
WHERE
|
||||
session_id = $1::text
|
||||
ORDER BY
|
||||
created_at ASC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspaceGitEventsBySessionID(ctx context.Context, sessionID string) ([]WorkspaceGitEvent, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspaceGitEventsBySessionID, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceGitEvent
|
||||
for rows.Next() {
|
||||
var i WorkspaceGitEvent
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.OwnerID,
|
||||
&i.OrganizationID,
|
||||
&i.EventType,
|
||||
&i.SessionID,
|
||||
&i.CommitSha,
|
||||
&i.CommitMessage,
|
||||
&i.Branch,
|
||||
&i.RepoName,
|
||||
pq.Array(&i.FilesChanged),
|
||||
&i.AgentName,
|
||||
&i.AiBridgeInterceptionID,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertWorkspaceGitEvent = `-- name: InsertWorkspaceGitEvent :one
|
||||
INSERT INTO workspace_git_events (
|
||||
workspace_id,
|
||||
agent_id,
|
||||
owner_id,
|
||||
organization_id,
|
||||
event_type,
|
||||
session_id,
|
||||
commit_sha,
|
||||
commit_message,
|
||||
branch,
|
||||
repo_name,
|
||||
files_changed,
|
||||
agent_name,
|
||||
ai_bridge_interception_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13
|
||||
)
|
||||
RETURNING id, workspace_id, agent_id, owner_id, organization_id, event_type, session_id, commit_sha, commit_message, branch, repo_name, files_changed, agent_name, ai_bridge_interception_id, created_at
|
||||
`
|
||||
|
||||
type InsertWorkspaceGitEventParams struct {
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SessionID sql.NullString `db:"session_id" json:"session_id"`
|
||||
CommitSha sql.NullString `db:"commit_sha" json:"commit_sha"`
|
||||
CommitMessage sql.NullString `db:"commit_message" json:"commit_message"`
|
||||
Branch sql.NullString `db:"branch" json:"branch"`
|
||||
RepoName sql.NullString `db:"repo_name" json:"repo_name"`
|
||||
FilesChanged []string `db:"files_changed" json:"files_changed"`
|
||||
AgentName sql.NullString `db:"agent_name" json:"agent_name"`
|
||||
AiBridgeInterceptionID uuid.NullUUID `db:"ai_bridge_interception_id" json:"ai_bridge_interception_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceGitEvent(ctx context.Context, arg InsertWorkspaceGitEventParams) (WorkspaceGitEvent, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertWorkspaceGitEvent,
|
||||
arg.WorkspaceID,
|
||||
arg.AgentID,
|
||||
arg.OwnerID,
|
||||
arg.OrganizationID,
|
||||
arg.EventType,
|
||||
arg.SessionID,
|
||||
arg.CommitSha,
|
||||
arg.CommitMessage,
|
||||
arg.Branch,
|
||||
arg.RepoName,
|
||||
pq.Array(arg.FilesChanged),
|
||||
arg.AgentName,
|
||||
arg.AiBridgeInterceptionID,
|
||||
)
|
||||
var i WorkspaceGitEvent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.OwnerID,
|
||||
&i.OrganizationID,
|
||||
&i.EventType,
|
||||
&i.SessionID,
|
||||
&i.CommitSha,
|
||||
&i.CommitMessage,
|
||||
&i.Branch,
|
||||
&i.RepoName,
|
||||
pq.Array(&i.FilesChanged),
|
||||
&i.AgentName,
|
||||
&i.AiBridgeInterceptionID,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listWorkspaceGitEventSessions = `-- name: ListWorkspaceGitEventSessions :many
|
||||
SELECT
|
||||
wge.session_id,
|
||||
wge.owner_id,
|
||||
wge.workspace_id,
|
||||
wge.agent_name,
|
||||
MIN(wge.created_at) AS started_at,
|
||||
MAX(wge.created_at) FILTER (WHERE wge.event_type = 'session_end') AS ended_at,
|
||||
COUNT(*) FILTER (WHERE wge.event_type = 'commit') AS commit_count,
|
||||
COUNT(*) FILTER (WHERE wge.event_type = 'push') AS push_count
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
wge.session_id IS NOT NULL
|
||||
AND CASE
|
||||
WHEN $1::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = $1::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = $2::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = $3::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $4::text != '' THEN wge.agent_name = $4::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $5::text != '' THEN wge.repo_name = $5::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $6::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= $6::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
wge.session_id,
|
||||
wge.owner_id,
|
||||
wge.workspace_id,
|
||||
wge.agent_name
|
||||
HAVING
|
||||
CASE
|
||||
WHEN $7::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN MIN(wge.created_at) < $7::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
started_at DESC
|
||||
LIMIT COALESCE(NULLIF($8::int, 0), 50)
|
||||
`
|
||||
|
||||
type ListWorkspaceGitEventSessionsParams struct {
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
AgentName string `db:"agent_name" json:"agent_name"`
|
||||
RepoName string `db:"repo_name" json:"repo_name"`
|
||||
Since time.Time `db:"since" json:"since"`
|
||||
AfterStartedAt time.Time `db:"after_started_at" json:"after_started_at"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
|
||||
type ListWorkspaceGitEventSessionsRow struct {
|
||||
SessionID sql.NullString `db:"session_id" json:"session_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
AgentName sql.NullString `db:"agent_name" json:"agent_name"`
|
||||
StartedAt interface{} `db:"started_at" json:"started_at"`
|
||||
EndedAt interface{} `db:"ended_at" json:"ended_at"`
|
||||
CommitCount int64 `db:"commit_count" json:"commit_count"`
|
||||
PushCount int64 `db:"push_count" json:"push_count"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) ListWorkspaceGitEventSessions(ctx context.Context, arg ListWorkspaceGitEventSessionsParams) ([]ListWorkspaceGitEventSessionsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWorkspaceGitEventSessions,
|
||||
arg.OwnerID,
|
||||
arg.WorkspaceID,
|
||||
arg.OrganizationID,
|
||||
arg.AgentName,
|
||||
arg.RepoName,
|
||||
arg.Since,
|
||||
arg.AfterStartedAt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListWorkspaceGitEventSessionsRow
|
||||
for rows.Next() {
|
||||
var i ListWorkspaceGitEventSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.SessionID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentName,
|
||||
&i.StartedAt,
|
||||
&i.EndedAt,
|
||||
&i.CommitCount,
|
||||
&i.PushCount,
|
||||
); 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 listWorkspaceGitEvents = `-- name: ListWorkspaceGitEvents :many
|
||||
SELECT
|
||||
id, workspace_id, agent_id, owner_id, organization_id, event_type, session_id, commit_sha, commit_message, branch, repo_name, files_changed, agent_name, ai_bridge_interception_id, created_at
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
CASE
|
||||
WHEN $1::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = $1::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $2::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = $2::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = $3::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $4::text != '' THEN wge.event_type = $4::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $5::text != '' THEN wge.session_id = $5::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $6::text != '' THEN wge.agent_name = $6::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $7::text != '' THEN wge.repo_name = $7::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $8::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= $8::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $9::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||||
(wge.created_at, wge.id) < (
|
||||
$10::timestamptz,
|
||||
$9::uuid
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
wge.created_at DESC,
|
||||
wge.id DESC
|
||||
LIMIT COALESCE(NULLIF($11::int, 0), 100)
|
||||
`
|
||||
|
||||
type ListWorkspaceGitEventsParams struct {
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
SessionID string `db:"session_id" json:"session_id"`
|
||||
AgentName string `db:"agent_name" json:"agent_name"`
|
||||
RepoName string `db:"repo_name" json:"repo_name"`
|
||||
Since time.Time `db:"since" json:"since"`
|
||||
AfterID uuid.UUID `db:"after_id" json:"after_id"`
|
||||
AfterCreatedAt time.Time `db:"after_created_at" json:"after_created_at"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) ListWorkspaceGitEvents(ctx context.Context, arg ListWorkspaceGitEventsParams) ([]WorkspaceGitEvent, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWorkspaceGitEvents,
|
||||
arg.OwnerID,
|
||||
arg.WorkspaceID,
|
||||
arg.OrganizationID,
|
||||
arg.EventType,
|
||||
arg.SessionID,
|
||||
arg.AgentName,
|
||||
arg.RepoName,
|
||||
arg.Since,
|
||||
arg.AfterID,
|
||||
arg.AfterCreatedAt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WorkspaceGitEvent
|
||||
for rows.Next() {
|
||||
var i WorkspaceGitEvent
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.AgentID,
|
||||
&i.OwnerID,
|
||||
&i.OrganizationID,
|
||||
&i.EventType,
|
||||
&i.SessionID,
|
||||
&i.CommitSha,
|
||||
&i.CommitMessage,
|
||||
&i.Branch,
|
||||
&i.RepoName,
|
||||
pq.Array(&i.FilesChanged),
|
||||
&i.AgentName,
|
||||
&i.AiBridgeInterceptionID,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getWorkspaceModulesByJobID = `-- name: GetWorkspaceModulesByJobID :many
|
||||
SELECT
|
||||
id, job_id, transition, source, version, key, created_at
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
-- name: InsertWorkspaceGitEvent :one
|
||||
INSERT INTO workspace_git_events (
|
||||
workspace_id,
|
||||
agent_id,
|
||||
owner_id,
|
||||
organization_id,
|
||||
event_type,
|
||||
session_id,
|
||||
commit_sha,
|
||||
commit_message,
|
||||
branch,
|
||||
repo_name,
|
||||
files_changed,
|
||||
agent_name,
|
||||
ai_bridge_interception_id
|
||||
) VALUES (
|
||||
@workspace_id,
|
||||
@agent_id,
|
||||
@owner_id,
|
||||
@organization_id,
|
||||
@event_type,
|
||||
@session_id,
|
||||
@commit_sha,
|
||||
@commit_message,
|
||||
@branch,
|
||||
@repo_name,
|
||||
@files_changed,
|
||||
@agent_name,
|
||||
@ai_bridge_interception_id
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetWorkspaceGitEventByID :one
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_git_events
|
||||
WHERE
|
||||
id = @id::uuid;
|
||||
|
||||
-- name: ListWorkspaceGitEvents :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
CASE
|
||||
WHEN @owner_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = @owner_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @workspace_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = @workspace_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = @organization_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @event_type::text != '' THEN wge.event_type = @event_type::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @session_id::text != '' THEN wge.session_id = @session_id::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @agent_name::text != '' THEN wge.agent_name = @agent_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @repo_name::text != '' THEN wge.repo_name = @repo_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @since::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= @since::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @after_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
||||
(wge.created_at, wge.id) < (
|
||||
@after_created_at::timestamptz,
|
||||
@after_id::uuid
|
||||
)
|
||||
)
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
wge.created_at DESC,
|
||||
wge.id DESC
|
||||
LIMIT COALESCE(NULLIF(@limit_opt::int, 0), 100);
|
||||
|
||||
-- name: CountWorkspaceGitEvents :one
|
||||
SELECT
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
CASE
|
||||
WHEN @owner_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = @owner_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @workspace_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = @workspace_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = @organization_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @event_type::text != '' THEN wge.event_type = @event_type::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @session_id::text != '' THEN wge.session_id = @session_id::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @agent_name::text != '' THEN wge.agent_name = @agent_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @repo_name::text != '' THEN wge.repo_name = @repo_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @since::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= @since::timestamptz
|
||||
ELSE true
|
||||
END;
|
||||
|
||||
-- name: ListWorkspaceGitEventSessions :many
|
||||
SELECT
|
||||
wge.session_id,
|
||||
wge.owner_id,
|
||||
wge.workspace_id,
|
||||
wge.agent_name,
|
||||
MIN(wge.created_at) AS started_at,
|
||||
MAX(wge.created_at) FILTER (WHERE wge.event_type = 'session_end') AS ended_at,
|
||||
COUNT(*) FILTER (WHERE wge.event_type = 'commit') AS commit_count,
|
||||
COUNT(*) FILTER (WHERE wge.event_type = 'push') AS push_count
|
||||
FROM
|
||||
workspace_git_events wge
|
||||
WHERE
|
||||
wge.session_id IS NOT NULL
|
||||
AND CASE
|
||||
WHEN @owner_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.owner_id = @owner_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @workspace_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.workspace_id = @workspace_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @organization_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN wge.organization_id = @organization_id::uuid
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @agent_name::text != '' THEN wge.agent_name = @agent_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @repo_name::text != '' THEN wge.repo_name = @repo_name::text
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN @since::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN wge.created_at >= @since::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
wge.session_id,
|
||||
wge.owner_id,
|
||||
wge.workspace_id,
|
||||
wge.agent_name
|
||||
HAVING
|
||||
CASE
|
||||
WHEN @after_started_at::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN MIN(wge.created_at) < @after_started_at::timestamptz
|
||||
ELSE true
|
||||
END
|
||||
ORDER BY
|
||||
started_at DESC
|
||||
LIMIT COALESCE(NULLIF(@limit_opt::int, 0), 50);
|
||||
|
||||
-- name: GetWorkspaceGitEventsBySessionID :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
workspace_git_events
|
||||
WHERE
|
||||
session_id = @session_id::text
|
||||
ORDER BY
|
||||
created_at ASC;
|
||||
|
||||
-- name: DeleteOldWorkspaceGitEvents :one
|
||||
WITH deleted AS (
|
||||
DELETE FROM workspace_git_events
|
||||
WHERE created_at < @before::timestamptz
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
deleted;
|
||||
@@ -103,6 +103,7 @@ const (
|
||||
UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id);
|
||||
UniqueWorkspaceBuildsPkey UniqueConstraint = "workspace_builds_pkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
|
||||
UniqueWorkspaceGitEventsPkey UniqueConstraint = "workspace_git_events_pkey" // ALTER TABLE ONLY workspace_git_events ADD CONSTRAINT workspace_git_events_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceProxiesPkey UniqueConstraint = "workspace_proxies_pkey" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id);
|
||||
UniqueWorkspaceProxiesRegionIDUnique UniqueConstraint = "workspace_proxies_region_id_unique" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_region_id_unique UNIQUE (region_id);
|
||||
UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Report workspace git event
|
||||
// @ID report-workspace-git-event
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Agents
|
||||
// @Param request body codersdk.CreateWorkspaceGitEventRequest true "Git event"
|
||||
// @Success 201 {object} codersdk.WorkspaceGitEvent
|
||||
// @Router /workspaceagents/me/git-events [post]
|
||||
func (api *API) postWorkspaceAgentGitEvent(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
|
||||
var req codersdk.CreateWorkspaceGitEventRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate event_type is one of the allowed values.
|
||||
switch req.EventType {
|
||||
case codersdk.WorkspaceGitEventTypeSessionStart,
|
||||
codersdk.WorkspaceGitEventTypeCommit,
|
||||
codersdk.WorkspaceGitEventTypePush,
|
||||
codersdk.WorkspaceGitEventTypeSessionEnd:
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid event type.",
|
||||
Detail: "event_type must be one of: session_start, commit, push, session_end.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the workspace to obtain owner_id and organization_id.
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
event, err := api.Database.InsertWorkspaceGitEvent(ctx, database.InsertWorkspaceGitEventParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: workspaceAgent.ID,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OrganizationID: workspace.OrganizationID,
|
||||
EventType: string(req.EventType),
|
||||
SessionID: sql.NullString{
|
||||
String: req.SessionID,
|
||||
Valid: req.SessionID != "",
|
||||
},
|
||||
CommitSha: sql.NullString{
|
||||
String: req.CommitSHA,
|
||||
Valid: req.CommitSHA != "",
|
||||
},
|
||||
CommitMessage: sql.NullString{
|
||||
String: req.CommitMessage,
|
||||
Valid: req.CommitMessage != "",
|
||||
},
|
||||
Branch: sql.NullString{
|
||||
String: req.Branch,
|
||||
Valid: req.Branch != "",
|
||||
},
|
||||
RepoName: sql.NullString{
|
||||
String: req.RepoName,
|
||||
Valid: req.RepoName != "",
|
||||
},
|
||||
FilesChanged: req.FilesChanged,
|
||||
AgentName: sql.NullString{
|
||||
String: req.AgentName,
|
||||
Valid: req.AgentName != "",
|
||||
},
|
||||
AiBridgeInterceptionID: uuid.NullUUID{},
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to insert git event.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.WorkspaceGitEvent{
|
||||
ID: event.ID,
|
||||
WorkspaceID: event.WorkspaceID,
|
||||
AgentID: event.AgentID,
|
||||
OwnerID: event.OwnerID,
|
||||
OrganizationID: event.OrganizationID,
|
||||
EventType: codersdk.WorkspaceGitEventType(event.EventType),
|
||||
SessionID: event.SessionID.String,
|
||||
CommitSHA: event.CommitSha.String,
|
||||
CommitMessage: event.CommitMessage.String,
|
||||
Branch: event.Branch.String,
|
||||
RepoName: event.RepoName.String,
|
||||
FilesChanged: event.FilesChanged,
|
||||
AgentName: event.AgentName.String,
|
||||
CreatedAt: event.CreatedAt,
|
||||
})
|
||||
}
|
||||
@@ -626,6 +626,21 @@ func (c *Client) PostLogSource(ctx context.Context, req PostLogSourceRequest) (c
|
||||
return logSource, json.NewDecoder(res.Body).Decode(&logSource)
|
||||
}
|
||||
|
||||
// PostGitEvent reports a git event (commit, push, session start/end) from the
|
||||
// workspace agent to the control plane.
|
||||
func (c *Client) PostGitEvent(ctx context.Context, req codersdk.CreateWorkspaceGitEventRequest) (codersdk.WorkspaceGitEvent, error) {
|
||||
res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/git-events", req)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceGitEvent{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
return codersdk.WorkspaceGitEvent{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var event codersdk.WorkspaceGitEvent
|
||||
return event, json.NewDecoder(res.Body).Decode(&event)
|
||||
}
|
||||
|
||||
type ExternalAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenExtra map[string]interface{} `json:"token_extra"`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WorkspaceGitEventType represents the type of git event captured during an AI
|
||||
// coding session.
|
||||
type WorkspaceGitEventType string
|
||||
|
||||
const (
|
||||
WorkspaceGitEventTypeSessionStart WorkspaceGitEventType = "session_start"
|
||||
WorkspaceGitEventTypeCommit WorkspaceGitEventType = "commit"
|
||||
WorkspaceGitEventTypePush WorkspaceGitEventType = "push"
|
||||
WorkspaceGitEventTypeSessionEnd WorkspaceGitEventType = "session_end"
|
||||
)
|
||||
|
||||
// CreateWorkspaceGitEventRequest is the payload sent by workspace agents to
|
||||
// report git activity (commits, pushes, session boundaries) to the control
|
||||
// plane.
|
||||
type CreateWorkspaceGitEventRequest struct {
|
||||
EventType WorkspaceGitEventType `json:"event_type" validate:"required"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
CommitSHA string `json:"commit_sha,omitempty"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
RepoName string `json:"repo_name,omitempty"`
|
||||
FilesChanged []string `json:"files_changed,omitempty"`
|
||||
AgentName string `json:"agent_name,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceGitEvent is a single git event stored for a workspace.
|
||||
type WorkspaceGitEvent struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
|
||||
AgentID uuid.UUID `json:"agent_id" format:"uuid"`
|
||||
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||
EventType WorkspaceGitEventType `json:"event_type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
CommitSHA string `json:"commit_sha,omitempty"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
RepoName string `json:"repo_name,omitempty"`
|
||||
FilesChanged []string `json:"files_changed,omitempty"`
|
||||
AgentName string `json:"agent_name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
# AI Session Capture
|
||||
|
||||
`coder-capture` is a Terraform module that installs git hooks and agent-specific
|
||||
hooks into Coder workspaces to capture AI coding session activity. It links git
|
||||
events — commits, pushes, and branch changes — to the AI tool that generated
|
||||
them, giving template administrators and platform teams visibility into how AI
|
||||
coding assistants are being used across workspaces.
|
||||
|
||||
Captured events are sent to the Coder control plane API and surfaced in the
|
||||
admin dashboard. Each event carries a session identifier that ties it to a
|
||||
specific AI coding session, so administrators can answer questions like: which
|
||||
AI tool generated this commit, how many AI-assisted commits happened this week,
|
||||
or which workspaces are most actively using AI coding assistants.
|
||||
|
||||
The module works by writing standard git hooks (`post-commit`, `commit-msg`,
|
||||
`pre-push`) into each workspace's git repositories, along with tool-specific
|
||||
hooks for Claude Code and Gemini CLI that fire at session start and end. When a
|
||||
hook fires, it reads the current AI session ID from the environment and ships
|
||||
the event to the agent's local API, which forwards it to the Coder control
|
||||
plane.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add the module to your template and point it at your workspace agent:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev" {
|
||||
arch = "amd64"
|
||||
os = "linux"
|
||||
}
|
||||
|
||||
module "coder-capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
```
|
||||
|
||||
The module installs itself during the agent's startup sequence. No additional
|
||||
agent configuration is required.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------------|--------|-----------------|------------------------------------------------------------------------------------------------------|
|
||||
| `agent_id` | string | *(required)* | The ID of the `coder_agent` resource to attach hooks to. |
|
||||
| `no_trailer` | bool | `false` | When `true`, disables appending AI session metadata to commit messages. Useful for strict commit-msg policies. |
|
||||
| `log_dir` | string | `/tmp/coder-capture` | Directory where hook execution logs are written. Must be writable by the workspace user. |
|
||||
|
||||
Example with all options:
|
||||
|
||||
```hcl
|
||||
module "coder-capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.dev.id
|
||||
no_trailer = false
|
||||
log_dir = "/home/coder/.coder-capture/logs"
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Git Hooks
|
||||
|
||||
`coder-capture` installs three git hooks globally in each workspace via
|
||||
`git config --global core.hooksPath`. This means hooks apply to every
|
||||
repository the user works in without per-repo setup.
|
||||
|
||||
| Hook | Trigger | Captured Data |
|
||||
|---------------|--------------------------------------|----------------------------------------------------|
|
||||
| `commit-msg` | Before a commit message is saved | Injects session ID trailer (unless `no_trailer` is set) |
|
||||
| `post-commit` | After a commit is created | Commit hash, author, timestamp, session ID |
|
||||
| `pre-push` | Before a push to a remote | Remote URL, branch name, commit range, session ID |
|
||||
|
||||
The hooks are shell scripts that call a small binary installed by the module.
|
||||
If the binary is unavailable or the API call fails, the hook exits cleanly so
|
||||
that git operations are never blocked.
|
||||
|
||||
### Session Detection
|
||||
|
||||
The module determines the current AI session ID through a priority chain:
|
||||
|
||||
1. **Claude Code hooks** — When Claude Code is active, it sets
|
||||
`CLAUDE_SESSION_ID` in the hook environment. The module installs a Claude
|
||||
Code `PreToolUse` hook that fires at the start of each tool use, and a
|
||||
`PostToolUse` hook that fires at the end, allowing precise session
|
||||
boundaries to be recorded.
|
||||
|
||||
2. **Gemini CLI hooks** — When Gemini CLI is active, it sets
|
||||
`GEMINI_SESSION_ID` via its hook mechanism. The module registers a Gemini
|
||||
hook script that fires on session start and end.
|
||||
|
||||
3. **Fallback UUID** — For tools that do not expose native hooks (Codex,
|
||||
Cursor, Aider, and others), the module generates a stable UUID derived from
|
||||
the workspace ID and the current process group. This UUID persists for the
|
||||
lifetime of the tool process, providing a best-effort session boundary.
|
||||
|
||||
If no AI tool is detected, events are still captured under an anonymous session
|
||||
so that git activity is never silently dropped.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Workspace (git hook fires)
|
||||
│
|
||||
▼
|
||||
coder-capture binary
|
||||
│ HTTP POST to agent local API
|
||||
▼
|
||||
Coder Agent (localhost)
|
||||
│ Forwards to control plane
|
||||
▼
|
||||
Coder Control Plane API
|
||||
│ Stored in database
|
||||
▼
|
||||
Admin Dashboard (/aibridge/*)
|
||||
```
|
||||
|
||||
The agent acts as a local proxy, buffering events when the control plane is
|
||||
temporarily unreachable and retrying with exponential backoff. Events are
|
||||
deduplicated by commit hash so that retries do not produce duplicate records.
|
||||
|
||||
## Tool Coverage
|
||||
|
||||
| Tool | Session Detection | Commit Capture | Push Capture |
|
||||
|--------------|-------------------------|----------------|--------------|
|
||||
| Claude Code | Native hooks (`CLAUDE_SESSION_ID`) | ✅ git hooks | ✅ git hooks |
|
||||
| Gemini CLI | Native hooks (`GEMINI_SESSION_ID`) | ✅ git hooks | ✅ git hooks |
|
||||
| Codex | Fallback UUID | ✅ git hooks | ✅ git hooks |
|
||||
| Cursor | Fallback UUID | ✅ git hooks | ✅ git hooks |
|
||||
| Aider | Fallback UUID | ✅ git hooks | ✅ git hooks |
|
||||
| Manual (no AI tool) | Anonymous session | ✅ git hooks | ✅ git hooks |
|
||||
|
||||
Tools with native hook support provide accurate session start and end times.
|
||||
Tools using the fallback UUID provide commit and push events but may group
|
||||
unrelated commits under the same session ID if multiple tool invocations share
|
||||
a process group.
|
||||
|
||||
## AI Bridge Integration
|
||||
|
||||
`coder-capture` captures git events and session boundaries on its own. When
|
||||
combined with the [AI Bridge](./integrations/ai-bridge.md) feature, the two
|
||||
systems share a session ID so that LLM conversation logs are linked to the git
|
||||
events they produced.
|
||||
|
||||
### Without AI Bridge
|
||||
|
||||
`coder-capture` records:
|
||||
|
||||
- Session start and end times (for tools with native hooks)
|
||||
- Every commit and its metadata
|
||||
- Every push and the commit range it contained
|
||||
- The session ID associated with each event
|
||||
|
||||
This gives administrators git-level visibility into AI activity without
|
||||
capturing the content of conversations.
|
||||
|
||||
### With AI Bridge
|
||||
|
||||
When AI Bridge is also enabled, the session ID written into git event records
|
||||
matches the conversation ID stored by AI Bridge. This allows the dashboard to
|
||||
display a complete picture: the user's conversation with the AI tool alongside
|
||||
the commits that resulted from it.
|
||||
|
||||
To enable both, set the AI Bridge environment variables in your template before
|
||||
starting the agent:
|
||||
|
||||
```hcl
|
||||
resource "coder_agent" "dev" {
|
||||
arch = "amd64"
|
||||
os = "linux"
|
||||
|
||||
env = {
|
||||
# AI Bridge endpoint — points at the local AI Bridge proxy.
|
||||
CODER_AI_BRIDGE_URL = "http://localhost:4900"
|
||||
|
||||
# Share the session ID across both systems.
|
||||
CODER_CAPTURE_SESSION_SOURCE = "aibridge"
|
||||
}
|
||||
|
||||
startup_script = <<-EOF
|
||||
# Start AI Bridge proxy first so coder-capture can reach it.
|
||||
coder-aibridge &
|
||||
sleep 1
|
||||
EOF
|
||||
}
|
||||
|
||||
module "coder-capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
```
|
||||
|
||||
With both systems running, each LLM conversation appears in the dashboard with
|
||||
an expandable list of the commits generated during that conversation.
|
||||
|
||||
## Admin Dashboard
|
||||
|
||||
Captured events are visible in the Coder admin dashboard under the AI Bridge
|
||||
section. The following views are available to users with the `Template
|
||||
Administrator` role or above.
|
||||
|
||||
### Sessions View — `/aibridge/sessions`
|
||||
|
||||
Lists all AI coding sessions across all workspaces. Each row shows:
|
||||
|
||||
- Session ID and the AI tool that generated it
|
||||
- Workspace and owner
|
||||
- Session start and end time (where available)
|
||||
- Number of commits and pushes made during the session
|
||||
|
||||
Click a session to see the individual events it contains.
|
||||
|
||||
### Git Events View — `/aibridge/git-events`
|
||||
|
||||
A chronological log of every commit and push event captured across all
|
||||
workspaces. Filterable by workspace, user, AI tool, date range, and repository.
|
||||
Useful for compliance audits or investigating unexpected commits.
|
||||
|
||||
### Dashboard View — `/aibridge/dashboard`
|
||||
|
||||
Aggregate metrics across your deployment:
|
||||
|
||||
- AI tool usage breakdown (commits by tool)
|
||||
- Sessions per day/week/month
|
||||
- Most active workspaces and users
|
||||
- Commit volume trends over time
|
||||
|
||||
## Data Format
|
||||
|
||||
### Event Types
|
||||
|
||||
| Event Type | When It Fires |
|
||||
|-----------------|---------------------------------------------------|
|
||||
| `session_start` | When an AI tool's native hook fires at startup |
|
||||
| `commit` | Immediately after a git commit is created |
|
||||
| `push` | Just before a push is sent to the remote |
|
||||
| `session_end` | When an AI tool's native hook fires at shutdown |
|
||||
|
||||
### JSON Schema
|
||||
|
||||
All events share a common envelope. Here is an example `commit` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "commit",
|
||||
"session_id": "claude-abc123def456",
|
||||
"workspace_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"agent_id": "7f3e9a1b-4c2d-4e8f-9a1b-3c2d4e8f9a1b",
|
||||
"timestamp": "2025-02-25T14:32:10Z",
|
||||
"tool": "claude-code",
|
||||
"payload": {
|
||||
"commit_hash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
"author_email": "user@example.com",
|
||||
"author_name": "Jane Smith",
|
||||
"message": "feat: add user authentication\n\nAI-Session: claude-abc123def456",
|
||||
"repository": "git@github.com:example/myapp.git",
|
||||
"branch": "feature/auth"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `session_start` event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "session_start",
|
||||
"session_id": "claude-abc123def456",
|
||||
"workspace_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"agent_id": "7f3e9a1b-4c2d-4e8f-9a1b-3c2d4e8f9a1b",
|
||||
"timestamp": "2025-02-25T14:30:00Z",
|
||||
"tool": "claude-code",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Events are delivered to `POST /api/v2/workspaceagents/{agent_id}/gitevents` and
|
||||
stored in the control plane database. The endpoint accepts a batch of up to 100
|
||||
events per request.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks are not firing
|
||||
|
||||
**Symptom**: Commits and pushes appear in the Git Events view with a significant
|
||||
delay or not at all.
|
||||
|
||||
**Check**:
|
||||
|
||||
```sh
|
||||
# Verify the global hooks path is set correctly.
|
||||
git config --global core.hooksPath
|
||||
|
||||
# Expected output: /home/coder/.coder-capture/hooks
|
||||
# (or the equivalent path for your workspace user)
|
||||
```
|
||||
|
||||
If the path is missing or points elsewhere, the module did not complete its
|
||||
startup sequence. Check the agent startup logs:
|
||||
|
||||
```sh
|
||||
cat /tmp/coder-capture/startup.log
|
||||
```
|
||||
|
||||
If the module installed but the hooks directory is wrong, re-run the startup
|
||||
script by rebuilding the workspace.
|
||||
|
||||
### API calls are failing
|
||||
|
||||
**Symptom**: Hook logs show `connection refused` or `unauthorized` errors.
|
||||
|
||||
```sh
|
||||
# Check recent hook execution logs.
|
||||
tail -n 50 /tmp/coder-capture/hook.log
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
- **Agent not yet ready**: The hook fired before the Coder Agent finished
|
||||
connecting to the control plane. The binary retries automatically; if the
|
||||
error is transient it will resolve on the next commit.
|
||||
- **Wrong agent token**: The `CODER_AGENT_TOKEN` environment variable is not
|
||||
set or has expired. Confirm it is present in the hook environment:
|
||||
|
||||
```sh
|
||||
env | grep CODER_AGENT_TOKEN
|
||||
```
|
||||
|
||||
- **Firewall blocking localhost**: Some workspace images apply restrictive
|
||||
`iptables` rules. Confirm the agent API is reachable:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:4/api/v2/buildinfo
|
||||
```
|
||||
|
||||
### Session ID is not being detected
|
||||
|
||||
**Symptom**: Events appear in the dashboard but the `session_id` field is a
|
||||
generic UUID rather than a tool-specific ID, even when using Claude Code or
|
||||
Gemini CLI.
|
||||
|
||||
**Check** that the tool's hook mechanism is enabled:
|
||||
|
||||
- **Claude Code**: Hooks must be enabled in `~/.claude/settings.json`. Ensure
|
||||
`hooks.enabled` is `true` and that the hook scripts installed by
|
||||
`coder-capture` are listed under `PreToolUse` and `PostToolUse`.
|
||||
|
||||
```sh
|
||||
cat ~/.claude/settings.json | grep -A5 hooks
|
||||
```
|
||||
|
||||
- **Gemini CLI**: Hook scripts must be present in `~/.gemini/hooks/`. Confirm:
|
||||
|
||||
```sh
|
||||
ls ~/.gemini/hooks/
|
||||
# Expected: session-start.sh session-end.sh
|
||||
```
|
||||
|
||||
If the scripts are missing, the module's startup script may not have run
|
||||
during this workspace session. Restart the workspace to trigger the startup
|
||||
script again.
|
||||
|
||||
### Commits from non-AI work are captured
|
||||
|
||||
This is expected behavior. `coder-capture` captures all git events in the
|
||||
workspace, not just those made by AI tools. Events without a detected session
|
||||
ID are filed under an anonymous session. Template administrators can filter
|
||||
these out in the Git Events view using the **Tool: None** filter.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Coder Capture — AI Session Tracking Module
|
||||
|
||||
A Terraform module that installs and enables `coder-capture` inside a Coder
|
||||
workspace. It automatically detects AI coding-tool sessions (Claude Code,
|
||||
Gemini CLI, Codex, Cursor, Aider, and others) via git hooks, captures session
|
||||
metadata, and reports activity back to the Coder control plane. Template admins
|
||||
can drop this module into any workspace template with a single `module` block —
|
||||
no changes to the underlying Docker image are required if the binary is
|
||||
pre-installed.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```hcl
|
||||
module "coder_capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
With optional flags:
|
||||
|
||||
```hcl
|
||||
module "coder_capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.main.id
|
||||
no_trailer = true # suppress commit-message trailers
|
||||
log_dir = "/var/log/capture" # store logs outside $HOME
|
||||
}
|
||||
```
|
||||
|
||||
> **Prerequisite**: The `coder-capture` binary must be available either in the
|
||||
> workspace's `$PATH` (e.g. baked into the Docker image) or at
|
||||
> `~/.coder-capture/bin/coder-capture`. The startup script copies it into place
|
||||
> automatically on first start if found in `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Variables
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|--------------|----------|---------|-----------------------------------------------------------------------------|
|
||||
| `agent_id` | `string` | — | **Required.** The ID of the Coder agent to attach the capture script to. |
|
||||
| `no_trailer` | `bool` | `false` | If `true`, suppress the `Coder-Session: …` trailer in git commit messages. |
|
||||
| `log_dir` | `string` | `""` | Custom directory for capture logs. Defaults to `~/coder-capture-logs/`. |
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Binary Installation
|
||||
|
||||
On workspace start the `run.sh` script checks for
|
||||
`~/.coder-capture/bin/coder-capture`. If absent, it looks for the binary in
|
||||
`$PATH` and copies it into place. If the binary is not found anywhere, the
|
||||
script prints a helpful warning and exits cleanly — the workspace still starts
|
||||
normally.
|
||||
|
||||
### 2. Session Detection via Git Hooks
|
||||
|
||||
`coder-capture enable` installs `post-commit` and `prepare-commit-msg` hooks
|
||||
into the user's global git hook directory (`core.hooksPath`). These hooks fire
|
||||
on every `git commit` and inspect the process tree to detect which AI tool is
|
||||
active.
|
||||
|
||||
### 3. AI Tool Detection
|
||||
|
||||
Each hook checks parent processes and environment variables for well-known AI
|
||||
coding tools. When a match is found, the session ID, tool name, model, and
|
||||
timestamp are recorded.
|
||||
|
||||
### 4. Session Trailer Injection
|
||||
|
||||
By default, `coder-capture` appends a `Coder-Session:` trailer to each commit
|
||||
message so that sessions can be correlated with commits in post-hoc analysis.
|
||||
Set `no_trailer = true` to disable this behaviour.
|
||||
|
||||
### 5. API Reporting
|
||||
|
||||
Session events are batched and sent to the Coder API using the workspace agent
|
||||
token (`CODER_AGENT_TOKEN`). Reports appear in the workspace activity feed and
|
||||
can be aggregated across the deployment via the admin dashboard or the Coder
|
||||
API.
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Tools
|
||||
|
||||
| Tool | Detection Method | Notes |
|
||||
|-----------------|-----------------------------------------------|------------------------------------|
|
||||
| **Claude Code** | `CLAUDE_SESSION_ID` env var + process name | Full session & model reporting |
|
||||
| **Gemini CLI** | `GEMINI_SESSION` env var + `gemini` process | Session ID captured from env |
|
||||
| **OpenAI Codex**| `CODEX_SESSION_ID` env var + process tree | Requires Codex CLI ≥ 1.4 |
|
||||
| **Cursor** | `CURSOR_SESSION_ID` env var + window title | Detected when committing from IDE |
|
||||
| **Aider** | `.aider*` commit message patterns + env | Trailer and log-file based |
|
||||
|
||||
> Detection is best-effort. Tools that commit via a subprocess may not be
|
||||
> detected if they clear the environment before spawning `git`.
|
||||
|
||||
---
|
||||
|
||||
## AI Bridge Integration
|
||||
|
||||
`coder-capture` integrates with the **Coder AI Bridge** to provide richer
|
||||
session context. When AI Bridge is running in the workspace, `coder-capture`
|
||||
reads structured session data from the Bridge's local socket instead of
|
||||
relying solely on environment variables and process inspection.
|
||||
|
||||
To enable AI Bridge support, add the `ai-bridge` module alongside this one:
|
||||
|
||||
```hcl
|
||||
module "ai_bridge" {
|
||||
source = "./modules/ai-bridge"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "coder_capture" {
|
||||
source = "./modules/coder-capture"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
The two modules discover each other automatically via a shared local socket at
|
||||
`/tmp/coder-ai-bridge.sock`; no extra configuration is needed.
|
||||
|
||||
### Without AI Bridge vs. With AI Bridge
|
||||
|
||||
| Capability | Without AI Bridge | With AI Bridge |
|
||||
|-----------------------------------|----------------------------|---------------------------------------|
|
||||
| Session detection | Environment + process tree | Structured socket protocol |
|
||||
| Model identification | Best-effort from env vars | Exact model string from tool |
|
||||
| Token / cost tracking | ✗ | ✓ (where tool exposes usage) |
|
||||
| Multi-agent session correlation | ✗ | ✓ |
|
||||
| Idle vs. active session tracking | ✗ | ✓ |
|
||||
| Reliability on env-clearing tools | Low | High |
|
||||
|
||||
---
|
||||
|
||||
## Resources Created
|
||||
|
||||
| Resource | Description |
|
||||
|-----------------------------|--------------------------------------------------|
|
||||
| `coder_script.coder_capture`| Startup script that installs and enables capture |
|
||||
|
||||
No persistent storage or network resources are provisioned by this module.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Binary not found warning on start**
|
||||
: Bake `coder-capture` into your Docker image:
|
||||
```dockerfile
|
||||
COPY --chown=root:root coder-capture /usr/local/bin/coder-capture
|
||||
RUN chmod +x /usr/local/bin/coder-capture
|
||||
```
|
||||
|
||||
**Commits not being attributed**
|
||||
: Check that the git global hooks path is not overridden in the workspace.
|
||||
Run `git config --global core.hooksPath` to inspect the current value.
|
||||
|
||||
**Logs location**
|
||||
: Default log directory is `~/coder-capture-logs/`. Override with `log_dir`.
|
||||
Each session creates a timestamped file: `capture-<timestamp>.jsonl`.
|
||||
@@ -0,0 +1,39 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of the Coder agent to attach the capture script to."
|
||||
}
|
||||
|
||||
variable "no_trailer" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "If true, do not inject Coder-Session trailer into commit messages."
|
||||
}
|
||||
|
||||
variable "log_dir" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Custom directory for capture logs. Defaults to ~/coder-capture-logs/."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_script" "coder_capture" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "AI Session Capture"
|
||||
icon = "/icon/git.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
no_trailer = var.no_trailer
|
||||
log_dir = var.log_dir
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
CAPTURE_BIN="$HOME/.coder-capture/bin/coder-capture"
|
||||
|
||||
# Install coder-capture if not present
|
||||
if [ ! -f "$CAPTURE_BIN" ]; then
|
||||
mkdir -p "$HOME/.coder-capture/bin"
|
||||
if command -v coder-capture >/dev/null 2>&1; then
|
||||
cp "$(which coder-capture)" "$CAPTURE_BIN"
|
||||
chmod +x "$CAPTURE_BIN"
|
||||
else
|
||||
echo "WARNING: coder-capture binary not found in PATH."
|
||||
echo "Install it in your Docker image: COPY coder-capture /usr/local/bin/coder-capture"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build enable command with optional flags
|
||||
ENABLE_ARGS="enable"
|
||||
%{ if no_trailer }
|
||||
ENABLE_ARGS="$ENABLE_ARGS --no-trailer"
|
||||
%{ endif }
|
||||
%{ if log_dir != "" }
|
||||
ENABLE_ARGS="$ENABLE_ARGS --log-dir ${log_dir}"
|
||||
%{ endif }
|
||||
|
||||
exec "$CAPTURE_BIN" $ENABLE_ARGS
|
||||
Executable
+529
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROGRAM_NAME="coder-capture"
|
||||
CAPTURE_DIR="${HOME}/.coder-capture"
|
||||
HOOKS_DIR="${CAPTURE_DIR}/hooks"
|
||||
SCRIPTS_DIR="${CAPTURE_DIR}/scripts"
|
||||
CONFIG_FILE="${CAPTURE_DIR}/config"
|
||||
CURRENT_SESSION_FILE="${CAPTURE_DIR}/.current-session"
|
||||
DEFAULT_LOG_DIR="${HOME}/coder-capture-logs"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage:
|
||||
${PROGRAM_NAME} enable [--no-trailer] [--log-dir DIR]
|
||||
${PROGRAM_NAME} disable
|
||||
${PROGRAM_NAME} status
|
||||
USAGE
|
||||
}
|
||||
|
||||
log_info() {
|
||||
printf '[%s] %s\n' "${PROGRAM_NAME}" "$*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
printf '[%s] warning: %s\n' "${PROGRAM_NAME}" "$*" >&2
|
||||
}
|
||||
|
||||
log_error() {
|
||||
printf '[%s] error: %s\n' "${PROGRAM_NAME}" "$*" >&2
|
||||
}
|
||||
|
||||
require_jq() {
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
log_error "jq is required for enable"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
load_config() {
|
||||
LOG_DIR="${DEFAULT_LOG_DIR}"
|
||||
TRAILER_ENABLED="1"
|
||||
if [[ -f "${CONFIG_FILE}" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "${CONFIG_FILE}"
|
||||
LOG_DIR="${LOG_DIR:-${DEFAULT_LOG_DIR}}"
|
||||
TRAILER_ENABLED="${TRAILER_ENABLED:-1}"
|
||||
fi
|
||||
}
|
||||
|
||||
write_config() {
|
||||
local log_dir="$1"
|
||||
local trailer_enabled="$2"
|
||||
mkdir -p "${CAPTURE_DIR}"
|
||||
cat >"${CONFIG_FILE}" <<CONFIG
|
||||
LOG_DIR='${log_dir}'
|
||||
TRAILER_ENABLED='${trailer_enabled}'
|
||||
CONFIG
|
||||
}
|
||||
|
||||
merge_json() {
|
||||
local target="$1"
|
||||
local overlay_json="$2"
|
||||
mkdir -p "$(dirname "${target}")"
|
||||
|
||||
local base_file overlay_file merged_file
|
||||
base_file="$(mktemp)"
|
||||
overlay_file="$(mktemp)"
|
||||
merged_file="$(mktemp)"
|
||||
|
||||
if [[ -s "${target}" ]] && jq empty "${target}" >/dev/null 2>&1; then
|
||||
cp "${target}" "${base_file}"
|
||||
else
|
||||
printf '{}\n' >"${base_file}"
|
||||
fi
|
||||
printf '%s\n' "${overlay_json}" >"${overlay_file}"
|
||||
|
||||
jq -s '.[0] * .[1]' "${base_file}" "${overlay_file}" >"${merged_file}"
|
||||
mv "${merged_file}" "${target}"
|
||||
rm -f "${base_file}" "${overlay_file}"
|
||||
}
|
||||
|
||||
write_hook_library() {
|
||||
local log_dir="$1"
|
||||
cat >"${SCRIPTS_DIR}/hook-lib.sh" <<'LIB'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
CAPTURE_DIR="${HOME}/.coder-capture"
|
||||
SESSION_FILE="${CAPTURE_DIR}/.current-session"
|
||||
LOG_DIR="__LOG_DIR__"
|
||||
EVENTS_FILE="${LOG_DIR}/events.jsonl"
|
||||
SESSIONS_FILE="${LOG_DIR}/sessions.jsonl"
|
||||
|
||||
mkdir -p "${CAPTURE_DIR}" "${LOG_DIR}" >/dev/null 2>&1
|
||||
|
||||
json_escape() {
|
||||
local s="${1:-}"
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\n'/\\n}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\t'/\\t}
|
||||
printf '%s' "${s}"
|
||||
}
|
||||
|
||||
generate_session_id() {
|
||||
local date_part rand
|
||||
date_part="$(date +%Y%m%d)"
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
rand="$(openssl rand -hex 6 2>/dev/null)"
|
||||
elif [[ -r /proc/sys/kernel/random/uuid ]]; then
|
||||
rand="$(tr -d '-' </proc/sys/kernel/random/uuid | cut -c1-12)"
|
||||
else
|
||||
rand="$(printf '%012x' "$((RANDOM * RANDOM + RANDOM))" | cut -c1-12)"
|
||||
fi
|
||||
printf '%s-%s' "${date_part}" "${rand}"
|
||||
}
|
||||
|
||||
get_session_id() {
|
||||
local sid=""
|
||||
if [[ -f "${SESSION_FILE}" ]]; then
|
||||
sid="$(head -n1 "${SESSION_FILE}" | tr -d '[:space:]')"
|
||||
fi
|
||||
if [[ -z "${sid}" && -n "${CODER_AI_BRIDGE_SESSION_ID:-}" ]]; then
|
||||
sid="${CODER_AI_BRIDGE_SESSION_ID}"
|
||||
fi
|
||||
if [[ -z "${sid}" ]]; then
|
||||
sid="$(generate_session_id)"
|
||||
fi
|
||||
printf '%s' "${sid}"
|
||||
}
|
||||
|
||||
detect_agent_name() {
|
||||
if [[ -n "${CODER_AI_BRIDGE_AGENT_NAME:-}" ]]; then
|
||||
printf '%s' "${CODER_AI_BRIDGE_AGENT_NAME}"
|
||||
elif [[ -n "${CLAUDECODE:-}" || -n "${CLAUDE_SESSION_ID:-}" ]]; then
|
||||
printf 'claude-code'
|
||||
elif [[ -n "${GEMINI_CLI:-}" || -n "${GEMINI_SESSION_ID:-}" ]]; then
|
||||
printf 'gemini-cli'
|
||||
else
|
||||
printf 'unknown'
|
||||
fi
|
||||
}
|
||||
|
||||
timestamp_utc() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
repo_name() {
|
||||
local top
|
||||
top="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
basename "${top:-$(pwd)}"
|
||||
}
|
||||
|
||||
branch_name() {
|
||||
git rev-parse --abbrev-ref HEAD 2>/dev/null
|
||||
}
|
||||
|
||||
files_changed_json() {
|
||||
local sha="$1"
|
||||
local item escaped out="" first=1
|
||||
while IFS= read -r item; do
|
||||
[[ -z "${item}" ]] && continue
|
||||
escaped="$(json_escape "${item}")"
|
||||
if [[ ${first} -eq 1 ]]; then
|
||||
out="\"${escaped}\""
|
||||
first=0
|
||||
else
|
||||
out+=",\"${escaped}\""
|
||||
fi
|
||||
done < <(git diff-tree --no-commit-id --name-only -r "${sha}" 2>/dev/null)
|
||||
printf '%s' "${out}"
|
||||
}
|
||||
|
||||
append_jsonl() {
|
||||
local path="$1"
|
||||
local payload="$2"
|
||||
mkdir -p "$(dirname "${path}")" >/dev/null 2>&1
|
||||
printf '%s\n' "${payload}" >>"${path}"
|
||||
}
|
||||
|
||||
post_event() {
|
||||
local endpoint="$1"
|
||||
local payload="$2"
|
||||
local fallback_file="$3"
|
||||
if [[ -n "${CODER_AGENT_URL:-}" && -n "${CODER_AGENT_TOKEN:-}" ]]; then
|
||||
(
|
||||
curl -fsS -m 2 -X POST "${endpoint}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Coder-Session-Token: ${CODER_AGENT_TOKEN}" \
|
||||
--data "${payload}" >/dev/null 2>&1 || append_jsonl "${fallback_file}" "${payload}"
|
||||
) &
|
||||
else
|
||||
append_jsonl "${fallback_file}" "${payload}"
|
||||
fi
|
||||
}
|
||||
LIB
|
||||
sed -i "s|__LOG_DIR__|${log_dir}|g" "${SCRIPTS_DIR}/hook-lib.sh"
|
||||
chmod +x "${SCRIPTS_DIR}/hook-lib.sh"
|
||||
}
|
||||
|
||||
write_post_commit_hook() {
|
||||
cat >"${HOOKS_DIR}/post-commit" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
LIB="${HOME}/.coder-capture/scripts/hook-lib.sh"
|
||||
[[ -f "${LIB}" ]] || exit 0
|
||||
# shellcheck source=/dev/null
|
||||
source "${LIB}"
|
||||
|
||||
sha="$(git rev-parse HEAD 2>/dev/null)"
|
||||
[[ -n "${sha}" ]] || exit 0
|
||||
|
||||
payload="$(printf '{"event_type":"commit","session_id":"%s","commit_sha":"%s","commit_message":"%s","branch":"%s","repo_name":"%s","files_changed":[%s],"agent_name":"%s","workspace_name":"%s","workspace_owner":"%s","ts":"%s"}' \
|
||||
"$(json_escape "$(get_session_id)")" \
|
||||
"$(json_escape "${sha}")" \
|
||||
"$(json_escape "$(git log -1 --pretty=%s "${sha}" 2>/dev/null)")" \
|
||||
"$(json_escape "$(branch_name)")" \
|
||||
"$(json_escape "$(repo_name)")" \
|
||||
"$(files_changed_json "${sha}")" \
|
||||
"$(json_escape "$(detect_agent_name)")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_NAME:-unknown}")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_OWNER_NAME:-unknown}")" \
|
||||
"$(json_escape "$(timestamp_utc)")")"
|
||||
|
||||
post_event "${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/git-events" "${payload}" "${EVENTS_FILE}"
|
||||
exit 0
|
||||
HOOK
|
||||
chmod +x "${HOOKS_DIR}/post-commit"
|
||||
}
|
||||
|
||||
write_commit_msg_hook() {
|
||||
local trailer_enabled="$1"
|
||||
cat >"${HOOKS_DIR}/commit-msg" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
TRAILER_ENABLED="__TRAILER_ENABLED__"
|
||||
[[ "${TRAILER_ENABLED}" == "1" ]] || exit 0
|
||||
|
||||
LIB="${HOME}/.coder-capture/scripts/hook-lib.sh"
|
||||
[[ -f "${LIB}" ]] || exit 0
|
||||
# shellcheck source=/dev/null
|
||||
source "${LIB}"
|
||||
|
||||
msg_file="${1:-}"
|
||||
[[ -n "${msg_file}" && -f "${msg_file}" ]] || exit 0
|
||||
|
||||
grep -qi '^Coder-Session:' "${msg_file}" && exit 0
|
||||
|
||||
sid="$(get_session_id)"
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
git interpret-trailers --in-place --if-exists doNothing \
|
||||
--trailer "Coder-Session: ${sid}" "${msg_file}" >/dev/null 2>&1
|
||||
else
|
||||
printf '\nCoder-Session: %s\n' "${sid}" >>"${msg_file}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
HOOK
|
||||
sed -i "s|__TRAILER_ENABLED__|${trailer_enabled}|g" "${HOOKS_DIR}/commit-msg"
|
||||
chmod +x "${HOOKS_DIR}/commit-msg"
|
||||
}
|
||||
|
||||
write_pre_push_hook() {
|
||||
cat >"${HOOKS_DIR}/pre-push" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
LIB="${HOME}/.coder-capture/scripts/hook-lib.sh"
|
||||
[[ -f "${LIB}" ]] || exit 0
|
||||
# shellcheck source=/dev/null
|
||||
source "${LIB}"
|
||||
|
||||
remote_name="${1:-unknown}"
|
||||
remote_url="${2:-unknown}"
|
||||
read -r local_ref local_sha remote_ref remote_sha || true
|
||||
|
||||
payload="$(printf '{"event_type":"push","session_id":"%s","commit_sha":"%s","branch":"%s","repo_name":"%s","push_local_ref":"%s","push_remote_ref":"%s","remote_name":"%s","remote_url":"%s","agent_name":"%s","workspace_name":"%s","workspace_owner":"%s","ts":"%s"}' \
|
||||
"$(json_escape "$(get_session_id)")" \
|
||||
"$(json_escape "${local_sha:-}")" \
|
||||
"$(json_escape "$(branch_name)")" \
|
||||
"$(json_escape "$(repo_name)")" \
|
||||
"$(json_escape "${local_ref:-}")" \
|
||||
"$(json_escape "${remote_ref:-}")" \
|
||||
"$(json_escape "${remote_name}")" \
|
||||
"$(json_escape "${remote_url}")" \
|
||||
"$(json_escape "$(detect_agent_name)")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_NAME:-unknown}")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_OWNER_NAME:-unknown}")" \
|
||||
"$(json_escape "$(timestamp_utc)")")"
|
||||
|
||||
post_event "${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/git-events" "${payload}" "${EVENTS_FILE}"
|
||||
exit 0
|
||||
HOOK
|
||||
chmod +x "${HOOKS_DIR}/pre-push"
|
||||
}
|
||||
|
||||
write_session_start_script() {
|
||||
cat >"${SCRIPTS_DIR}/session-start.sh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
LIB="${HOME}/.coder-capture/scripts/hook-lib.sh"
|
||||
[[ -f "${LIB}" ]] || exit 0
|
||||
# shellcheck source=/dev/null
|
||||
source "${LIB}"
|
||||
|
||||
sid="$(generate_session_id)"
|
||||
printf '%s\n' "${sid}" >"${SESSION_FILE}"
|
||||
|
||||
payload="$(printf '{"event_type":"session_start","session_id":"%s","agent_name":"%s","workspace_name":"%s","workspace_owner":"%s","ts":"%s"}' \
|
||||
"$(json_escape "${sid}")" \
|
||||
"$(json_escape "$(detect_agent_name)")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_NAME:-unknown}")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_OWNER_NAME:-unknown}")" \
|
||||
"$(json_escape "$(timestamp_utc)")")"
|
||||
|
||||
append_jsonl "${SESSIONS_FILE}" "${payload}"
|
||||
post_event "${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/session-events" "${payload}" "${EVENTS_FILE}"
|
||||
printf '%s\n' "${sid}"
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "${SCRIPTS_DIR}/session-start.sh"
|
||||
}
|
||||
|
||||
write_session_end_script() {
|
||||
cat >"${SCRIPTS_DIR}/session-end.sh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set +e
|
||||
|
||||
LIB="${HOME}/.coder-capture/scripts/hook-lib.sh"
|
||||
[[ -f "${LIB}" ]] || exit 0
|
||||
# shellcheck source=/dev/null
|
||||
source "${LIB}"
|
||||
|
||||
sid="$(get_session_id)"
|
||||
payload="$(printf '{"event_type":"session_end","session_id":"%s","agent_name":"%s","workspace_name":"%s","workspace_owner":"%s","ts":"%s"}' \
|
||||
"$(json_escape "${sid}")" \
|
||||
"$(json_escape "$(detect_agent_name)")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_NAME:-unknown}")" \
|
||||
"$(json_escape "${CODER_WORKSPACE_OWNER_NAME:-unknown}")" \
|
||||
"$(json_escape "$(timestamp_utc)")")"
|
||||
|
||||
append_jsonl "${SESSIONS_FILE}" "${payload}"
|
||||
post_event "${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/session-events" "${payload}" "${EVENTS_FILE}"
|
||||
rm -f "${SESSION_FILE}"
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "${SCRIPTS_DIR}/session-end.sh"
|
||||
}
|
||||
|
||||
tool_status() {
|
||||
local path="$1"
|
||||
local start_cmd="$2"
|
||||
local end_cmd="$3"
|
||||
if [[ ! -f "${path}" ]]; then
|
||||
printf 'not detected'
|
||||
return
|
||||
fi
|
||||
if grep -q "${start_cmd}" "${path}" 2>/dev/null && grep -q "${end_cmd}" "${path}" 2>/dev/null; then
|
||||
printf 'configured'
|
||||
else
|
||||
printf 'detected (hooks not configured)'
|
||||
fi
|
||||
}
|
||||
|
||||
print_status() {
|
||||
load_config
|
||||
local hooks_path hook current_session events_count sessions_count
|
||||
hooks_path="$(git config --global --get core.hooksPath 2>/dev/null || true)"
|
||||
|
||||
local installed=()
|
||||
for hook in post-commit commit-msg pre-push; do
|
||||
[[ -x "${HOOKS_DIR}/${hook}" ]] && installed+=("${hook}")
|
||||
done
|
||||
|
||||
current_session="none"
|
||||
[[ -f "${CURRENT_SESSION_FILE}" ]] && current_session="$(head -n1 "${CURRENT_SESSION_FILE}" | tr -d '[:space:]')"
|
||||
current_session="${current_session:-none}"
|
||||
|
||||
events_count=0
|
||||
sessions_count=0
|
||||
[[ -f "${LOG_DIR}/events.jsonl" ]] && events_count="$(wc -l <"${LOG_DIR}/events.jsonl" | tr -d '[:space:]')"
|
||||
[[ -f "${LOG_DIR}/sessions.jsonl" ]] && sessions_count="$(wc -l <"${LOG_DIR}/sessions.jsonl" | tr -d '[:space:]')"
|
||||
|
||||
printf 'coder-capture status\n'
|
||||
printf ' capture dir: %s\n' "${CAPTURE_DIR}"
|
||||
printf ' log dir: %s\n' "${LOG_DIR}"
|
||||
printf ' git core.hooksPath: %s\n' "${hooks_path:-unset}"
|
||||
if [[ ${#installed[@]} -gt 0 ]]; then
|
||||
printf ' installed hooks: %s\n' "${installed[*]}"
|
||||
else
|
||||
printf ' installed hooks: none\n'
|
||||
fi
|
||||
printf ' trailer injection: %s\n' "$( [[ "${TRAILER_ENABLED}" == "1" ]] && printf 'enabled' || printf 'disabled' )"
|
||||
printf ' claude code: %s\n' "$(tool_status "${HOME}/.claude/settings.json" "${SCRIPTS_DIR}/session-start.sh" "${SCRIPTS_DIR}/session-end.sh")"
|
||||
printf ' gemini cli: %s\n' "$(tool_status "${HOME}/.gemini/settings.json" "${SCRIPTS_DIR}/session-start.sh" "${SCRIPTS_DIR}/session-end.sh")"
|
||||
printf ' current session: %s\n' "${current_session}"
|
||||
printf ' events logged: %s\n' "${events_count}"
|
||||
printf ' session logs: %s\n' "${sessions_count}"
|
||||
}
|
||||
|
||||
merge_agent_hooks() {
|
||||
local claude_settings="${HOME}/.claude/settings.json"
|
||||
local gemini_settings="${HOME}/.gemini/settings.json"
|
||||
local claude_overlay gemini_overlay
|
||||
|
||||
claude_overlay="$(cat <<JSON
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": {
|
||||
"command": "${SCRIPTS_DIR}/session-start.sh"
|
||||
},
|
||||
"Stop": {
|
||||
"command": "${SCRIPTS_DIR}/session-end.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)"
|
||||
|
||||
gemini_overlay="$(cat <<JSON
|
||||
{
|
||||
"hooks": {
|
||||
"sessionStart": {
|
||||
"command": "${SCRIPTS_DIR}/session-start.sh"
|
||||
},
|
||||
"sessionEnd": {
|
||||
"command": "${SCRIPTS_DIR}/session-end.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)"
|
||||
|
||||
merge_json "${claude_settings}" "${claude_overlay}"
|
||||
merge_json "${gemini_settings}" "${gemini_overlay}"
|
||||
}
|
||||
|
||||
enable_cmd() {
|
||||
local trailer_enabled="1"
|
||||
local log_dir="${DEFAULT_LOG_DIR}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-trailer)
|
||||
trailer_enabled="0"
|
||||
shift
|
||||
;;
|
||||
--log-dir)
|
||||
[[ $# -ge 2 ]] || { log_error "--log-dir requires a value"; exit 1; }
|
||||
log_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "unknown enable option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_jq
|
||||
mkdir -p "${HOOKS_DIR}" "${SCRIPTS_DIR}" "${log_dir}"
|
||||
|
||||
write_hook_library "${log_dir}"
|
||||
write_post_commit_hook
|
||||
write_commit_msg_hook "${trailer_enabled}"
|
||||
write_pre_push_hook
|
||||
write_session_start_script
|
||||
write_session_end_script
|
||||
write_config "${log_dir}" "${trailer_enabled}"
|
||||
|
||||
git config --global core.hooksPath "${HOOKS_DIR}"
|
||||
merge_agent_hooks
|
||||
|
||||
log_info "enabled capture hooks and session scripts"
|
||||
print_status
|
||||
}
|
||||
|
||||
disable_cmd() {
|
||||
load_config
|
||||
[[ -d "${HOOKS_DIR}" ]] && rm -rf "${HOOKS_DIR}"
|
||||
if [[ "$(git config --global --get core.hooksPath 2>/dev/null || true)" == "${HOOKS_DIR}" ]]; then
|
||||
git config --global --unset core.hooksPath || true
|
||||
fi
|
||||
log_info "disabled git hooks. Logs preserved in ${LOG_DIR}"
|
||||
print_status
|
||||
}
|
||||
|
||||
status_cmd() {
|
||||
print_status
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ $# -ge 1 ]] || { usage; exit 1; }
|
||||
case "$1" in
|
||||
enable)
|
||||
shift
|
||||
enable_cmd "$@"
|
||||
;;
|
||||
disable)
|
||||
shift
|
||||
disable_cmd "$@"
|
||||
;;
|
||||
status)
|
||||
shift
|
||||
[[ $# -eq 0 ]] || log_warn "status ignores extra arguments"
|
||||
status_cmd
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
log_error "unknown subcommand: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
// TODO: These types are placeholders. Run `make gen` once the backend
|
||||
// API is implemented to generate proper types from the Go SDK.
|
||||
|
||||
export interface WorkspaceGitEvent {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
owner_id: string;
|
||||
organization_id: string;
|
||||
event_type: "session_start" | "commit" | "push" | "session_end";
|
||||
session_id: string | null;
|
||||
commit_sha: string | null;
|
||||
commit_message: string | null;
|
||||
branch: string | null;
|
||||
repo_name: string | null;
|
||||
files_changed: string[];
|
||||
agent_name: string;
|
||||
ai_bridge_interception_id: string | null;
|
||||
created_at: string;
|
||||
// Joined fields from API
|
||||
owner_username?: string;
|
||||
owner_avatar_url?: string;
|
||||
workspace_name?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceGitEventSession {
|
||||
session_id: string;
|
||||
owner_id: string;
|
||||
workspace_id: string;
|
||||
agent_name: string;
|
||||
repo_name: string | null;
|
||||
branch: string | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
commit_count: number;
|
||||
push_count: number;
|
||||
// Joined fields from API
|
||||
owner_username?: string;
|
||||
owner_avatar_url?: string;
|
||||
workspace_name?: string;
|
||||
}
|
||||
@@ -4,17 +4,22 @@ import {
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader";
|
||||
import { TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Outlet, useLocation } from "react-router";
|
||||
import { AIBridgeHelpTooltip } from "./AIBridgeHelpTooltip";
|
||||
|
||||
const AIBridgeLayout: FC<PropsWithChildren> = () => {
|
||||
const location = useLocation();
|
||||
const paths = location.pathname.split("/");
|
||||
const activeTab = paths.at(-1) ?? "sessions";
|
||||
|
||||
return (
|
||||
<Margins className="pb-12">
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>AI Bridge Logs</span>
|
||||
<span>AI Bridge</span>
|
||||
<AIBridgeHelpTooltip />
|
||||
</div>
|
||||
</PageHeaderTitle>
|
||||
@@ -22,6 +27,24 @@ const AIBridgeLayout: FC<PropsWithChildren> = () => {
|
||||
Centralized auditing for LLM usage across your organization.
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<Tabs active={activeTab} className="mb-10 -mt-3">
|
||||
<TabsList>
|
||||
<TabLink to="sessions" value="sessions">
|
||||
Sessions
|
||||
</TabLink>
|
||||
<TabLink to="request-logs" value="request-logs">
|
||||
Request Logs
|
||||
</TabLink>
|
||||
<TabLink to="git-events" value="git-events">
|
||||
Git Events
|
||||
</TabLink>
|
||||
<TabLink to="dashboard" value="dashboard">
|
||||
Dashboard
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
</Margins>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||
import type { FC } from "react";
|
||||
import { pageTitle } from "utils/page";
|
||||
|
||||
// TODO: Replace placeholder values with real data from the AI Bridge
|
||||
// dashboard API endpoint once it is implemented.
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const StatCard: FC<StatCardProps> = ({ label, value, subtitle }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-border p-6">
|
||||
<span className="text-xs font-medium text-content-secondary uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-4xl font-bold text-content-primary">{value}</span>
|
||||
<span className="text-xs text-content-secondary">{subtitle}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardPage: FC = () => {
|
||||
const feats = useFeatureVisibility();
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const isEntitled = Boolean(feats.aibridge);
|
||||
const hasPermission = permissions.viewAnyAIBridgeInterception;
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={hasPermission}>
|
||||
<title>{pageTitle("Dashboard", "AI Bridge")}</title>
|
||||
|
||||
{!isEntitled ? (
|
||||
<PaywallAIGovernance />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Active Developers" value="—" subtitle="This week" />
|
||||
<StatCard label="Total Sessions" value="—" subtitle="All time" />
|
||||
<StatCard label="Total Commits" value="—" subtitle="All time" />
|
||||
<StatCard label="Total Tokens" value="—" subtitle="All time" />
|
||||
</div>
|
||||
)}
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WorkspaceGitEvent } from "api/types/workspaceGitEvents";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import type { FC } from "react";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { GitEventsPageView } from "./GitEventsPageView";
|
||||
|
||||
// TODO: Replace with real data from usePaginatedQuery once the backend
|
||||
// API endpoint for AI Bridge git events is implemented.
|
||||
const MOCK_GIT_EVENTS: WorkspaceGitEvent[] = [];
|
||||
|
||||
const GitEventsPage: FC = () => {
|
||||
const feats = useFeatureVisibility();
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const isEntitled = Boolean(feats.aibridge);
|
||||
const hasPermission = permissions.viewAnyAIBridgeInterception;
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={hasPermission}>
|
||||
<title>{pageTitle("Git Events", "AI Bridge")}</title>
|
||||
|
||||
<GitEventsPageView
|
||||
isLoading={false}
|
||||
isVisible={isEntitled}
|
||||
events={MOCK_GIT_EVENTS}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitEventsPage;
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { WorkspaceGitEvent } from "api/types/workspaceGitEvents";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import { TableEmpty } from "components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
import type { FC } from "react";
|
||||
import { relativeTime } from "utils/time";
|
||||
|
||||
interface GitEventsPageViewProps {
|
||||
isLoading: boolean;
|
||||
isVisible: boolean;
|
||||
events?: readonly WorkspaceGitEvent[];
|
||||
}
|
||||
|
||||
export const GitEventsPageView: FC<GitEventsPageViewProps> = ({
|
||||
isLoading,
|
||||
isVisible,
|
||||
events,
|
||||
}) => {
|
||||
if (!isVisible) {
|
||||
return <PaywallAIGovernance />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Event Type</TableHead>
|
||||
<TableHead>Developer</TableHead>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Commit SHA</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead>Repository</TableHead>
|
||||
<TableHead>Files Changed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : events?.length === 0 || events === undefined ? (
|
||||
<TableEmpty message="No git events available" />
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell>{relativeTime(event.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{event.event_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={event.owner_username ?? event.owner_id}
|
||||
src={event.owner_avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{event.session_id ? (
|
||||
<span className="font-mono text-xs text-content-secondary">
|
||||
{event.session_id.slice(0, 8)}…
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{event.commit_sha ? (
|
||||
<span className="font-mono text-xs text-content-secondary">
|
||||
{event.commit_sha.slice(0, 7)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{event.branch ?? "—"}</TableCell>
|
||||
<TableCell>{event.repo_name ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{event.files_changed.length > 0 ? (
|
||||
<Badge>{event.files_changed.length}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WorkspaceGitEventSession } from "api/types/workspaceGitEvents";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "modules/permissions/RequirePermission";
|
||||
import type { FC } from "react";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { SessionsPageView } from "./SessionsPageView";
|
||||
|
||||
// TODO: Replace with real data from usePaginatedQuery once the backend
|
||||
// API endpoint for AI Bridge sessions is implemented.
|
||||
const MOCK_SESSIONS: WorkspaceGitEventSession[] = [];
|
||||
|
||||
const SessionsPage: FC = () => {
|
||||
const feats = useFeatureVisibility();
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const isEntitled = Boolean(feats.aibridge);
|
||||
const hasPermission = permissions.viewAnyAIBridgeInterception;
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={hasPermission}>
|
||||
<title>{pageTitle("Sessions", "AI Bridge")}</title>
|
||||
|
||||
<SessionsPageView
|
||||
isLoading={false}
|
||||
isVisible={isEntitled}
|
||||
sessions={MOCK_SESSIONS}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionsPage;
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { WorkspaceGitEventSession } from "api/types/workspaceGitEvents";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { PaywallAIGovernance } from "components/Paywall/PaywallAIGovernance";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import { TableEmpty } from "components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
import type { FC } from "react";
|
||||
import { relativeTime } from "utils/time";
|
||||
|
||||
interface SessionsPageViewProps {
|
||||
isLoading: boolean;
|
||||
isVisible: boolean;
|
||||
sessions?: readonly WorkspaceGitEventSession[];
|
||||
}
|
||||
|
||||
export const SessionsPageView: FC<SessionsPageViewProps> = ({
|
||||
isLoading,
|
||||
isVisible,
|
||||
sessions,
|
||||
}) => {
|
||||
if (!isVisible) {
|
||||
return <PaywallAIGovernance />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Developer</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Workspace</TableHead>
|
||||
<TableHead>Repository</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead>Commits</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : sessions?.length === 0 || sessions === undefined ? (
|
||||
<TableEmpty message="No sessions available" />
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<TableRow key={session.session_id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-content-secondary">
|
||||
{session.session_id.slice(0, 8)}…
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={session.owner_username ?? session.owner_id}
|
||||
src={session.owner_avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{session.agent_name}</TableCell>
|
||||
<TableCell>
|
||||
{session.workspace_name ?? session.workspace_id}
|
||||
</TableCell>
|
||||
<TableCell>{session.repo_name ?? "—"}</TableCell>
|
||||
<TableCell>{session.branch ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{session.commit_count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{relativeTime(session.started_at)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
+13
-1
@@ -351,6 +351,15 @@ const AIBridgeLayout = lazy(
|
||||
const AIBridgeRequestLogsPage = lazy(
|
||||
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
|
||||
);
|
||||
const AIBridgeSessionsPage = lazy(
|
||||
() => import("./pages/AIBridgePage/SessionsPage/SessionsPage"),
|
||||
);
|
||||
const AIBridgeGitEventsPage = lazy(
|
||||
() => import("./pages/AIBridgePage/GitEventsPage/GitEventsPage"),
|
||||
);
|
||||
const AIBridgeDashboardPage = lazy(
|
||||
() => import("./pages/AIBridgePage/DashboardPage/DashboardPage"),
|
||||
);
|
||||
|
||||
const GlobalLayout = () => {
|
||||
return (
|
||||
@@ -578,8 +587,11 @@ export const router = createBrowserRouter(
|
||||
</Route>
|
||||
|
||||
<Route path="/aibridge" element={<AIBridgeLayout />}>
|
||||
<Route index element={<Navigate to="request-logs" replace />} />
|
||||
<Route index element={<Navigate to="sessions" replace />} />
|
||||
<Route path="sessions" element={<AIBridgeSessionsPage />} />
|
||||
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
|
||||
<Route path="git-events" element={<AIBridgeGitEventsPage />} />
|
||||
<Route path="dashboard" element={<AIBridgeDashboardPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/health" element={<HealthLayout />}>
|
||||
|
||||
Reference in New Issue
Block a user