Compare commits

...

7 Commits

Author SHA1 Message Date
Atif Ali adae949525 feat(site): add AI Bridge sessions, git events, and dashboard pages
- Add tab navigation to AIBridgeLayout (Sessions, Request Logs, Git Events, Dashboard)
- Rename page header title from 'AI Bridge Logs' to 'AI Bridge'
- Add placeholder TypeScript types for WorkspaceGitEvent and WorkspaceGitEventSession
- Add SessionsPage and SessionsPageView (table with mock data, TODO for real API)
- Add GitEventsPage and GitEventsPageView (table with mock data, TODO for real API)
- Add DashboardPage with four stat cards (Active Developers, Total Sessions, Total Commits, Total Tokens)
- Update router: index redirects to 'sessions', add sessions/git-events/dashboard routes
2026-02-25 17:42:09 +00:00
Atif Ali 934c2ac0c5 feat(coderd): add workspace git events API endpoint
Add a POST /workspaceagents/me/git-events endpoint that lets workspace
agents report git activity (commits, pushes, session start/end) to the
Coder control plane.

Changes:
- Run make gen to produce sqlc types for the workspace_git_events table
  (migration 000422) and queries (InsertWorkspaceGitEvent and friends).
- codersdk/workspacegitevents.go: SDK request/response types
  (WorkspaceGitEventType, CreateWorkspaceGitEventRequest,
  WorkspaceGitEvent).
- codersdk/agentsdk/agentsdk.go: PostGitEvent client method following the
  PostLogSource pattern.
- coderd/workspacegitevents.go: postWorkspaceAgentGitEvent handler that
  validates event_type, resolves workspace IDs from the agent context,
  and persists the event via InsertWorkspaceGitEvent.
- coderd/database/dbauthz/dbauthz.go: implement InsertWorkspaceGitEvent
  as a passthrough (agent is already authenticated by middleware).
- coderd/coderd.go: register POST /workspaceagents/me/git-events inside
  the /me route group that carries workspaceAgentInfo middleware.
2026-02-25 17:42:00 +00:00
Atif Ali 799b3e0c59 docs: add AI session capture admin guide 2026-02-25 17:31:50 +00:00
Atif Ali ae7674e44c feat: add coder-capture Terraform module wrapper 2026-02-25 17:31:41 +00:00
Atif Ali e09c9aa4ba feat: add workspace_git_events database migration 2026-02-25 17:19:00 +00:00
Atif Ali edcbed8a0e feat: add workspace_git_events sqlc queries 2026-02-25 16:33:37 +00:00
Atif Ali b241a780f9 feat: add coder-capture workspace session capture script 2026-02-25 16:23:23 +00:00
29 changed files with 2686 additions and 3 deletions
+1
View File
@@ -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(
+30
View File
@@ -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())
+56
View File
@@ -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)
+105
View File
@@ -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()
+45
View File
@@ -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.';
+19
View File
@@ -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"`
+7
View File
@@ -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.
+488
View File
@@ -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;
+1
View File
@@ -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);
+114
View File
@@ -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,
})
}
+15
View File
@@ -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"`
+50
View File
@@ -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"`
}
+375
View File
@@ -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.
+165
View File
@@ -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`.
+39
View File
@@ -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
})
}
+28
View File
@@ -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
+529
View File
@@ -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 "$@"
+41
View File
@@ -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;
}
+25 -2
View File
@@ -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)}&hellip;
</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)}&hellip;
</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
View File
@@ -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 />}>