mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: rank chat workspace templates (#25037)
closes CODAGT-203
## Summary
`list_templates` now returns a ranked shortlist with a recommendation,
so the chat agent can pick the right template the way a colleague would:
prefer what matches the request, what the user already uses, and what
the rest of the organization uses. Instead of teaching the model an enum
protocol in prompts, every result carries a fixed `next_step`
instruction telling the agent what to do.
## How list_templates works
1. **Fetch**: active, non-deprecated templates in the chat's
organization, filtered by the admin template allowlist, authorized as
the chat owner (no system escalation).
2. **Query relevance** (optional `query` argument): each template
receives the highest tier any of its fields matches, and a higher tier
always outranks a lower one regardless of usage:
| Tier | Match |
|------|-------|
| 4 | name or display name equals the query |
| 3 | name or display name starts with the query |
| 2 | name or display name contains the query |
| 1 | description contains the query (checked only when no name field
matched) |
| 0 | no match; the template is excluded |
Matching is case-insensitive and ignores spaces/hyphens/underscores
(`python gpu` matches `python-gpu`).
3. **Usage signals**: a new `GetTemplateRankingSignalsByOwnerID` query
returns, per template, the owner's active and recently-deleted workspace
counts within a 60-day window, the last in-window usage, and the count
of distinct developers with an active workspace (unclaimed prebuilds
excluded).
4. **Affinity score** (computed in Go, per template, from that
template's signals only):
```text
affinity = 10 x (active + 0.5 x deleted) x 0.5^(days_since_last_use /
14)
+ ln(1 + active_developers)
```
`active`/`deleted` are the owner's in-window workspace counts,
`days_since_last_use` is measured from the most recent in-window usage
(the personal term is zero without in-window usage), and
`active_developers` is the org-wide count. Personal usage carries 10x
the weight of org popularity; the confidence floor is the score of two
active developers (`ln 3`) and the required lead over the runner-up is
`ln 3 - ln 2`.
5. **Rank**: query tier first (when a query is present), then affinity
score, then name/ID for determinism. Results paginate 10 per page with
`next_page` present only when more exist.
## Recommendation contract
The result tells the agent what to do next instead of describing
confidence levels:
- `recommended_template_id` is present only when the top template is a
clear winner: the only available template, a decisive query match, or an
affinity score that clears a floor and leads the runner-up by a derived
margin.
- `next_step` is always present and is one of four fixed sentences: use
the recommendation, ask the user to choose, retry a query that matched
nothing, or report that no templates are available.
Per-template items carry raw evidence (`active_developers`,
`your_workspace_count`, `last_used_by_you`) rather than derived labels.
When signals fail to load, the tool logs and degrades to asking the user
unless the query alone is decisive.
Prompts and the `create_workspace`/`read_template` descriptions
reference the field through the `chattool.NextStepField` constant, so
the instruction lives in one place and cannot drift. `create_workspace`
remains idempotent and allowlist-enforced.
## Authorization
The signals query runs with the chat owner's permissions: reading the
owner's own workspaces plus a template-metadata read for the cross-user
popularity count. dbauthz rejects the call if any requested template is
not readable by the owner (covered by allow and deny method tests).
## Docs
Adds `docs/ai-coder/agents/tools/` explaining how agent tool calls work,
with `list_templates` ranking and the `next_step` contract as the first
documented tools.
This commit is contained in:
@@ -4592,6 +4592,45 @@ func (q *querier) GetTemplatePresetsWithPrebuilds(ctx context.Context, templateI
|
||||
return q.db.GetTemplatePresetsWithPrebuilds(ctx, templateID)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
|
||||
// The personal signal reads only the owner's own workspaces.
|
||||
workspaceObj := rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String())
|
||||
if arg.OrganizationID != uuid.Nil {
|
||||
workspaceObj = workspaceObj.InOrg(arg.OrganizationID)
|
||||
} else {
|
||||
workspaceObj = workspaceObj.AnyOrganization()
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, workspaceObj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The cross-user popularity count is template metadata, not workspace
|
||||
// reads, so it only requires read access to every requested template.
|
||||
if len(arg.TemplateIDs) > 0 {
|
||||
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceTemplate.Type)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
|
||||
}
|
||||
authorizedTemplates, err := q.db.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
OrganizationID: arg.OrganizationID,
|
||||
IDs: arg.TemplateIDs,
|
||||
}, prep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizedIDs := make(map[uuid.UUID]struct{}, len(authorizedTemplates))
|
||||
for _, template := range authorizedTemplates {
|
||||
authorizedIDs[template.ID] = struct{}{}
|
||||
}
|
||||
for _, templateID := range arg.TemplateIDs {
|
||||
if _, ok := authorizedIDs[templateID]; !ok {
|
||||
return nil, NotAuthorizedError{Err: xerrors.Errorf("not authorized to read template %s", templateID)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return q.db.GetTemplateRankingSignalsByOwnerID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
|
||||
if err := q.authorizeTemplateInsights(ctx, arg.TemplateIDs); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3586,6 +3586,49 @@ func (s *MethodTestSuite) TestWorkspace() {
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts()
|
||||
}))
|
||||
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
|
||||
OwnerID: uuid.New(),
|
||||
OrganizationID: uuid.New(),
|
||||
TemplateIDs: []uuid.UUID{uuid.New()},
|
||||
}
|
||||
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
OrganizationID: arg.OrganizationID,
|
||||
IDs: arg.TemplateIDs,
|
||||
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
|
||||
dbm.EXPECT().GetTemplateRankingSignalsByOwnerID(gomock.Any(), arg).Return([]database.GetTemplateRankingSignalsByOwnerIDRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
|
||||
OwnerID: uuid.New(),
|
||||
TemplateIDs: []uuid.UUID{uuid.New()},
|
||||
}
|
||||
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
IDs: arg.TemplateIDs,
|
||||
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
|
||||
dbm.EXPECT().GetTemplateRankingSignalsByOwnerID(gomock.Any(), arg).Return([]database.GetTemplateRankingSignalsByOwnerIDRow{}, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetTemplateRankingSignalsByOwnerID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
// Deny path: an unauthorized template ID rejects the call before the
|
||||
// query runs (no query expectation is registered).
|
||||
arg := database.GetTemplateRankingSignalsByOwnerIDParams{
|
||||
OwnerID: uuid.New(),
|
||||
OrganizationID: uuid.New(),
|
||||
TemplateIDs: []uuid.UUID{uuid.New(), uuid.New()},
|
||||
}
|
||||
dbm.EXPECT().GetAuthorizedTemplates(gomock.Any(), database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
OrganizationID: arg.OrganizationID,
|
||||
IDs: arg.TemplateIDs,
|
||||
}, gomock.Any()).Return([]database.Template{{ID: arg.TemplateIDs[0]}}, nil).AnyTimes()
|
||||
check.Args(arg).
|
||||
Asserts(rbac.ResourceWorkspace.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionRead).
|
||||
Errors(dbauthz.NotAuthorizedError{Err: xerrors.Errorf("not authorized to read template %s", arg.TemplateIDs[1])})
|
||||
}))
|
||||
s.Run("GetWorkspaceACLByID", s.Mocked(func(dbM *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
ws := testutil.Fake(s.T(), faker, database.Workspace{})
|
||||
dbM.EXPECT().GetWorkspaceByID(gomock.Any(), ws.ID).Return(ws, nil).AnyTimes()
|
||||
|
||||
@@ -192,6 +192,10 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
|
||||
testName := s.T().Name()
|
||||
names := strings.Split(testName, "/")
|
||||
methodName := names[len(names)-1]
|
||||
// Repeated subtests get "#NN" suffixes; count them under the base method.
|
||||
if baseMethodName, _, ok := strings.Cut(methodName, "#"); ok {
|
||||
methodName = baseMethodName
|
||||
}
|
||||
s.methodAccounting[methodName]++
|
||||
|
||||
fakeAuthorizer := &coderdtest.FakeAuthorizer{}
|
||||
|
||||
+8
@@ -2978,6 +2978,14 @@ func (m queryMetricsStore) GetTemplatePresetsWithPrebuilds(ctx context.Context,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTemplateRankingSignalsByOwnerID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTemplateRankingSignalsByOwnerID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTemplateRankingSignalsByOwnerID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTemplateUsageStats(ctx, arg)
|
||||
|
||||
Generated
+15
@@ -5547,6 +5547,21 @@ func (mr *MockStoreMockRecorder) GetTemplatePresetsWithPrebuilds(ctx, templateID
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplatePresetsWithPrebuilds", reflect.TypeOf((*MockStore)(nil).GetTemplatePresetsWithPrebuilds), ctx, templateID)
|
||||
}
|
||||
|
||||
// GetTemplateRankingSignalsByOwnerID mocks base method.
|
||||
func (m *MockStore) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg database.GetTemplateRankingSignalsByOwnerIDParams) ([]database.GetTemplateRankingSignalsByOwnerIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTemplateRankingSignalsByOwnerID", ctx, arg)
|
||||
ret0, _ := ret[0].([]database.GetTemplateRankingSignalsByOwnerIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTemplateRankingSignalsByOwnerID indicates an expected call of GetTemplateRankingSignalsByOwnerID.
|
||||
func (mr *MockStoreMockRecorder) GetTemplateRankingSignalsByOwnerID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateRankingSignalsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetTemplateRankingSignalsByOwnerID), ctx, arg)
|
||||
}
|
||||
|
||||
// GetTemplateUsageStats mocks base method.
|
||||
func (m *MockStore) GetTemplateUsageStats(ctx context.Context, arg database.GetTemplateUsageStatsParams) ([]database.TemplateUsageStat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+6
@@ -776,6 +776,12 @@ type sqlcQuerier interface {
|
||||
// It also returns the number of desired instances for each preset.
|
||||
// If template_id is specified, only template versions associated with that template will be returned.
|
||||
GetTemplatePresetsWithPrebuilds(ctx context.Context, templateID uuid.NullUUID) ([]GetTemplatePresetsWithPrebuildsRow, error)
|
||||
// GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
|
||||
// one owner: in-window active and recently-deleted workspace counts, the last
|
||||
// in-window usage, and distinct active developers per template. The affinity
|
||||
// score is computed in Go (see listtemplates.go) so the ranking policy and
|
||||
// its confidence thresholds live in one place.
|
||||
GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg GetTemplateRankingSignalsByOwnerIDParams) ([]GetTemplateRankingSignalsByOwnerIDRow, error)
|
||||
GetTemplateUsageStats(ctx context.Context, arg GetTemplateUsageStatsParams) ([]TemplateUsageStat, error)
|
||||
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
|
||||
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
|
||||
|
||||
Generated
+111
@@ -36996,6 +36996,117 @@ func (q *sqlQuerier) GetRegularWorkspaceCreateMetrics(ctx context.Context) ([]Ge
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTemplateRankingSignalsByOwnerID = `-- name: GetTemplateRankingSignalsByOwnerID :many
|
||||
WITH org_usage AS (
|
||||
-- Distinct developers with a non-deleted workspace; the prebuilds system
|
||||
-- user is excluded so unclaimed prebuilds do not inflate popularity.
|
||||
SELECT
|
||||
w.template_id,
|
||||
COUNT(DISTINCT w.owner_id) AS org_devs
|
||||
FROM
|
||||
workspaces w
|
||||
WHERE
|
||||
w.template_id = ANY($1 :: uuid[])
|
||||
AND NOT w.deleted
|
||||
AND w.owner_id != $2 :: uuid
|
||||
AND CASE
|
||||
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
|
||||
w.organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
w.template_id
|
||||
),
|
||||
user_usage AS (
|
||||
-- The owner's workspaces used within the lookback window, split into
|
||||
-- active and recently-deleted counts.
|
||||
SELECT
|
||||
w.template_id,
|
||||
COUNT(*) FILTER (WHERE NOT w.deleted) AS active_count,
|
||||
COUNT(*) FILTER (WHERE w.deleted) AS deleted_recent_count,
|
||||
MAX(w.last_used_at) :: timestamptz AS last_used_at
|
||||
FROM
|
||||
workspaces w
|
||||
WHERE
|
||||
w.owner_id = $4
|
||||
AND w.template_id = ANY($1 :: uuid[])
|
||||
AND w.last_used_at > $5 :: timestamptz
|
||||
AND CASE
|
||||
WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
|
||||
w.organization_id = $3
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
w.template_id
|
||||
)
|
||||
SELECT
|
||||
t.template_id :: uuid AS template_id,
|
||||
COALESCE(u.active_count, 0) :: bigint AS active_count,
|
||||
COALESCE(u.deleted_recent_count, 0) :: bigint AS deleted_recent_count,
|
||||
u.last_used_at,
|
||||
COALESCE(o.org_devs, 0) :: bigint AS org_devs
|
||||
FROM
|
||||
unnest($1 :: uuid[]) AS t(template_id)
|
||||
LEFT JOIN user_usage u ON u.template_id = t.template_id
|
||||
LEFT JOIN org_usage o ON o.template_id = t.template_id
|
||||
`
|
||||
|
||||
type GetTemplateRankingSignalsByOwnerIDParams struct {
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
PrebuildsUserID uuid.UUID `db:"prebuilds_user_id" json:"prebuilds_user_id"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
LookbackCutoff time.Time `db:"lookback_cutoff" json:"lookback_cutoff"`
|
||||
}
|
||||
|
||||
type GetTemplateRankingSignalsByOwnerIDRow struct {
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
ActiveCount int64 `db:"active_count" json:"active_count"`
|
||||
DeletedRecentCount int64 `db:"deleted_recent_count" json:"deleted_recent_count"`
|
||||
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
|
||||
OrgDevs int64 `db:"org_devs" json:"org_devs"`
|
||||
}
|
||||
|
||||
// GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
|
||||
// one owner: in-window active and recently-deleted workspace counts, the last
|
||||
// in-window usage, and distinct active developers per template. The affinity
|
||||
// score is computed in Go (see listtemplates.go) so the ranking policy and
|
||||
// its confidence thresholds live in one place.
|
||||
func (q *sqlQuerier) GetTemplateRankingSignalsByOwnerID(ctx context.Context, arg GetTemplateRankingSignalsByOwnerIDParams) ([]GetTemplateRankingSignalsByOwnerIDRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTemplateRankingSignalsByOwnerID,
|
||||
pq.Array(arg.TemplateIDs),
|
||||
arg.PrebuildsUserID,
|
||||
arg.OrganizationID,
|
||||
arg.OwnerID,
|
||||
arg.LookbackCutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTemplateRankingSignalsByOwnerIDRow
|
||||
for rows.Next() {
|
||||
var i GetTemplateRankingSignalsByOwnerIDRow
|
||||
if err := rows.Scan(
|
||||
&i.TemplateID,
|
||||
&i.ActiveCount,
|
||||
&i.DeletedRecentCount,
|
||||
&i.LastUsedAt,
|
||||
&i.OrgDevs,
|
||||
); 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 getWorkspaceACLByID = `-- name: GetWorkspaceACLByID :one
|
||||
SELECT
|
||||
group_acl as groups,
|
||||
|
||||
@@ -497,6 +497,65 @@ LEFT JOIN workspaces ON workspaces.template_id = templates.id AND workspaces.del
|
||||
WHERE templates.id = ANY(@template_ids :: uuid[])
|
||||
GROUP BY templates.id;
|
||||
|
||||
-- name: GetTemplateRankingSignalsByOwnerID :many
|
||||
-- GetTemplateRankingSignalsByOwnerID returns raw template-ranking signals for
|
||||
-- one owner: in-window active and recently-deleted workspace counts, the last
|
||||
-- in-window usage, and distinct active developers per template. The affinity
|
||||
-- score is computed in Go (see listtemplates.go) so the ranking policy and
|
||||
-- its confidence thresholds live in one place.
|
||||
WITH org_usage AS (
|
||||
-- Distinct developers with a non-deleted workspace; the prebuilds system
|
||||
-- user is excluded so unclaimed prebuilds do not inflate popularity.
|
||||
SELECT
|
||||
w.template_id,
|
||||
COUNT(DISTINCT w.owner_id) AS org_devs
|
||||
FROM
|
||||
workspaces w
|
||||
WHERE
|
||||
w.template_id = ANY(@template_ids :: uuid[])
|
||||
AND NOT w.deleted
|
||||
AND w.owner_id != @prebuilds_user_id :: uuid
|
||||
AND CASE
|
||||
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
|
||||
w.organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
w.template_id
|
||||
),
|
||||
user_usage AS (
|
||||
-- The owner's workspaces used within the lookback window, split into
|
||||
-- active and recently-deleted counts.
|
||||
SELECT
|
||||
w.template_id,
|
||||
COUNT(*) FILTER (WHERE NOT w.deleted) AS active_count,
|
||||
COUNT(*) FILTER (WHERE w.deleted) AS deleted_recent_count,
|
||||
MAX(w.last_used_at) :: timestamptz AS last_used_at
|
||||
FROM
|
||||
workspaces w
|
||||
WHERE
|
||||
w.owner_id = @owner_id
|
||||
AND w.template_id = ANY(@template_ids :: uuid[])
|
||||
AND w.last_used_at > @lookback_cutoff :: timestamptz
|
||||
AND CASE
|
||||
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000' :: uuid THEN
|
||||
w.organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
GROUP BY
|
||||
w.template_id
|
||||
)
|
||||
SELECT
|
||||
t.template_id :: uuid AS template_id,
|
||||
COALESCE(u.active_count, 0) :: bigint AS active_count,
|
||||
COALESCE(u.deleted_recent_count, 0) :: bigint AS deleted_recent_count,
|
||||
u.last_used_at,
|
||||
COALESCE(o.org_devs, 0) :: bigint AS org_devs
|
||||
FROM
|
||||
unnest(@template_ids :: uuid[]) AS t(template_id)
|
||||
LEFT JOIN user_usage u ON u.template_id = t.template_id
|
||||
LEFT JOIN org_usage o ON o.template_id = t.template_id;
|
||||
|
||||
-- name: InsertWorkspace :one
|
||||
INSERT INTO
|
||||
workspaces (
|
||||
|
||||
Reference in New Issue
Block a user