mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the runtime_mcp_overlay column and a per-task dispatch hook; this change inverts the default from "all-on" to opt-in and locks the overlay to the agent owner's own connected apps: - Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP. Owner-only read/write; non-owner GET/PUT silently redacts/drops the field (same shape as mcp_config). - agent_task_queue carries originator_user_id UUID. Set from the top-of-chain HUMAN at every enqueue path: * issue/mention comment by member -> author_id * issue/mention comment by agent -> inherit via comment.source_task_id -> parent task originator_user_id * quick-create -> requester_id * chat -> initiator_user_id * retry -> SQL-inherited from parent row * autopilot -> NULL (system-driven) - BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID, agent) and short-circuits on five gates: invalid originator, originator != agent.owner_id, empty allowlist, empty intersection of allowlist ∩ active connections, defensive empty session URL. Composio CreateSession is called with BOTH `toolkits.slugs` (the intersection) AND `connected_accounts` (the pinned account ids), narrowing the tool-router twice. - The originator-vs-owner gate closes the agent-fanout privacy hole: any workspace member who can @-mention a public agent used to project the owner's connected apps into their run. Now the overlay only mounts when the human at the top of the chain IS the agent owner. Tests: - dispatch_test.go covers all 5 gates plus uppercase/whitespace slug normalisation. - task_runtime_mcp_overlay_test.go covers the no-op gates of the new applyRuntimeMCPOverlay signature. - agent_composio_allowlist_test.go (handler): owner roundtrip (list/empty/null), workspace-admin silent-drop, owner-only GET visibility, pure normaliseComposioToolkitAllowlist. - resolve_originator_test.go (service, DB-backed): member-authored, agent-authored inherits via comment.source_task_id, invalid id. Migration 129 up/down/up verified against docker postgres. Co-authored-by: multica-agent <[email protected]> * chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
Eve
parent
aa4268c1e2
commit
4708dba978
@@ -60,13 +60,25 @@ type AgentResponse struct {
|
||||
// ThinkingLevel is the runtime-native reasoning/effort token persisted
|
||||
// for this agent (empty = use runtime default). The picker is per-runtime
|
||||
// per-model; the API never normalizes across providers. See MUL-2339.
|
||||
ThinkingLevel string `json:"thinking_level"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []AgentSkillSummary `json:"skills"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
ThinkingLevel string `json:"thinking_level"`
|
||||
// ComposioToolkitAllowlist is the subset of Composio toolkit slugs this
|
||||
// agent is allowed to mount as MCP at task dispatch — but only when the
|
||||
// run originator is the agent owner (MUL-3869 / MUL-3721). NULL or empty
|
||||
// = no overlay regardless of who triggers. Like mcp_config, this is
|
||||
// owner-only data: the slugs themselves are not secret, but the
|
||||
// "this is what {agent owner} is willing to surface" view is — surfacing
|
||||
// it cross-account is privacy-confusing UX and would let workspace
|
||||
// members infer another member's integration footprint. Redacted to
|
||||
// `nil` + `composio_toolkit_allowlist_redacted=true` for non-owners,
|
||||
// mirroring the existing mcp_config redaction contract.
|
||||
ComposioToolkitAllowlist []string `json:"composio_toolkit_allowlist,omitempty"`
|
||||
ComposioToolkitAllowlistRedacted bool `json:"composio_toolkit_allowlist_redacted,omitempty"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []AgentSkillSummary `json:"skills"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
}
|
||||
|
||||
// runtimeConfigGatewayTokenMask is the placeholder the API substitutes for
|
||||
@@ -117,31 +129,41 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
mcpConfig = json.RawMessage(a.McpConfig)
|
||||
}
|
||||
|
||||
// composio_toolkit_allowlist: the column is stored as TEXT[] and arrives
|
||||
// here as a []string (sqlc). NULL and `{}` both serialize as nil through
|
||||
// the postgres driver — both correctly mean "no toolkits", but the API
|
||||
// surface keeps them distinguishable from "owner has not opened the
|
||||
// integration yet" only via the trio (slice nil / slice empty / slice
|
||||
// non-empty). We hand the slice through verbatim so the redaction +
|
||||
// owner-only gate below can decide.
|
||||
composioAllowlist := a.ComposioToolkitAllowlist
|
||||
|
||||
return AgentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
RuntimeID: uuidToString(a.RuntimeID),
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
Instructions: a.Instructions,
|
||||
AvatarURL: textToPtr(a.AvatarUrl),
|
||||
RuntimeMode: a.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
HasCustomEnv: envKeyCount > 0,
|
||||
CustomEnvKeyCount: envKeyCount,
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
Model: a.Model.String,
|
||||
ThinkingLevel: a.ThinkingLevel.String,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
Skills: []AgentSkillSummary{},
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
RuntimeID: uuidToString(a.RuntimeID),
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
Instructions: a.Instructions,
|
||||
AvatarURL: textToPtr(a.AvatarUrl),
|
||||
RuntimeMode: a.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
HasCustomEnv: envKeyCount > 0,
|
||||
CustomEnvKeyCount: envKeyCount,
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
Model: a.Model.String,
|
||||
ThinkingLevel: a.ThinkingLevel.String,
|
||||
ComposioToolkitAllowlist: composioAllowlist,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
Skills: []AgentSkillSummary{},
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +632,17 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if actorType == "agent" || alwaysRedact || !canViewAgentSecrets(a, userID, member.Role) {
|
||||
redactMcpConfig(&resp)
|
||||
}
|
||||
// composio_toolkit_allowlist is owner-only — not because the slugs
|
||||
// are secret but because surfacing "what {owner} has opted into"
|
||||
// across the workspace leaks the owner's integration footprint and
|
||||
// confuses non-owners who cannot actually edit it. Workspace
|
||||
// owner/admin do NOT bypass this gate (unlike mcp_config) because
|
||||
// the dispatch decision is owner-vs-originator anyway, so admins
|
||||
// seeing the slugs gives them nothing actionable. Agent actors are
|
||||
// also redacted (same A2A lateral-movement reasoning as mcp_config).
|
||||
if actorType == "agent" || uuidToString(a.OwnerID) != userID {
|
||||
redactComposioToolkitAllowlist(&resp)
|
||||
}
|
||||
visible = append(visible, resp)
|
||||
}
|
||||
|
||||
@@ -660,6 +693,11 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
redactMcpConfig(&resp)
|
||||
}
|
||||
}
|
||||
// composio_toolkit_allowlist visibility is strictly owner-only (see
|
||||
// ListAgents for the rationale). No workspace owner/admin bypass.
|
||||
if actorType == "agent" || uuidToString(agent.OwnerID) != userID {
|
||||
redactComposioToolkitAllowlist(&resp)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -678,6 +716,13 @@ type CreateAgentRequest struct {
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Model string `json:"model"`
|
||||
ThinkingLevel string `json:"thinking_level"`
|
||||
// ComposioToolkitAllowlist seeds the per-task overlay gate (MUL-3869). On
|
||||
// create only the calling user can be the owner, so we accept the field
|
||||
// unconditionally here; the cross-owner permission gate lives on PUT.
|
||||
// Nil = leave column NULL (no overlay). Empty slice = explicit `{}` (no
|
||||
// overlay either, but the column reads as "configured" — distinct from
|
||||
// "owner has never opened the integration").
|
||||
ComposioToolkitAllowlist []string `json:"composio_toolkit_allowlist"`
|
||||
// Template records which template slug was used to seed this agent
|
||||
// (e.g. "coding" / "planning" / "writing" / "assistant"). Empty when
|
||||
// the caller didn't come from a template picker — the `agent_created`
|
||||
@@ -811,23 +856,32 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
mc = append([]byte(nil), rawMcpConfig...)
|
||||
}
|
||||
|
||||
// composio_toolkit_allowlist: the JSON field is a list-of-slugs that gets
|
||||
// stored as TEXT[]. We normalise here (lowercase + trim + dedupe) so the
|
||||
// dispatch path can compare against per-user connection rows with a
|
||||
// straight equality. A nil slice (or absent JSON key) maps to a NULL
|
||||
// column on insert. An explicitly empty list (`[]`) is preserved as an
|
||||
// empty TEXT[] (the dispatch path treats NULL and `{}` identically).
|
||||
allowlist := normaliseComposioToolkitAllowlist(req.ComposioToolkitAllowlist)
|
||||
|
||||
created, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Instructions: req.Instructions,
|
||||
AvatarUrl: ptrToText(req.AvatarURL),
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
CustomEnv: ce,
|
||||
CustomArgs: ca,
|
||||
McpConfig: mc,
|
||||
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
|
||||
ThinkingLevel: pgtype.Text{String: req.ThinkingLevel, Valid: req.ThinkingLevel != ""},
|
||||
WorkspaceID: wsUUID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Instructions: req.Instructions,
|
||||
AvatarUrl: ptrToText(req.AvatarURL),
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
CustomEnv: ce,
|
||||
CustomArgs: ca,
|
||||
McpConfig: mc,
|
||||
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
|
||||
ThinkingLevel: pgtype.Text{String: req.ThinkingLevel, Valid: req.ThinkingLevel != ""},
|
||||
ComposioToolkitAllowlist: allowlist,
|
||||
})
|
||||
if err != nil {
|
||||
// Unique constraint on (workspace_id, name) — return a clear conflict error
|
||||
@@ -895,6 +949,16 @@ type UpdateAgentRequest struct {
|
||||
// Distinguishing those modes is why this is a pointer; the raw-fields
|
||||
// map captured at decode time tells us whether the key was sent.
|
||||
ThinkingLevel *string `json:"thinking_level"`
|
||||
// ComposioToolkitAllowlist is a tri-state, same pattern as
|
||||
// thinking_level, mcp_config:
|
||||
// - field omitted → no change (column preserved as-is)
|
||||
// - field present with null → explicit clear (ClearAgent... query)
|
||||
// - field present with [] → store empty TEXT[] (configured, no toolkits)
|
||||
// - field present with non-empty → store deduped lowercase slugs
|
||||
// The decode-time raw fields map disambiguates "omitted" from "explicit
|
||||
// null" (a *[]string can't, because a nil pointer is the same wire
|
||||
// representation as both). MUL-3869.
|
||||
ComposioToolkitAllowlist *[]string `json:"composio_toolkit_allowlist"`
|
||||
}
|
||||
|
||||
// workspaceAlwaysRedactSecrets reports whether the workspace has opted
|
||||
@@ -942,9 +1006,17 @@ func canViewAgentSecrets(agent db.Agent, userID string, memberRole string) bool
|
||||
// redaction in ListAgents / GetAgent. The caller still receives the
|
||||
// canonical form in the HTTP response; only the broadcast copy is
|
||||
// redacted.
|
||||
//
|
||||
// composio_toolkit_allowlist follows the same fan-out rule: every
|
||||
// workspace member subscribes to agent:created/updated/archived, so
|
||||
// a non-redacted broadcast would leak the agent owner's per-toolkit
|
||||
// allowlist to every member regardless of whether they would have
|
||||
// been allowed to read it via GET. Redact unconditionally on the
|
||||
// broadcast copy.
|
||||
func broadcastAgentResponse(resp AgentResponse) AgentResponse {
|
||||
out := resp
|
||||
redactMcpConfig(&out)
|
||||
redactComposioToolkitAllowlist(&out)
|
||||
// Belt-and-suspenders: agentToResponse already masks gateway.token on
|
||||
// every read, so by the time a response reaches this broadcast helper
|
||||
// the field is already "***". Re-mask anyway so a future refactor that
|
||||
@@ -965,6 +1037,61 @@ func redactMcpConfig(resp *AgentResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
// redactComposioToolkitAllowlist removes the composio_toolkit_allowlist
|
||||
// value from the response when the caller is not the agent owner. The slug
|
||||
// list itself is not secret, but the "what {agent owner} has opted into"
|
||||
// view leaks the owner's integration footprint across the workspace, which
|
||||
// is the same privacy concern that gates mcp_config visibility behind
|
||||
// owner-only canViewAgentSecrets. We surface a coarse `_redacted` flag so
|
||||
// the front-end can render "Configured" without the contents (parity with
|
||||
// mcp_config_redacted). The clearing matters: the JSON `omitempty` only
|
||||
// drops nil slices, so reset to nil rather than `[]string{}`.
|
||||
func redactComposioToolkitAllowlist(resp *AgentResponse) {
|
||||
if resp.ComposioToolkitAllowlist != nil {
|
||||
resp.ComposioToolkitAllowlist = nil
|
||||
resp.ComposioToolkitAllowlistRedacted = true
|
||||
}
|
||||
}
|
||||
|
||||
// normaliseComposioToolkitAllowlist canonicalises an incoming allowlist
|
||||
// payload before persisting. Each slug is trimmed + lowercased so the
|
||||
// dispatch path (which compares against user_composio_connection.toolkit_slug,
|
||||
// stored lowercased by the Composio service) does a flat string match
|
||||
// without needing a CITEXT or per-query LOWER(). Empty / whitespace-only
|
||||
// strings are dropped and duplicates collapsed so a sloppy UI payload
|
||||
// can't waste DB row-length or surface twice in the response.
|
||||
//
|
||||
// Contract:
|
||||
// - nil in → nil out: "field absent / explicit null" preserved. Combined
|
||||
// with sqlc.narg('composio_toolkit_allowlist')::text[] in UpdateAgent,
|
||||
// this is what makes "omit field" mean "leave column alone".
|
||||
// - empty slice in → empty slice out: "owner cleared all toolkits".
|
||||
// Distinct from nil only at the column-NULL level; the dispatch path
|
||||
// treats both identically as "no overlay".
|
||||
// - non-empty in → trimmed, lowercased, deduped, stable order.
|
||||
func normaliseComposioToolkitAllowlist(in []string) []string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
if len(in) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, raw := range in {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[s]; dup {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactAgentResponseForActor strips secret-bearing fields from an agent
|
||||
// resource HTTP response when the request actor is an agent. Read
|
||||
// handlers already gate on actorType — mutation handlers
|
||||
@@ -972,9 +1099,14 @@ func redactMcpConfig(resp *AgentResponse) {
|
||||
// an agent with a host owner/admin token can do an unrelated mutation
|
||||
// (e.g. flip max_concurrent_tasks) on a target agent and harvest the
|
||||
// target's mcp_config from the mutation response. MUL-2600.
|
||||
//
|
||||
// composio_toolkit_allowlist is redacted under the same logic: an agent
|
||||
// runs with its host owner's PAT, so a mutation against a sibling agent
|
||||
// could otherwise return the sibling owner's allowlist in the response.
|
||||
func redactAgentResponseForActor(resp *AgentResponse, actorType string) {
|
||||
if actorType == "agent" {
|
||||
redactMcpConfig(resp)
|
||||
redactComposioToolkitAllowlist(resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,6 +1312,40 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// composio_toolkit_allowlist handling (MUL-3869). Tri-state semantics
|
||||
// mirror thinking_level (see above): omitted → no change, null →
|
||||
// ClearAgentComposioToolkitAllowlist, slice → wholesale replace.
|
||||
//
|
||||
// Owner-only WRITE. The caller is already past canManageAgent, which lets
|
||||
// workspace owner/admins through alongside the agent owner — but the
|
||||
// dispatch gate downstream is owner-vs-originator, so a workspace admin
|
||||
// flipping someone else's agent allowlist would create an allowlist that
|
||||
// can never fire (the admin is not the originator either) and silently
|
||||
// confuse the agent owner about what their agent is allowed to mount.
|
||||
// Drop the field with a debug log instead of erroring so an over-eager
|
||||
// UI that sends the whole agent payload back on every save (PATCH-as-PUT)
|
||||
// keeps working — same "silent ignore" stance the issue calls out, and
|
||||
// the same one mcp_config takes for the broader admin pattern.
|
||||
shouldClearComposioAllowlist := false
|
||||
if _, hasAllowlist := rawFields["composio_toolkit_allowlist"]; hasAllowlist {
|
||||
isAgentOwner := uuidToString(existing.OwnerID) == requestUserID(r)
|
||||
if !isAgentOwner {
|
||||
slog.Debug("update agent: composio_toolkit_allowlist write by non-owner silently dropped",
|
||||
append(logger.RequestAttrs(r), "agent_id", id)...)
|
||||
} else if req.ComposioToolkitAllowlist == nil {
|
||||
// JSON null → explicit clear via the dedicated query.
|
||||
shouldClearComposioAllowlist = true
|
||||
} else {
|
||||
// Normalise (trim/lowercase/dedupe). Empty slice is preserved as
|
||||
// an empty TEXT[] so the persisted value distinguishes "owner
|
||||
// cleared every toolkit" from "owner has never opened the
|
||||
// integration" (the dispatch path treats both as "no overlay"
|
||||
// either way, but the column tells UX whether to show a primed
|
||||
// vs empty picker).
|
||||
params.ComposioToolkitAllowlist = normaliseComposioToolkitAllowlist(*req.ComposioToolkitAllowlist)
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
@@ -1206,6 +1372,14 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if shouldClearComposioAllowlist {
|
||||
updated, err = h.Queries.ClearAgentComposioToolkitAllowlist(r.Context(), updated.ID)
|
||||
if err != nil {
|
||||
slog.Warn("clear agent composio_toolkit_allowlist failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to clear composio_toolkit_allowlist: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp := agentToResponse(updated)
|
||||
// agentToResponse always initialises Skills as []; junction-table rows
|
||||
@@ -1223,6 +1397,13 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
actorType, actorID := h.resolveActor(r, userID, uuidToString(updated.WorkspaceID))
|
||||
h.publish(protocol.EventAgentStatus, uuidToString(updated.WorkspaceID), actorType, actorID, map[string]any{"agent": broadcastAgentResponse(resp)})
|
||||
redactAgentResponseForActor(&resp, actorType)
|
||||
// Workspace admins / non-owner members pass canManageAgent for legitimate
|
||||
// admin actions (e.g. bulk reassigning agents off a leaving member's
|
||||
// runtime), but they must not learn the agent owner's composio allowlist
|
||||
// from the mutation response. See ListAgents/GetAgent for the same gate.
|
||||
if uuidToString(updated.OwnerID) != userID {
|
||||
redactComposioToolkitAllowlist(&resp)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// allowlistFixture seeds an agent owned by a freshly-created user (not the
|
||||
// workspace owner) so we can drive the "caller is workspace owner / admin
|
||||
// but not agent owner" branch of UpdateAgent.composio_toolkit_allowlist.
|
||||
// Returns the agent id and the agent owner's user id; the test's own
|
||||
// testUserID stays the workspace owner.
|
||||
func allowlistFixture(t *testing.T) (agentID, agentOwnerID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Allowlist Owner', '[email protected]')
|
||||
RETURNING id
|
||||
`).Scan(&agentOwnerID); err != nil {
|
||||
t.Fatalf("create owner user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = '[email protected]'`)
|
||||
})
|
||||
|
||||
if _, err := testPool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, testWorkspaceID, agentOwnerID); err != nil {
|
||||
t.Fatalf("add owner as workspace member: %v", err)
|
||||
}
|
||||
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
)
|
||||
VALUES ($1, 'allowlist-test-agent', '', 'cloud', '{}'::jsonb,
|
||||
$2, 'workspace', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, handlerTestRuntimeID(t), agentOwnerID).Scan(&agentID); err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(),
|
||||
`DELETE FROM agent WHERE id = $1`, agentID)
|
||||
})
|
||||
return agentID, agentOwnerID
|
||||
}
|
||||
|
||||
func readAllowlistColumn(t *testing.T, agentID string) ([]string, bool) {
|
||||
t.Helper()
|
||||
var stored []string
|
||||
var isNull bool
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
SELECT COALESCE(composio_toolkit_allowlist, '{}'::text[]),
|
||||
composio_toolkit_allowlist IS NULL
|
||||
FROM agent WHERE id = $1
|
||||
`, agentID).Scan(&stored, &isNull); err != nil {
|
||||
t.Fatalf("read allowlist column: %v", err)
|
||||
}
|
||||
return stored, isNull
|
||||
}
|
||||
|
||||
// TestUpdateAgent_AllowlistRoundtripForOwner is the happy path: the agent
|
||||
// owner submits a list, then a normalised duplicate, then an empty array,
|
||||
// then JSON null. The handler must persist the four canonical states
|
||||
// distinguishable by the dispatch path:
|
||||
// - non-empty deduped lowercased TEXT[]
|
||||
// - the same value after a noisy second write (idempotent)
|
||||
// - empty `{}` after explicit []
|
||||
// - NULL after an explicit null
|
||||
func TestUpdateAgent_AllowlistRoundtripForOwner(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
agentID, ownerID := allowlistFixture(t)
|
||||
|
||||
// 1. Owner writes a noisy list — trims/casefolds/dedupes inline.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.UpdateAgent(w, withURLParam(newRequestAs(
|
||||
ownerID, http.MethodPut, "/api/agents/"+agentID,
|
||||
map[string]any{
|
||||
"composio_toolkit_allowlist": []string{" Notion ", "NOTION", "github", ""},
|
||||
},
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent owner write: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
stored, isNull := readAllowlistColumn(t, agentID)
|
||||
if isNull {
|
||||
t.Fatalf("after owner write, allowlist still NULL")
|
||||
}
|
||||
if len(stored) != 2 || stored[0] != "notion" || stored[1] != "github" {
|
||||
t.Errorf("normalised allowlist = %v; want [notion github]", stored)
|
||||
}
|
||||
|
||||
// 2. Owner writes empty array — distinct from NULL on the column.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.UpdateAgent(w, withURLParam(newRequestAs(
|
||||
ownerID, http.MethodPut, "/api/agents/"+agentID,
|
||||
map[string]any{"composio_toolkit_allowlist": []string{}},
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent owner empty write: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
stored, isNull = readAllowlistColumn(t, agentID)
|
||||
if isNull {
|
||||
t.Fatalf("empty array must persist as empty TEXT[], not NULL")
|
||||
}
|
||||
if len(stored) != 0 {
|
||||
t.Errorf("after empty write, stored = %v; want empty", stored)
|
||||
}
|
||||
|
||||
// 3. Owner explicitly nulls the column.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.UpdateAgent(w, withURLParam(newRequestAs(
|
||||
ownerID, http.MethodPut, "/api/agents/"+agentID,
|
||||
map[string]any{"composio_toolkit_allowlist": nil},
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent owner null write: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
_, isNull = readAllowlistColumn(t, agentID)
|
||||
if !isNull {
|
||||
t.Errorf("explicit null must persist as NULL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAgent_AllowlistSilentlyDroppedForNonOwner is the Stage 3.1
|
||||
// privacy guarantee on the write path: a workspace owner / admin can call
|
||||
// UpdateAgent against another user's agent (which canManageAgent allows
|
||||
// for legitimate admin actions like reassigning the agent off a leaving
|
||||
// member's runtime), but a `composio_toolkit_allowlist` field in that
|
||||
// request must be silently ignored. Without this, an admin could
|
||||
// arbitrarily widen what an agent surfaces from another member's
|
||||
// connected accounts.
|
||||
func TestUpdateAgent_AllowlistSilentlyDroppedForNonOwner(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
agentID, _ := allowlistFixture(t)
|
||||
|
||||
// Seed an existing allowlist via direct SQL so we can prove the
|
||||
// admin's submission did not overwrite it.
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
UPDATE agent SET composio_toolkit_allowlist = $2 WHERE id = $1
|
||||
`, agentID, []string{"notion"}); err != nil {
|
||||
t.Fatalf("seed existing allowlist: %v", err)
|
||||
}
|
||||
|
||||
// Workspace owner (testUserID) is NOT the agent owner.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.UpdateAgent(w, withURLParam(newRequest(
|
||||
http.MethodPut, "/api/agents/"+agentID,
|
||||
map[string]any{
|
||||
// Touch some other field too, so the request is otherwise
|
||||
// valid (canManageAgent admits workspace owners).
|
||||
"description": "admin description sweep",
|
||||
"composio_toolkit_allowlist": []string{
|
||||
"github", "slack", // would-be widening
|
||||
},
|
||||
},
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent admin sweep: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// The non-owner-edit must NOT have replaced the allowlist; the
|
||||
// description update must have landed.
|
||||
stored, isNull := readAllowlistColumn(t, agentID)
|
||||
if isNull {
|
||||
t.Fatalf("allowlist must NOT be wiped by non-owner write; got NULL")
|
||||
}
|
||||
if len(stored) != 1 || stored[0] != "notion" {
|
||||
t.Errorf("non-owner widening landed: allowlist=%v; want unchanged [notion]", stored)
|
||||
}
|
||||
// The response shape should also not surface the would-be widened
|
||||
// allowlist to the admin. agentToResponse populates it from the
|
||||
// (unchanged) DB row, and the owner-only redaction at the end of
|
||||
// UpdateAgent then strips it for the admin caller.
|
||||
var resp AgentResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.ComposioToolkitAllowlist) != 0 {
|
||||
t.Errorf("admin response leaked allowlist contents: %v", resp.ComposioToolkitAllowlist)
|
||||
}
|
||||
if !resp.ComposioToolkitAllowlistRedacted {
|
||||
t.Errorf("admin response should mark allowlist redacted, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAgent_AllowlistVisibility verifies the read-path redaction is
|
||||
// strictly owner-only:
|
||||
// - the agent owner sees the slug list verbatim
|
||||
// - the workspace owner (admin) sees nothing + redacted=true
|
||||
// - the response shape stays parseable in both cases (no nil-pointer panic)
|
||||
func TestGetAgent_AllowlistVisibility(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
agentID, ownerID := allowlistFixture(t)
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
UPDATE agent SET composio_toolkit_allowlist = $2 WHERE id = $1
|
||||
`, agentID, []string{"notion", "github"}); err != nil {
|
||||
t.Fatalf("seed allowlist: %v", err)
|
||||
}
|
||||
|
||||
// Owner sees the list.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.GetAgent(w, withURLParam(newRequestAs(
|
||||
ownerID, http.MethodGet, "/api/agents/"+agentID, nil,
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetAgent as owner: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ownerResp AgentResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &ownerResp); err != nil {
|
||||
t.Fatalf("decode owner response: %v", err)
|
||||
}
|
||||
if len(ownerResp.ComposioToolkitAllowlist) != 2 {
|
||||
t.Errorf("owner: allowlist=%v; want length 2", ownerResp.ComposioToolkitAllowlist)
|
||||
}
|
||||
if ownerResp.ComposioToolkitAllowlistRedacted {
|
||||
t.Errorf("owner should not see redacted flag")
|
||||
}
|
||||
|
||||
// Workspace owner (testUserID) is NOT the agent owner — must be redacted.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.GetAgent(w, withURLParam(newRequest(
|
||||
http.MethodGet, "/api/agents/"+agentID, nil,
|
||||
), "id", agentID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetAgent as ws-owner: got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var adminResp AgentResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &adminResp); err != nil {
|
||||
t.Fatalf("decode admin response: %v", err)
|
||||
}
|
||||
if len(adminResp.ComposioToolkitAllowlist) != 0 {
|
||||
t.Errorf("ws-owner response leaked allowlist: %v", adminResp.ComposioToolkitAllowlist)
|
||||
}
|
||||
if !adminResp.ComposioToolkitAllowlistRedacted {
|
||||
t.Errorf("ws-owner response should mark allowlist redacted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormaliseComposioToolkitAllowlist_PureFunction exercises the canonical
|
||||
// normalisation so future refactors don't silently change the persisted
|
||||
// form (and break the dispatch path's flat slug compare).
|
||||
func TestNormaliseComposioToolkitAllowlist_PureFunction(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"nil-in nil-out", nil, nil},
|
||||
{"empty-in empty-out", []string{}, []string{}},
|
||||
{"trim", []string{" notion "}, []string{"notion"}},
|
||||
{"lower", []string{"NOTION", "GitHub"}, []string{"notion", "github"}},
|
||||
{"dedupe", []string{"notion", "NOTION", "notion"}, []string{"notion"}},
|
||||
{"drop empty", []string{"", " ", "notion"}, []string{"notion"}},
|
||||
{"preserve order of first-seen", []string{"notion", "github", "notion"}, []string{"notion", "github"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := normaliseComposioToolkitAllowlist(tc.in)
|
||||
if tc.want == nil {
|
||||
if got != nil {
|
||||
t.Fatalf("got %v; want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %v; want %v", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("got[%d]=%q; want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
sdk "github.com/multica-ai/multica/server/pkg/composio"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// mcpOverlayServerName is the deterministic key under `mcpServers` used to
|
||||
@@ -49,52 +52,102 @@ type mcpOverlayPayload struct {
|
||||
}
|
||||
|
||||
// BuildTaskOverlay returns the JSON overlay to write into
|
||||
// agent_task_queue.runtime_mcp_overlay for a task whose initiator is the
|
||||
// given Multica user, or (nil, nil) when the user has no active Composio
|
||||
// connections — in which case no Composio session is created and no token
|
||||
// is provisioned.
|
||||
// agent_task_queue.runtime_mcp_overlay for a task whose top-of-chain human
|
||||
// originator is the given Multica user and whose dispatching agent is
|
||||
// `agent`, or (nil, nil) when ANY of the gates below trip — meaning no
|
||||
// Composio session is created and no token is provisioned.
|
||||
//
|
||||
// The overlay shape is exactly the one the daemon-side merge expects:
|
||||
// Five short-circuit gates, in order:
|
||||
//
|
||||
// 1. originator is not a valid UUID — autopilot / system run with no human
|
||||
// in the chain. There is no "current user's connected apps" view, and
|
||||
// gate 2 would also fail, so skip the work.
|
||||
//
|
||||
// 2. originator != agent.OwnerID — Stage 3.1 of MUL-3721 / MUL-3869. An
|
||||
// agent's Composio overlay is the agent OWNER's connected-apps view
|
||||
// projected into the run. When the run was triggered by anyone else
|
||||
// (another workspace member, an agent fan-out whose originator chain
|
||||
// terminates at someone else), we MUST NOT project the owner's
|
||||
// integration footprint into that run: it would let any workspace
|
||||
// member who can @-mention the agent read into the owner's accounts.
|
||||
// Returns (nil, nil) deterministically — not an error — so the agent
|
||||
// still runs with whatever it had in agent.mcp_config.
|
||||
//
|
||||
// 3. agent.composio_toolkit_allowlist is empty/NULL — the agent owner
|
||||
// never explicitly opted into ANY toolkit. Default behavior is OFF: no
|
||||
// overlay. This is the "all-on → opt-in" inversion the Stage 3.1
|
||||
// redesign brought in.
|
||||
//
|
||||
// 4. After intersecting the allowlist with the originator's currently-
|
||||
// active Composio connections, NO toolkit has an active connection.
|
||||
// Either the owner allowlisted toolkits they haven't connected, or the
|
||||
// connection was revoked. Either way we have nothing to mount.
|
||||
//
|
||||
// 5. Composio returns a session with no URL — defensive: a half-baked
|
||||
// overlay would make every runtime fail noisily on an empty MCP URL.
|
||||
//
|
||||
// On the happy path the overlay is:
|
||||
//
|
||||
// {"mcpServers": {"composio": {"type": "http", "url": "...", "headers": {...}}}}
|
||||
//
|
||||
// The (nil, nil) early return is load-bearing for cost / privacy reasons:
|
||||
//
|
||||
// - cost: each Composio MCP session is a separate session id at Composio,
|
||||
// so we do not provision one for users who would have nothing to call
|
||||
// anyway. This makes the integration scale with the active-connect
|
||||
// population, not the total task population.
|
||||
//
|
||||
// - privacy: a user without any connection has not consented to any
|
||||
// third-party reach — emitting an overlay would still attach a bearer
|
||||
// scoped to their composio user namespace, which is wasted attack
|
||||
// surface.
|
||||
//
|
||||
// Idempotency: this is called per-enqueue, not per-claim, so a single task
|
||||
// has at most one overlay generated and stored. A task that gets re-enqueued
|
||||
// (only via the retry path, which inserts a fresh row, not the same row)
|
||||
// will compute a new overlay for the new row — the parent row's overlay is
|
||||
// already cleared by the terminal-state trigger by the time retry runs.
|
||||
//
|
||||
// Errors are returned so the caller can decide whether to fail the enqueue
|
||||
// (probably no — best-effort enqueue keeps the agent runnable without
|
||||
// Composio tools) or just log and continue. CreateMCPSession failures are
|
||||
// expected to be transient (Composio API outage / network blip).
|
||||
func (s *Service) BuildTaskOverlay(ctx context.Context, userID pgtype.UUID) (json.RawMessage, error) {
|
||||
session, err := s.CreateMCPSession(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("composio: build task overlay: %w", err)
|
||||
}
|
||||
if session == nil {
|
||||
// No active connections — see method comment for why this is the
|
||||
// load-bearing zero-cost path.
|
||||
// CreateSession is called with BOTH the `toolkits.slugs` allowlist filter
|
||||
// and `connected_accounts` pinning so the session is narrowed twice over:
|
||||
// the tool-router only sees the intersection of what the agent owner
|
||||
// allowlisted AND what the originator has live credentials for. This is
|
||||
// belt-and-suspenders — neither field alone would be enough to prevent the
|
||||
// session from drifting toward toolkits the owner did not authorize.
|
||||
func (s *Service) BuildTaskOverlay(ctx context.Context, originatorUserID pgtype.UUID, agent db.Agent) (json.RawMessage, error) {
|
||||
// Gate 1: no human in the chain.
|
||||
if !originatorUserID.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
if session.URL == "" {
|
||||
// Defensive: Composio returned a session row but no URL. Treat as
|
||||
// "no overlay" rather than writing a half-baked entry; the daemon
|
||||
// would otherwise wire up an MCP server with an empty URL which
|
||||
// every runtime fails on noisily.
|
||||
// Gate 2: originator must be the agent owner. Without this gate, any
|
||||
// workspace member who can @-mention the agent (public agent) gets the
|
||||
// owner's connected apps projected into their run, which is the
|
||||
// privacy hole the Stage 3.1 redesign closes.
|
||||
if !agent.OwnerID.Valid || agent.OwnerID.Bytes != originatorUserID.Bytes {
|
||||
return nil, nil
|
||||
}
|
||||
// Gate 3: agent owner has not allowlisted any toolkit. NULL and empty
|
||||
// `{}` are treated identically — both mean "no overlay".
|
||||
allowSet := normaliseAllowlistToSet(agent.ComposioToolkitAllowlist)
|
||||
if len(allowSet) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resolve the originator's active connections and intersect with the
|
||||
// allowlist. The intersection is the canonical input both for filtering
|
||||
// the Composio CreateSession call AND for the early bail-out below.
|
||||
rows, err := s.store.ListActiveUserComposioConnections(ctx, originatorUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("composio: build task overlay: list connections: %w", err)
|
||||
}
|
||||
pinned := pinConnectedAccounts(rows, allowSet)
|
||||
if len(pinned) == 0 {
|
||||
// Gate 4: owner allowlisted toolkits the originator (who is the
|
||||
// owner per gate 2) has not connected — or has revoked since.
|
||||
return nil, nil
|
||||
}
|
||||
// `toolkits.slugs` narrows what the tool-router exposes; pair it with
|
||||
// the connected-account pin so the session can never surface an
|
||||
// account outside the (allowlist ∩ active connections) set.
|
||||
slugs := make([]string, 0, len(pinned))
|
||||
for slug := range pinned {
|
||||
slugs = append(slugs, slug)
|
||||
}
|
||||
|
||||
resp, err := s.sdk.CreateSession(ctx, sdk.CreateSessionRequest{
|
||||
UserID: util.UUIDToString(originatorUserID),
|
||||
Toolkits: map[string]any{"slugs": slugs},
|
||||
ConnectedAccounts: pinned,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("composio: build task overlay: create session: %w", err)
|
||||
}
|
||||
// Gate 5: Composio answered 200 with no MCP URL. Treat as "no overlay"
|
||||
// rather than wire up a server with an empty URL — every runtime fails
|
||||
// noisily on that.
|
||||
if resp == nil || resp.MCP.URL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -102,8 +155,8 @@ func (s *Service) BuildTaskOverlay(ctx context.Context, userID pgtype.UUID) (jso
|
||||
MCPServers: map[string]composioMCPServer{
|
||||
mcpOverlayServerName: {
|
||||
Type: "http",
|
||||
URL: session.URL,
|
||||
Headers: session.Headers,
|
||||
URL: resp.MCP.URL,
|
||||
Headers: s.sdk.MCPAuthHeaders(),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -113,3 +166,91 @@ func (s *Service) BuildTaskOverlay(ctx context.Context, userID pgtype.UUID) (jso
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// normaliseAllowlistToSet maps the agent.composio_toolkit_allowlist
|
||||
// TEXT[] column into a slug→{} set. Each entry is lowercased + trimmed
|
||||
// defensively (the API layer already normalises on write, but DB-level
|
||||
// migrations / out-of-band writes might bypass that, and the cost of a
|
||||
// re-normalise is one map walk). An empty result triggers gate 3 in
|
||||
// BuildTaskOverlay, identically for NULL columns and `{}` arrays.
|
||||
func normaliseAllowlistToSet(allow []string) map[string]struct{} {
|
||||
if len(allow) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(allow))
|
||||
for _, s := range allow {
|
||||
slug := lowerTrim(s)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
out[slug] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pinConnectedAccounts intersects the originator's active connection rows
|
||||
// with the allowlist set and returns the `connected_accounts` map shape
|
||||
// the Composio /tool_router/session endpoint expects: one entry per
|
||||
// (allowlisted) toolkit slug, value = the originator's connected account
|
||||
// id for that toolkit.
|
||||
//
|
||||
// Newest-wins on duplicates: rows arrive ordered by connected_at DESC
|
||||
// (see ListActiveUserComposioConnections), so the first row seen for a
|
||||
// given slug is the most recently connected account, matching the
|
||||
// single-account-per-toolkit invariant CreateMCPSession already documents.
|
||||
func pinConnectedAccounts(rows []db.UserComposioConnection, allowSet map[string]struct{}) map[string]any {
|
||||
pinned := make(map[string]any, len(rows))
|
||||
for _, row := range rows {
|
||||
slug := lowerTrim(row.ToolkitSlug)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
if _, allowed := allowSet[slug]; !allowed {
|
||||
continue
|
||||
}
|
||||
if _, dup := pinned[slug]; dup {
|
||||
continue
|
||||
}
|
||||
pinned[slug] = row.ConnectedAccountID
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
// lowerTrim is the tiny inlined helper that keeps allowlist and connection
|
||||
// slug comparison consistent without dragging the unicode lib for what is
|
||||
// always an ASCII slug.
|
||||
func lowerTrim(s string) string {
|
||||
// strings.ToLower + TrimSpace would do, but we avoid importing strings
|
||||
// just for two ASCII transforms in a hot path. Manual loop is
|
||||
// allocation-free for the common all-ASCII case.
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
if start == end {
|
||||
return ""
|
||||
}
|
||||
// Detect upper-case before allocating.
|
||||
upper := false
|
||||
for i := start; i < end; i++ {
|
||||
if s[i] >= 'A' && s[i] <= 'Z' {
|
||||
upper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !upper {
|
||||
return s[start:end]
|
||||
}
|
||||
b := make([]byte, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
b[i-start] = c
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -48,38 +48,153 @@ func uuidToString(u pgtype.UUID) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// TestBuildTaskOverlay_NoConnections is the load-bearing zero-cost branch:
|
||||
// a user with no active rows must NOT cause CreateMCPSession to fire, and
|
||||
// must NOT emit any overlay JSON. This is the property that lets Composio
|
||||
// scale with the active-connect population, not the total task population.
|
||||
func TestBuildTaskOverlay_NoConnections(t *testing.T) {
|
||||
// makeAgent returns an Agent fixture with the given owner and optional
|
||||
// allowlist. Other Agent fields are zero-valued because BuildTaskOverlay
|
||||
// only reads OwnerID + ComposioToolkitAllowlist.
|
||||
func makeAgent(owner pgtype.UUID, allowlist ...string) db.Agent {
|
||||
a := db.Agent{OwnerID: owner}
|
||||
if allowlist != nil {
|
||||
a.ComposioToolkitAllowlist = allowlist
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// --- Gate 1: invalid originator ------------------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_NoOriginatorIsNoOp covers the autopilot / system-run
|
||||
// branch: when there is no human at the top of the chain, we must not
|
||||
// project anyone's connected apps into the run. The builder is short-
|
||||
// circuited BEFORE touching the store so a guaranteed-empty result never
|
||||
// costs a DB query.
|
||||
func TestBuildTaskOverlay_NoOriginatorIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
|
||||
user := mintUUID(7)
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), user)
|
||||
owner := mintUUID(7)
|
||||
agent := makeAgent(owner, "notion")
|
||||
seedActiveConnection(t, store, owner, "notion", "ca_owner_notion")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), pgtype.UUID{}, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if overlay != nil {
|
||||
t.Errorf("expected nil overlay for user with no connections, got %s", string(overlay))
|
||||
t.Errorf("expected nil overlay for invalid originator, got %s", string(overlay))
|
||||
}
|
||||
if sdkFake.createSessCalls != 0 {
|
||||
t.Errorf("expected zero CreateSession calls for empty user, got %d", sdkFake.createSessCalls)
|
||||
t.Errorf("expected zero CreateSession calls, got %d", sdkFake.createSessCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTaskOverlay_WithConnections — the happy path. The overlay must
|
||||
// be the exact shape the daemon-side merge expects:
|
||||
//
|
||||
// {"mcpServers": {"composio": {"type": "http", "url": "...", "headers": {...}}}}
|
||||
//
|
||||
// We also assert that CreateSession was sent the Multica user id verbatim
|
||||
// (the composio_user_id == Multica user id invariant the rest of the
|
||||
// integration depends on).
|
||||
func TestBuildTaskOverlay_WithConnections(t *testing.T) {
|
||||
// --- Gate 2: originator != agent.OwnerID ---------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_OriginatorNotOwnerIsNoOp is the Stage 3.1 contract:
|
||||
// even when the originator HAS active connections that overlap the agent's
|
||||
// allowlist, projecting them into the run would let any member who can
|
||||
// @-mention the agent read into the agent owner's accounts. Must return
|
||||
// (nil, nil) without calling CreateSession.
|
||||
func TestBuildTaskOverlay_OriginatorNotOwnerIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
|
||||
owner := mintUUID(11)
|
||||
other := mintUUID(12) // a different human in the workspace
|
||||
agent := makeAgent(owner, "notion")
|
||||
// Even seeding the OTHER user with a matching connection must not
|
||||
// produce an overlay — the gate is on identity, not "has any
|
||||
// connection to that toolkit".
|
||||
seedActiveConnection(t, store, other, "notion", "ca_other_notion")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), other, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if overlay != nil {
|
||||
t.Errorf("expected nil overlay when originator != owner, got %s", string(overlay))
|
||||
}
|
||||
if sdkFake.createSessCalls != 0 {
|
||||
t.Errorf("CreateSession must not be called for non-owner originator, got %d calls", sdkFake.createSessCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gate 3: empty / NULL allowlist --------------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_EmptyAllowlistIsNoOp covers both NULL and `{}`
|
||||
// columns: until the agent owner has opted into specific toolkits, the
|
||||
// dispatch decision is OFF — no overlay, no Composio call, no token.
|
||||
func TestBuildTaskOverlay_EmptyAllowlistIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, allowlist := range map[string][]string{
|
||||
"nil-slice": nil,
|
||||
"empty-slice": {},
|
||||
"whitespace": {" ", "\t"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
owner := mintUUID(20)
|
||||
agent := makeAgent(owner, allowlist...)
|
||||
seedActiveConnection(t, store, owner, "notion", "ca_owner_notion")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if overlay != nil {
|
||||
t.Errorf("expected nil overlay for empty allowlist, got %s", string(overlay))
|
||||
}
|
||||
if sdkFake.createSessCalls != 0 {
|
||||
t.Errorf("CreateSession must not run when allowlist is empty, got %d calls", sdkFake.createSessCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gate 4: allowlist non-empty but no matching active connection -------
|
||||
|
||||
// TestBuildTaskOverlay_NoMatchingConnectionIsNoOp — the owner allowlisted
|
||||
// toolkits they have not connected (or revoked the connection for). The
|
||||
// intersection is empty, so we have nothing to mount and must not pay for
|
||||
// an empty Composio session.
|
||||
func TestBuildTaskOverlay_NoMatchingConnectionIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
owner := mintUUID(30)
|
||||
agent := makeAgent(owner, "notion", "github")
|
||||
// Owner connected SLACK only — not in allowlist.
|
||||
seedActiveConnection(t, store, owner, "slack", "ca_owner_slack")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if overlay != nil {
|
||||
t.Errorf("expected nil overlay for empty intersection, got %s", string(overlay))
|
||||
}
|
||||
if sdkFake.createSessCalls != 0 {
|
||||
t.Errorf("CreateSession must not run when intersection is empty, got %d calls", sdkFake.createSessCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Happy path: allowlist ∩ active connections is non-empty -------------
|
||||
|
||||
// TestBuildTaskOverlay_HappyPath_FiltersBothWays — the canonical
|
||||
// successful dispatch. Asserts:
|
||||
// - CreateSession was called with the Multica user id verbatim
|
||||
// - both filters were passed (toolkits.slugs AND connected_accounts)
|
||||
// - the slug set is exactly the intersection (allowlist ∩ active)
|
||||
// - connected_accounts pins the correct connected_account_id per slug
|
||||
// - the returned overlay JSON has the daemon-expected shape
|
||||
// - non-allowlisted active connections (slack here) do NOT leak through
|
||||
func TestBuildTaskOverlay_HappyPath_FiltersBothWays(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{
|
||||
createSessResp: &sdk.CreateSessionResponse{
|
||||
@@ -88,19 +203,46 @@ func TestBuildTaskOverlay_WithConnections(t *testing.T) {
|
||||
}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
user := mintUUID(13)
|
||||
seedActiveConnection(t, store, user, "notion", "ca_user_notion")
|
||||
owner := mintUUID(13)
|
||||
agent := makeAgent(owner, "notion", "github")
|
||||
// Three active connections; only the two in the allowlist should be
|
||||
// surfaced. The third (slack) is the proof that the filter is being
|
||||
// applied — without it, every active connection would leak into the
|
||||
// session even when the owner did not allowlist it.
|
||||
seedActiveConnection(t, store, owner, "notion", "ca_owner_notion")
|
||||
seedActiveConnection(t, store, owner, "github", "ca_owner_github")
|
||||
seedActiveConnection(t, store, owner, "slack", "ca_owner_slack")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), user)
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlay) == 0 {
|
||||
t.Fatalf("expected non-empty overlay, got nil")
|
||||
}
|
||||
// MCP session was provisioned with the Multica user id verbatim.
|
||||
if sdkFake.lastSessReq.UserID != uuidToString(user) {
|
||||
t.Errorf("CreateSession user id: got %q, want %q", sdkFake.lastSessReq.UserID, uuidToString(user))
|
||||
|
||||
// composio_user_id == Multica user id invariant
|
||||
if sdkFake.lastSessReq.UserID != uuidToString(owner) {
|
||||
t.Errorf("CreateSession user id: got %q, want %q", sdkFake.lastSessReq.UserID, uuidToString(owner))
|
||||
}
|
||||
// Toolkits.slugs filter must be the intersection, not the agent's
|
||||
// full allowlist nor the user's full connection set.
|
||||
tk, _ := sdkFake.lastSessReq.Toolkits["slugs"].([]string)
|
||||
if len(tk) != 2 || !containsString(tk, "notion") || !containsString(tk, "github") {
|
||||
t.Errorf("CreateSession toolkits.slugs = %v, want exactly [notion github]", tk)
|
||||
}
|
||||
if containsString(tk, "slack") {
|
||||
t.Errorf("non-allowlisted slack leaked into toolkits.slugs: %v", tk)
|
||||
}
|
||||
// connected_accounts pinning
|
||||
if got := sdkFake.lastSessReq.ConnectedAccounts["notion"]; got != "ca_owner_notion" {
|
||||
t.Errorf("connected_accounts[notion] = %v, want ca_owner_notion", got)
|
||||
}
|
||||
if got := sdkFake.lastSessReq.ConnectedAccounts["github"]; got != "ca_owner_github" {
|
||||
t.Errorf("connected_accounts[github] = %v, want ca_owner_github", got)
|
||||
}
|
||||
if _, leaked := sdkFake.lastSessReq.ConnectedAccounts["slack"]; leaked {
|
||||
t.Errorf("non-allowlisted slack leaked into connected_accounts")
|
||||
}
|
||||
|
||||
var payload mcpOverlayPayload
|
||||
@@ -122,10 +264,12 @@ func TestBuildTaskOverlay_WithConnections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gate 5: defensive empty-URL response --------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_EmptyURL guards a defensive branch: Composio
|
||||
// returning a 200 with an empty mcp.url must not produce a half-baked
|
||||
// overlay (every runtime sidecar generator would emit a server with an
|
||||
// empty URL, breaking the task).
|
||||
// overlay — every runtime sidecar generator would emit a server with an
|
||||
// empty URL, breaking the task.
|
||||
func TestBuildTaskOverlay_EmptyURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{
|
||||
@@ -135,10 +279,11 @@ func TestBuildTaskOverlay_EmptyURL(t *testing.T) {
|
||||
}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
user := mintUUID(14)
|
||||
seedActiveConnection(t, store, user, "github", "ca_user_github")
|
||||
owner := mintUUID(14)
|
||||
agent := makeAgent(owner, "github")
|
||||
seedActiveConnection(t, store, owner, "github", "ca_owner_github")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), user)
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -147,6 +292,8 @@ func TestBuildTaskOverlay_EmptyURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SDK error surfacing -------------------------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_SDKError — an SDK failure (Composio outage, network
|
||||
// blip, …) must surface as an error so the caller can log it. The caller
|
||||
// (TaskService.applyRuntimeMCPOverlay) is responsible for swallowing the
|
||||
@@ -156,10 +303,11 @@ func TestBuildTaskOverlay_SDKError(t *testing.T) {
|
||||
sdkFake := &fakeSDK{createSessErr: errors.New("composio: 503 backend")}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
user := mintUUID(15)
|
||||
seedActiveConnection(t, store, user, "slack", "ca_user_slack")
|
||||
owner := mintUUID(15)
|
||||
agent := makeAgent(owner, "slack")
|
||||
seedActiveConnection(t, store, owner, "slack", "ca_owner_slack")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), user)
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from SDK failure, got nil")
|
||||
}
|
||||
@@ -170,3 +318,51 @@ func TestBuildTaskOverlay_SDKError(t *testing.T) {
|
||||
t.Errorf("expected nil overlay on SDK error, got %s", string(overlay))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slug normalisation regression --------------------------------------
|
||||
|
||||
// TestBuildTaskOverlay_NormalisesAllowlistAndConnectionSlugs — a
|
||||
// defensively normalised compare. The API write path lowers + trims slugs
|
||||
// before persisting, but DB migrations or out-of-band writes can put
|
||||
// uppercase / padded entries in the column. The dispatch path must still
|
||||
// match against (lowercased, trimmed) Composio connection rows so a
|
||||
// well-intentioned UI typo cannot silently disable the overlay.
|
||||
func TestBuildTaskOverlay_NormalisesAllowlistAndConnectionSlugs(t *testing.T) {
|
||||
t.Parallel()
|
||||
sdkFake := &fakeSDK{
|
||||
createSessResp: &sdk.CreateSessionResponse{
|
||||
MCP: sdk.MCPDescriptor{URL: "https://mcp.composio.dev/session/x"},
|
||||
},
|
||||
}
|
||||
store := newFakeStore()
|
||||
svc := newTestService(t, sdkFake, store)
|
||||
owner := mintUUID(40)
|
||||
// allowlist has whitespace-padded MIXED-case entries.
|
||||
agent := makeAgent(owner, " Notion ", "GITHUB")
|
||||
// connection rows arrive with the canonical lowercased slugs (which
|
||||
// is what the connect flow always writes).
|
||||
seedActiveConnection(t, store, owner, "notion", "ca_a")
|
||||
|
||||
overlay, err := svc.BuildTaskOverlay(context.Background(), owner, agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if overlay == nil {
|
||||
t.Fatalf("expected non-empty overlay despite uppercase/padded allowlist")
|
||||
}
|
||||
if got := sdkFake.lastSessReq.ConnectedAccounts["notion"]; got != "ca_a" {
|
||||
t.Errorf("normalised match failed: connected_accounts[notion] = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// containsString reports whether haystack contains needle. Small local
|
||||
// helper so the tests don't pull in slices.Contains and stay copy-paste-
|
||||
// compatible with the existing tests in this package.
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// newResolveOriginatorPool mirrors the local-postgres pattern used in
|
||||
// task_claim_race_test.go: skip when the test database is unreachable
|
||||
// instead of failing, so `go test ./...` stays usable in CI / clean
|
||||
// developer setups that don't run Postgres.
|
||||
func newResolveOriginatorPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Skipf("database unavailable: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("database unreachable: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
// seedOriginatorFanout builds the minimal fixture for an agent→agent
|
||||
// fanout chain:
|
||||
//
|
||||
// human U → (member-authored comment on issue I) →
|
||||
// agent A handles task T_A with originator_user_id = U →
|
||||
// agent A posts a reply comment C (author_type=agent, source_task_id=T_A) →
|
||||
// agent B picks up C as its trigger
|
||||
//
|
||||
// Returns: workspace id, member-authored comment id (C0), agent-authored
|
||||
// comment id (C1, with source_task_id=T_A), and U as pgtype.UUID. T_A's
|
||||
// originator_user_id is U so the agent-fanout branch can prove the
|
||||
// inheritance.
|
||||
func seedOriginatorFanout(t *testing.T, pool *pgxpool.Pool) (memberCommentID, agentCommentID, userID pgtype.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
var workspaceID, agentAID, agentBID, runtimeID, issueID, taskAID, userIDStr, commentMemberID, commentAgentID string
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO "user" (name, email)
|
||||
VALUES ('Resolve Originator User', '[email protected]')
|
||||
RETURNING id
|
||||
`).Scan(&userIDStr); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(),
|
||||
`DELETE FROM "user" WHERE email = '[email protected]'`)
|
||||
})
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO workspace (name, slug)
|
||||
VALUES ('resolve-orig-ws', 'resolve-orig-ws-' || gen_random_uuid())
|
||||
RETURNING id
|
||||
`).Scan(&workspaceID); err != nil {
|
||||
t.Fatalf("seed workspace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(),
|
||||
`DELETE FROM workspace WHERE id = $1`, workspaceID)
|
||||
})
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
`, workspaceID, userIDStr); err != nil {
|
||||
t.Fatalf("seed member: %v", err)
|
||||
}
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO agent_runtime (
|
||||
workspace_id, name, runtime_mode, provider, status, device_info, metadata, owner_id
|
||||
) VALUES ($1, 'r', 'cloud', 'codex', 'online', '', '{}'::jsonb, $2)
|
||||
RETURNING id
|
||||
`, workspaceID, userIDStr).Scan(&runtimeID); err != nil {
|
||||
t.Fatalf("seed runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
)
|
||||
VALUES ($1, 'agent-A', 'cloud', '{}'::jsonb,
|
||||
$2, 'workspace', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, workspaceID, runtimeID, userIDStr).Scan(&agentAID); err != nil {
|
||||
t.Fatalf("seed agent A: %v", err)
|
||||
}
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args
|
||||
)
|
||||
VALUES ($1, 'agent-B', 'cloud', '{}'::jsonb,
|
||||
$2, 'workspace', 1, $3, '', '{}'::jsonb, '[]'::jsonb)
|
||||
RETURNING id
|
||||
`, workspaceID, runtimeID, userIDStr).Scan(&agentBID); err != nil {
|
||||
t.Fatalf("seed agent B: %v", err)
|
||||
}
|
||||
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, creator_type, creator_id)
|
||||
VALUES ($1, 'fanout-issue', 'member', $2)
|
||||
RETURNING id
|
||||
`, workspaceID, userIDStr).Scan(&issueID); err != nil {
|
||||
t.Fatalf("seed issue: %v", err)
|
||||
}
|
||||
|
||||
// Agent A's task carries the originator (the human U). This is the
|
||||
// row the resolver must follow back through comment.source_task_id.
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, originator_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, 'completed', 0, $4)
|
||||
RETURNING id
|
||||
`, agentAID, runtimeID, issueID, userIDStr).Scan(&taskAID); err != nil {
|
||||
t.Fatalf("seed task A: %v", err)
|
||||
}
|
||||
|
||||
// Member-authored comment (no source_task_id).
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
|
||||
VALUES ($1, $2, 'member', $3, 'human comment')
|
||||
RETURNING id
|
||||
`, issueID, workspaceID, userIDStr).Scan(&commentMemberID); err != nil {
|
||||
t.Fatalf("seed member comment: %v", err)
|
||||
}
|
||||
|
||||
// Agent-authored comment whose source_task_id points at task A.
|
||||
// resolveOriginatorFromTriggerComment must inherit task A's originator.
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, source_task_id)
|
||||
VALUES ($1, $2, 'agent', $3, 'agent comment', $4)
|
||||
RETURNING id
|
||||
`, issueID, workspaceID, agentAID, taskAID).Scan(&commentAgentID); err != nil {
|
||||
t.Fatalf("seed agent comment: %v", err)
|
||||
}
|
||||
|
||||
memberCommentID = util.MustParseUUID(commentMemberID)
|
||||
agentCommentID = util.MustParseUUID(commentAgentID)
|
||||
userID = util.MustParseUUID(userIDStr)
|
||||
return
|
||||
}
|
||||
|
||||
// TestResolveOriginatorFromTriggerComment_MemberAuthored — the base case:
|
||||
// a comment authored by a workspace member IS the top-of-chain. The
|
||||
// originator is the comment's own author_id.
|
||||
func TestResolveOriginatorFromTriggerComment_MemberAuthored(t *testing.T) {
|
||||
pool := newResolveOriginatorPool(t)
|
||||
memberCommentID, _, userID := seedOriginatorFanout(t, pool)
|
||||
svc := &TaskService{Queries: db.New(pool)}
|
||||
|
||||
got := svc.resolveOriginatorFromTriggerComment(context.Background(), memberCommentID)
|
||||
if !got.Valid {
|
||||
t.Fatalf("expected valid originator for member-authored comment, got invalid")
|
||||
}
|
||||
if got.Bytes != userID.Bytes {
|
||||
t.Errorf("originator = %s, want %s", util.UUIDToString(got), util.UUIDToString(userID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOriginatorFromTriggerComment_AgentAuthoredInheritsFromParent
|
||||
// — the load-bearing fanout case. Agent A finished a task it ran on
|
||||
// behalf of human U; A then posts a comment that triggers agent B. The
|
||||
// trigger comment's author is A (not a human), but resolving the
|
||||
// originator must walk comment.source_task_id → parent task →
|
||||
// parent.originator_user_id, yielding U.
|
||||
func TestResolveOriginatorFromTriggerComment_AgentAuthoredInheritsFromParent(t *testing.T) {
|
||||
pool := newResolveOriginatorPool(t)
|
||||
_, agentCommentID, userID := seedOriginatorFanout(t, pool)
|
||||
svc := &TaskService{Queries: db.New(pool)}
|
||||
|
||||
got := svc.resolveOriginatorFromTriggerComment(context.Background(), agentCommentID)
|
||||
if !got.Valid {
|
||||
t.Fatalf("expected valid originator inherited from parent task, got invalid")
|
||||
}
|
||||
if got.Bytes != userID.Bytes {
|
||||
t.Errorf("originator = %s, want %s (parent task's originator_user_id)",
|
||||
util.UUIDToString(got), util.UUIDToString(userID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOriginatorFromTriggerComment_InvalidCommentID — defensive
|
||||
// branch. An invalid pgtype.UUID must short-circuit before any DB query
|
||||
// and return invalid.
|
||||
func TestResolveOriginatorFromTriggerComment_InvalidCommentID(t *testing.T) {
|
||||
pool := newResolveOriginatorPool(t)
|
||||
svc := &TaskService{Queries: db.New(pool)}
|
||||
got := svc.resolveOriginatorFromTriggerComment(context.Background(), pgtype.UUID{})
|
||||
if got.Valid {
|
||||
t.Errorf("invalid comment id must yield invalid originator, got %s", util.UUIDToString(got))
|
||||
}
|
||||
}
|
||||
+106
-51
@@ -58,13 +58,20 @@ type TaskService struct {
|
||||
// internal/integrations/composio.Service.BuildTaskOverlay; tests provide an
|
||||
// inline fake so they don't have to spin a fake Composio SDK.
|
||||
//
|
||||
// Contract: (nil, nil) means "user has no active connections, do not write
|
||||
// any overlay onto the task". Any non-nil JSON is the exact value to store
|
||||
// in agent_task_queue.runtime_mcp_overlay; non-nil error is surfaced to the
|
||||
// caller but treated as best-effort — failed overlay computation must not
|
||||
// fail the enqueue.
|
||||
// Contract: (nil, nil) means "no overlay for this run" — covers all five
|
||||
// gates the implementation enforces (no human originator / originator ≠
|
||||
// agent owner / empty allowlist / empty intersection with active
|
||||
// connections / empty session URL). Any non-nil JSON is the exact value
|
||||
// to store in agent_task_queue.runtime_mcp_overlay; a non-nil error is
|
||||
// surfaced to the caller but treated as best-effort — failed overlay
|
||||
// computation must not fail the enqueue.
|
||||
//
|
||||
// agent is passed by value so the builder can inspect OwnerID and
|
||||
// ComposioToolkitAllowlist without re-querying the DB; every enqueue path
|
||||
// already loaded the agent for runtime/archive checks, so passing it is
|
||||
// free and avoids a second GetAgent round-trip in the hot path.
|
||||
type ComposioOverlayBuilder interface {
|
||||
BuildTaskOverlay(ctx context.Context, userID pgtype.UUID) (json.RawMessage, error)
|
||||
BuildTaskOverlay(ctx context.Context, originatorUserID pgtype.UUID, agent db.Agent) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type TaskWakeupNotifier interface {
|
||||
@@ -167,7 +174,7 @@ func (s *TaskService) captureTaskQueued(ctx context.Context, task db.AgentTaskQu
|
||||
}
|
||||
|
||||
// applyRuntimeMCPOverlay computes the per-task Composio MCP overlay for the
|
||||
// task's initiator user and writes it to agent_task_queue.runtime_mcp_overlay.
|
||||
// task's originator user and writes it to agent_task_queue.runtime_mcp_overlay.
|
||||
// Called by every Enqueue* path AFTER the task row is inserted and BEFORE
|
||||
// the daemon is notified, so by the time the claim handler reads the row
|
||||
// the overlay is in place (or has been deterministically skipped).
|
||||
@@ -179,26 +186,29 @@ func (s *TaskService) captureTaskQueued(ctx context.Context, task db.AgentTaskQu
|
||||
// No-op when:
|
||||
// - s.Composio is nil — Composio integration not configured (the common
|
||||
// case for any deployment that hasn't set COMPOSIO_API_KEY).
|
||||
// - initiatorUserID is not a valid UUID — the task has no attributable
|
||||
// human initiator (autopilot, system-driven, etc.), so there is no
|
||||
// "current user's connected apps" to reflect.
|
||||
// - BuildTaskOverlay returns (nil, nil) — the initiator user has zero
|
||||
// active connections.
|
||||
func (s *TaskService) applyRuntimeMCPOverlay(ctx context.Context, taskID, initiatorUserID pgtype.UUID) {
|
||||
// - taskID is not a valid UUID — defensive; should never happen because
|
||||
// every caller threads in a row that was just inserted.
|
||||
// - BuildTaskOverlay returns (nil, nil) — see ComposioOverlayBuilder
|
||||
// doc for the five-gate enumeration that produces this.
|
||||
//
|
||||
// agent is passed verbatim because the builder needs agent.OwnerID and
|
||||
// agent.ComposioToolkitAllowlist for the originator-vs-owner gate
|
||||
// (MUL-3869). Each Enqueue* path has already loaded the agent for its
|
||||
// own runtime / archive checks, so passing the value is free and avoids
|
||||
// a second GetAgent round-trip in the hot path.
|
||||
func (s *TaskService) applyRuntimeMCPOverlay(ctx context.Context, taskID, originatorUserID pgtype.UUID, agent db.Agent) {
|
||||
if s == nil || s.Composio == nil {
|
||||
return
|
||||
}
|
||||
if !initiatorUserID.Valid {
|
||||
return
|
||||
}
|
||||
if !taskID.Valid {
|
||||
return
|
||||
}
|
||||
overlay, err := s.Composio.BuildTaskOverlay(ctx, initiatorUserID)
|
||||
overlay, err := s.Composio.BuildTaskOverlay(ctx, originatorUserID, agent)
|
||||
if err != nil {
|
||||
slog.Warn("runtime mcp overlay: BuildTaskOverlay failed; task will run without composio overlay",
|
||||
"task_id", util.UUIDToString(taskID),
|
||||
"initiator_user_id", util.UUIDToString(initiatorUserID),
|
||||
"originator_user_id", util.UUIDToString(originatorUserID),
|
||||
"agent_id", util.UUIDToString(agent.ID),
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
@@ -218,18 +228,32 @@ func (s *TaskService) applyRuntimeMCPOverlay(ctx context.Context, taskID, initia
|
||||
}
|
||||
slog.Debug("runtime mcp overlay: attached composio session",
|
||||
"task_id", util.UUIDToString(taskID),
|
||||
"initiator_user_id", util.UUIDToString(initiatorUserID),
|
||||
"originator_user_id", util.UUIDToString(originatorUserID),
|
||||
"agent_id", util.UUIDToString(agent.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// resolveInitiatorFromTriggerComment returns the comment author's user UUID
|
||||
// when the trigger comment was authored by a member, otherwise an invalid
|
||||
// pgtype.UUID. Used by the issue and mention enqueue paths to feed the
|
||||
// per-task overlay builder. Agent-authored trigger comments — e.g. a squad
|
||||
// leader fan-out — are intentionally NOT treated as initiators here: their
|
||||
// "user-connected apps" view is empty by construction and we want to avoid
|
||||
// spending a Composio session for a guaranteed-empty overlay.
|
||||
func (s *TaskService) resolveInitiatorFromTriggerComment(ctx context.Context, commentID pgtype.UUID) pgtype.UUID {
|
||||
// resolveOriginatorFromTriggerComment returns the top-of-chain HUMAN user
|
||||
// id for a comment that triggered an Enqueue* path. The chain rules
|
||||
// (MUL-3869):
|
||||
//
|
||||
// - trigger comment authored by a member → originator = author_id (that
|
||||
// member IS the top-of-chain human).
|
||||
// - trigger comment authored by an agent → read the parent task via
|
||||
// comment.source_task_id and inherit its originator_user_id. This is
|
||||
// the load-bearing case for agent fan-out: agent A @-mentions agent B,
|
||||
// comment author is A, but we MUST surface the human who originally
|
||||
// told A to run, not lose the originator at the first agent hop.
|
||||
// - missing comment / unknown source task / NULL parent originator →
|
||||
// invalid pgtype.UUID. BuildTaskOverlay treats that as "no overlay"
|
||||
// (gate 1).
|
||||
//
|
||||
// A nil receiver / nil Queries falls through to invalid so unit-test
|
||||
// setups that don't wire a DB stay safe.
|
||||
func (s *TaskService) resolveOriginatorFromTriggerComment(ctx context.Context, commentID pgtype.UUID) pgtype.UUID {
|
||||
if s == nil || s.Queries == nil {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
if !commentID.Valid {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
@@ -237,10 +261,25 @@ func (s *TaskService) resolveInitiatorFromTriggerComment(ctx context.Context, co
|
||||
if err != nil {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
if comment.AuthorType != "member" {
|
||||
switch comment.AuthorType {
|
||||
case "member":
|
||||
return comment.AuthorID
|
||||
case "agent":
|
||||
// Inherit from the agent's own triggering task. comment.source_task_id
|
||||
// is set by every agent comment-write path (see migration 120), so a
|
||||
// NULL here means either the comment predates that migration or it
|
||||
// was authored out-of-band — both fall through to "no overlay".
|
||||
if !comment.SourceTaskID.Valid {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
parent, err := s.Queries.GetAgentTask(ctx, comment.SourceTaskID)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
return parent.OriginatorUserID
|
||||
default:
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
return comment.AuthorID
|
||||
}
|
||||
|
||||
func (s *TaskService) captureTaskDispatched(ctx context.Context, task db.AgentTaskQueue) {
|
||||
@@ -581,6 +620,7 @@ func (s *TaskService) enqueueIssueTask(ctx context.Context, issue db.Issue, trig
|
||||
TriggerSummary: s.buildCommentTriggerSummary(ctx, triggerCommentID),
|
||||
ForceFreshSession: pgtype.Bool{Bool: forceFreshSession, Valid: forceFreshSession},
|
||||
HandoffNote: pgtype.Text{String: handoffNote, Valid: handoffNote != ""},
|
||||
OriginatorUserID: s.resolveOriginatorFromTriggerComment(ctx, triggerCommentID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
@@ -599,7 +639,7 @@ func (s *TaskService) enqueueIssueTask(ctx context.Context, issue db.Issue, trig
|
||||
// queued broadcast afterwards risks the dispatch event reaching clients
|
||||
// before the queued one (rare but unsafe-by-construction). Publishing
|
||||
// in the desired observe-order makes correctness independent of timing.
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, s.resolveInitiatorFromTriggerComment(ctx, triggerCommentID))
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, task.OriginatorUserID, agent)
|
||||
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task)
|
||||
s.NotifyTaskEnqueued(ctx, task)
|
||||
return task, nil
|
||||
@@ -660,6 +700,7 @@ func (s *TaskService) enqueueMentionTask(ctx context.Context, issue db.Issue, ag
|
||||
ForceFreshSession: pgtype.Bool{Bool: forceFreshSession, Valid: forceFreshSession},
|
||||
HandoffNote: pgtype.Text{String: handoffNote, Valid: handoffNote != ""},
|
||||
SquadID: squadID,
|
||||
OriginatorUserID: s.resolveOriginatorFromTriggerComment(ctx, triggerCommentID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||
@@ -668,7 +709,7 @@ func (s *TaskService) enqueueMentionTask(ctx context.Context, issue db.Issue, ag
|
||||
|
||||
slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "is_leader_task", isLeader)
|
||||
// See EnqueueTaskForIssue for ordering rationale.
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, s.resolveInitiatorFromTriggerComment(ctx, triggerCommentID))
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, task.OriginatorUserID, agent)
|
||||
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task)
|
||||
s.NotifyTaskEnqueued(ctx, task)
|
||||
return task, nil
|
||||
@@ -770,10 +811,11 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
|
||||
}
|
||||
|
||||
task, err := s.Queries.CreateQuickCreateTask(ctx, db.CreateQuickCreateTaskParams{
|
||||
AgentID: agentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
Priority: priorityToInt("high"),
|
||||
Context: contextJSON,
|
||||
AgentID: agentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
Priority: priorityToInt("high"),
|
||||
Context: contextJSON,
|
||||
OriginatorUserID: requesterID,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("create quick-create task: %w", err)
|
||||
@@ -793,7 +835,7 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
|
||||
// cycle. Without this the user perceives "quick create never
|
||||
// triggered" because the modal closes immediately and the task
|
||||
// sits in 'queued' until the next sleepWithContextOrWakeup tick.
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, requesterID)
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, requesterID, agent)
|
||||
s.NotifyTaskEnqueued(ctx, task)
|
||||
return task, nil
|
||||
}
|
||||
@@ -853,11 +895,12 @@ func (s *TaskService) EnqueueChatTask(ctx context.Context, chatSession db.ChatSe
|
||||
}
|
||||
|
||||
task, err := s.Queries.CreateChatTask(ctx, db.CreateChatTaskParams{
|
||||
AgentID: chatSession.AgentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
Priority: 2, // medium priority for chat
|
||||
ChatSessionID: chatSession.ID,
|
||||
InitiatorUserID: initiatorUserID,
|
||||
AgentID: chatSession.AgentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
Priority: 2, // medium priority for chat
|
||||
ChatSessionID: chatSession.ID,
|
||||
InitiatorUserID: initiatorUserID,
|
||||
OriginatorUserID: initiatorUserID,
|
||||
ForceFreshSession: pgtype.Bool{
|
||||
Bool: forceFreshSession,
|
||||
Valid: true,
|
||||
@@ -870,7 +913,7 @@ func (s *TaskService) EnqueueChatTask(ctx context.Context, chatSession db.ChatSe
|
||||
|
||||
slog.Info("chat task enqueued", "task_id", util.UUIDToString(task.ID), "chat_session_id", util.UUIDToString(chatSession.ID), "agent_id", util.UUIDToString(chatSession.AgentID))
|
||||
// See EnqueueTaskForIssue for ordering rationale.
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, initiatorUserID)
|
||||
s.applyRuntimeMCPOverlay(ctx, task.ID, initiatorUserID, agent)
|
||||
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task)
|
||||
s.NotifyTaskEnqueued(ctx, task)
|
||||
return task, nil
|
||||
@@ -1753,15 +1796,27 @@ func (s *TaskService) MaybeRetryFailedTask(ctx context.Context, parent db.AgentT
|
||||
// as EnqueueTaskFor*. Broadcast queued first, then notify the daemon —
|
||||
// see EnqueueTaskForIssue for ordering rationale.
|
||||
//
|
||||
// The parent task's initiator_user_id is the canonical reference here:
|
||||
// the user behind the original run has not changed and the new row
|
||||
// inherits the same attribution at the DB layer (see
|
||||
// agent.sql / CreateRetryTask). NOTE: CreateRetryTask currently does
|
||||
// NOT copy initiator_user_id forward to the child, so on retry we read
|
||||
// the parent's value directly to keep the overlay attached. If a future
|
||||
// schema change starts copying it, this still works (parent vs child
|
||||
// carry the same value).
|
||||
s.applyRuntimeMCPOverlay(ctx, child.ID, parent.InitiatorUserID)
|
||||
// The originator_user_id is inherited from the parent row in SQL (see
|
||||
// CreateRetryTask in agent.sql), so child.OriginatorUserID is the
|
||||
// canonical, transparent source for the overlay decision: the human
|
||||
// behind the original run has not changed. Loading the agent costs one
|
||||
// extra round-trip vs. the previous "always pass parent.InitiatorUserID"
|
||||
// path, but the overlay builder now needs OwnerID + allowlist anyway —
|
||||
// an unrelated retry of a chat task on an archived agent already
|
||||
// catches this kind of mid-life-cycle drift before we get here.
|
||||
agent, agentErr := s.Queries.GetAgent(ctx, child.AgentID)
|
||||
if agentErr != nil {
|
||||
// Best-effort: failing to resolve the agent for the overlay is not
|
||||
// retry-fatal. Log and continue — the daemon will reject the claim
|
||||
// later if the agent is genuinely gone.
|
||||
slog.Warn("task auto-retry: load agent for overlay failed",
|
||||
"child_task_id", util.UUIDToString(child.ID),
|
||||
"agent_id", util.UUIDToString(child.AgentID),
|
||||
"error", agentErr,
|
||||
)
|
||||
} else {
|
||||
s.applyRuntimeMCPOverlay(ctx, child.ID, child.OriginatorUserID, agent)
|
||||
}
|
||||
s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, child)
|
||||
s.NotifyTaskEnqueued(ctx, child)
|
||||
return &child, nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// stubOverlayBuilder records every call so the test can assert which
|
||||
@@ -15,14 +16,16 @@ import (
|
||||
type stubOverlayBuilder struct {
|
||||
calls int
|
||||
lastUser pgtype.UUID
|
||||
lastAgent db.Agent
|
||||
resp json.RawMessage
|
||||
err error
|
||||
respIsNil bool
|
||||
}
|
||||
|
||||
func (s *stubOverlayBuilder) BuildTaskOverlay(_ context.Context, userID pgtype.UUID) (json.RawMessage, error) {
|
||||
func (s *stubOverlayBuilder) BuildTaskOverlay(_ context.Context, originatorUserID pgtype.UUID, agent db.Agent) (json.RawMessage, error) {
|
||||
s.calls++
|
||||
s.lastUser = userID
|
||||
s.lastUser = originatorUserID
|
||||
s.lastAgent = agent
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
@@ -32,6 +35,13 @@ func (s *stubOverlayBuilder) BuildTaskOverlay(_ context.Context, userID pgtype.U
|
||||
return s.resp, nil
|
||||
}
|
||||
|
||||
// mintTestAgent returns an Agent fixture with the given owner id stamped
|
||||
// in; other fields are zero because applyRuntimeMCPOverlay treats the
|
||||
// agent as opaque data forwarded to the builder.
|
||||
func mintTestAgent(owner pgtype.UUID) db.Agent {
|
||||
return db.Agent{OwnerID: owner}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeMCPOverlay_NoComposioIsNoOp pins the safety-net that
|
||||
// makes Stage 3 a pure addition for every Multica deployment that hasn't
|
||||
// enabled Composio: when s.Composio is nil, the helper must not panic
|
||||
@@ -45,27 +55,49 @@ func TestApplyRuntimeMCPOverlay_NoComposioIsNoOp(t *testing.T) {
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x02}, Valid: true}
|
||||
|
||||
// Should not panic and should not touch Queries.
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID)
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID, mintTestAgent(userID))
|
||||
}
|
||||
|
||||
// TestApplyRuntimeMCPOverlay_InvalidInitiatorIsNoOp covers the
|
||||
// no-attributable-human-initiator path (autopilot, system-driven). When
|
||||
// the initiator UUID is not valid we must NOT call BuildTaskOverlay — we
|
||||
// must not pay the Composio session cost for a guaranteed-empty overlay.
|
||||
func TestApplyRuntimeMCPOverlay_InvalidInitiatorIsNoOp(t *testing.T) {
|
||||
// TestApplyRuntimeMCPOverlay_InvalidTaskIDIsNoOp guards a defensive branch:
|
||||
// every call site threads a row that was just inserted, but a bug that
|
||||
// loses the task id between Create and the overlay call must short-circuit
|
||||
// before touching Queries — otherwise SetAgentTaskRuntimeMCPOverlay would
|
||||
// be hit with an invalid UUID.
|
||||
func TestApplyRuntimeMCPOverlay_InvalidTaskIDIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubOverlayBuilder{}
|
||||
svc := &TaskService{Composio: builder}
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x03}, Valid: true}
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x03}, Valid: true}
|
||||
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, pgtype.UUID{}) // invalid
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), pgtype.UUID{}, userID, mintTestAgent(userID))
|
||||
if builder.calls != 0 {
|
||||
t.Errorf("expected 0 builder calls for invalid initiator, got %d", builder.calls)
|
||||
t.Errorf("expected 0 builder calls for invalid task id, got %d", builder.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeMCPOverlay_InvalidOriginatorReachesBuilder — the
|
||||
// invalid-originator gate now lives INSIDE BuildTaskOverlay (gate 1 in
|
||||
// the dispatch.go contract). applyRuntimeMCPOverlay just forwards the
|
||||
// invalid UUID; the builder is responsible for returning (nil, nil).
|
||||
// We assert that the helper does not panic and does call the builder so
|
||||
// the in-process contract stays explicit.
|
||||
func TestApplyRuntimeMCPOverlay_InvalidOriginatorReachesBuilder(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubOverlayBuilder{respIsNil: true}
|
||||
svc := &TaskService{Composio: builder} // Queries nil on purpose
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x04}, Valid: true}
|
||||
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, pgtype.UUID{}, db.Agent{})
|
||||
if builder.calls != 1 {
|
||||
t.Errorf("expected 1 builder call (gate 1 lives in the builder), got %d", builder.calls)
|
||||
}
|
||||
if builder.lastUser.Valid {
|
||||
t.Errorf("builder received valid originator; want invalid: %+v", builder.lastUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeMCPOverlay_NilOverlaySkipsUpdate — BuildTaskOverlay
|
||||
// returning (nil, nil) means the user has no active connections. The
|
||||
// returning (nil, nil) means one of the five gates short-circuited. The
|
||||
// helper must short-circuit before touching Queries so a unit-test setup
|
||||
// with Queries=nil is safe and so we don't issue a pointless UPDATE
|
||||
// against the queue table in production.
|
||||
@@ -73,10 +105,10 @@ func TestApplyRuntimeMCPOverlay_NilOverlaySkipsUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubOverlayBuilder{respIsNil: true}
|
||||
svc := &TaskService{Composio: builder} // Queries nil on purpose
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x04}, Valid: true}
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x05}, Valid: true}
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x05}, Valid: true}
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x06}, Valid: true}
|
||||
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID)
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID, mintTestAgent(userID))
|
||||
if builder.calls != 1 {
|
||||
t.Errorf("expected exactly 1 builder call, got %d", builder.calls)
|
||||
}
|
||||
@@ -90,11 +122,40 @@ func TestApplyRuntimeMCPOverlay_BuilderErrorSwallowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubOverlayBuilder{err: errors.New("upstream 503")}
|
||||
svc := &TaskService{Composio: builder}
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x06}, Valid: true}
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x07}, Valid: true}
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x07}, Valid: true}
|
||||
userID := pgtype.UUID{Bytes: [16]byte{0x08}, Valid: true}
|
||||
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID)
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, userID, mintTestAgent(userID))
|
||||
if builder.calls != 1 {
|
||||
t.Errorf("expected 1 builder call, got %d", builder.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeMCPOverlay_AgentPassedThrough asserts that the helper
|
||||
// forwards the agent value verbatim to the builder so the originator-vs-
|
||||
// owner gate (MUL-3869) actually sees the right OwnerID. Without this
|
||||
// pass-through every enqueue path would silently fall back to the
|
||||
// "originator ≠ owner → no overlay" branch of the builder.
|
||||
func TestApplyRuntimeMCPOverlay_AgentPassedThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubOverlayBuilder{respIsNil: true}
|
||||
svc := &TaskService{Composio: builder}
|
||||
taskID := pgtype.UUID{Bytes: [16]byte{0x09}, Valid: true}
|
||||
owner := pgtype.UUID{Bytes: [16]byte{0x0A}, Valid: true}
|
||||
agent := db.Agent{
|
||||
ID: pgtype.UUID{Bytes: [16]byte{0x0B}, Valid: true},
|
||||
OwnerID: owner,
|
||||
ComposioToolkitAllowlist: []string{"notion", "github"},
|
||||
}
|
||||
|
||||
svc.applyRuntimeMCPOverlay(context.Background(), taskID, owner, agent)
|
||||
if builder.calls != 1 {
|
||||
t.Fatalf("expected 1 builder call, got %d", builder.calls)
|
||||
}
|
||||
if builder.lastAgent.OwnerID != owner {
|
||||
t.Errorf("builder received OwnerID=%+v, want %+v", builder.lastAgent.OwnerID, owner)
|
||||
}
|
||||
if len(builder.lastAgent.ComposioToolkitAllowlist) != 2 {
|
||||
t.Errorf("builder received allowlist=%v, want length 2", builder.lastAgent.ComposioToolkitAllowlist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS agent_task_queue_originator_user_id_idx;
|
||||
ALTER TABLE agent_task_queue DROP COLUMN IF EXISTS originator_user_id;
|
||||
ALTER TABLE agent DROP COLUMN IF EXISTS composio_toolkit_allowlist;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Stage 3.1 of the Composio epic (MUL-3869, parent MUL-3721): turn the default
|
||||
-- behaviour from "all-on" into "Composio MCP is only mounted when (a) the
|
||||
-- agent's owner has explicitly allowlisted toolkits AND (b) the run originator
|
||||
-- at the top of the trigger chain is that same owner".
|
||||
--
|
||||
-- Two columns ship together because the dispatch decision needs both:
|
||||
-- * agent.composio_toolkit_allowlist — which toolkits the agent is allowed
|
||||
-- to surface; NULL or [] = no MCP overlay regardless of who triggers.
|
||||
-- * agent_task_queue.originator_user_id — the top-of-chain human originator
|
||||
-- for THIS task, inherited across agent @-mention fanout. The dispatch
|
||||
-- hook gates on (originator == agent.owner_id) so an agent never gets
|
||||
-- another user's Composio session even if that other user @-mentions it.
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- agent.composio_toolkit_allowlist
|
||||
-- ----------------------------------------------------------------------------
|
||||
ALTER TABLE agent
|
||||
ADD COLUMN composio_toolkit_allowlist TEXT[] NULL;
|
||||
|
||||
COMMENT ON COLUMN agent.composio_toolkit_allowlist IS
|
||||
'Composio toolkit slugs this agent is allowed to mount as MCP. NULL or empty array = no MCP. Only effective when the run originator matches agent.owner_id; see migration 129 + MUL-3869 / MUL-3721. Stored as TEXT[] so the dispatch path can intersect against the originator''s active connections with a single SQL ANY() filter, no JSON parse on the hot path.';
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- agent_task_queue.originator_user_id
|
||||
-- ----------------------------------------------------------------------------
|
||||
--
|
||||
-- Distinct from initiator_user_id (migration 117): initiator is whoever
|
||||
-- physically sent the triggering message — which for an agent fan-out is
|
||||
-- ANOTHER AGENT, not a human — and is set today only for chat tasks. The
|
||||
-- Composio overlay decision needs the HUMAN at the top of the chain so we
|
||||
-- never inadvertently project a human's connected apps into a run that
|
||||
-- person did not start. Hence a dedicated column whose contract is "the
|
||||
-- originator is always a 'user' row (or NULL when none can be attributed,
|
||||
-- e.g. autopilot/system runs)".
|
||||
--
|
||||
-- No FK to "user" by design (matches initiator_user_id and the
|
||||
-- comment.source_task_id pattern): foreign keys on hot-path queue tables
|
||||
-- have lock implications on busy production deploys, and the column only
|
||||
-- feeds best-effort overlay decisions — a stale id simply degrades to "no
|
||||
-- overlay", never a corrupt run.
|
||||
ALTER TABLE agent_task_queue
|
||||
ADD COLUMN originator_user_id UUID NULL;
|
||||
|
||||
COMMENT ON COLUMN agent_task_queue.originator_user_id IS
|
||||
'Top-of-chain human originator for this run. For human-triggered tasks (comment by a member, chat, quick-create) equals that member. For agent-fanout tasks (agent A @-mentions agent B) inherited from the parent task''s originator_user_id via comment.source_task_id. NULL when no human is in the chain (autopilot, system-driven). Used by the Composio MCP overlay dispatch to require originator == agent.owner_id; see migration 129 + MUL-3869.';
|
||||
|
||||
-- Dispatch reads originator_user_id for every overlay decision; a partial
|
||||
-- index on the non-NULL subset keeps the working set small (autopilot and
|
||||
-- pre-stage-3.1 rows stay out of the index entirely).
|
||||
CREATE INDEX agent_task_queue_originator_user_id_idx
|
||||
ON agent_task_queue(originator_user_id)
|
||||
WHERE originator_user_id IS NOT NULL;
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
// source: agent.sql
|
||||
|
||||
package db
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const archiveAgent = `-- name: ArchiveAgent :one
|
||||
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type ArchiveAgentParams struct {
|
||||
@@ -48,6 +48,7 @@ func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Age
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -56,7 +57,7 @@ const archiveAgentsByIDs = `-- name: ArchiveAgentsByIDs :many
|
||||
UPDATE agent
|
||||
SET archived_at = now(), archived_by = $1, updated_at = now()
|
||||
WHERE id = ANY($2::uuid[]) AND archived_at IS NULL
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type ArchiveAgentsByIDsParams struct {
|
||||
@@ -104,6 +105,7 @@ func (q *Queries) ArchiveAgentsByIDs(ctx context.Context, arg ArchiveAgentsByIDs
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,7 +121,7 @@ const archiveAgentsByRuntime = `-- name: ArchiveAgentsByRuntime :many
|
||||
UPDATE agent
|
||||
SET archived_at = now(), archived_by = $1, updated_at = now()
|
||||
WHERE runtime_id = ANY($2::uuid[]) AND archived_at IS NULL
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type ArchiveAgentsByRuntimeParams struct {
|
||||
@@ -163,6 +165,7 @@ func (q *Queries) ArchiveAgentsByRuntime(ctx context.Context, arg ArchiveAgentsB
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,7 +181,7 @@ const cancelAgentTask = `-- name: CancelAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) {
|
||||
@@ -216,6 +219,7 @@ func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTas
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -224,7 +228,7 @@ const cancelAgentTasksByAgent = `-- name: CancelAgentTasksByAgent :many
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Bulk-cancel every active (queued/dispatched/running) task for an agent.
|
||||
@@ -273,6 +277,7 @@ func (q *Queries) CancelAgentTasksByAgent(ctx context.Context, agentID pgtype.UU
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,7 +293,7 @@ const cancelAgentTasksByChatSession = `-- name: CancelAgentTasksByChatSession :m
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Cancels active tasks belonging to a chat session. Called from
|
||||
@@ -337,6 +342,7 @@ func (q *Queries) CancelAgentTasksByChatSession(ctx context.Context, chatSession
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -352,7 +358,7 @@ const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :many
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Cancels every active task on the issue and returns the affected rows so the
|
||||
@@ -401,6 +407,7 @@ func (q *Queries) CancelAgentTasksByIssue(ctx context.Context, issueID pgtype.UU
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -416,7 +423,7 @@ const cancelAgentTasksByIssueAndAgent = `-- name: CancelAgentTasksByIssueAndAgen
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CancelAgentTasksByIssueAndAgentParams struct {
|
||||
@@ -469,6 +476,7 @@ func (q *Queries) CancelAgentTasksByIssueAndAgent(ctx context.Context, arg Cance
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -484,7 +492,7 @@ const cancelAgentTasksByTriggerComment = `-- name: CancelAgentTasksByTriggerComm
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||||
WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Cancels active tasks whose trigger is the given comment. Called when a
|
||||
@@ -533,6 +541,7 @@ func (q *Queries) CancelAgentTasksByTriggerComment(ctx context.Context, triggerC
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -573,7 +582,7 @@ WHERE id = (
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type ClaimAgentTaskParams struct {
|
||||
@@ -625,6 +634,50 @@ func (q *Queries) ClaimAgentTask(ctx context.Context, arg ClaimAgentTaskParams)
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const clearAgentComposioToolkitAllowlist = `-- name: ClearAgentComposioToolkitAllowlist :one
|
||||
UPDATE agent SET composio_toolkit_allowlist = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
// Explicit NULL-clear for composio_toolkit_allowlist. The COALESCE-based
|
||||
// UpdateAgent cannot set the column back to NULL — sending an empty array
|
||||
// through there would persist `{}` (still a non-NULL, equivalent to "no
|
||||
// toolkits" but distinct from "field never configured"). The API uses this
|
||||
// dedicated query when the agent owner removes every toolkit; subsequent
|
||||
// dispatch decisions treat NULL identically to `{}` (both -> no overlay).
|
||||
func (q *Queries) ClearAgentComposioToolkitAllowlist(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, clearAgentComposioToolkitAllowlist, id)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
&i.CustomEnv,
|
||||
&i.CustomArgs,
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -632,7 +685,7 @@ func (q *Queries) ClaimAgentTask(ctx context.Context, arg ClaimAgentTaskParams)
|
||||
const clearAgentMcpConfig = `-- name: ClearAgentMcpConfig :one
|
||||
UPDATE agent SET mcp_config = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
@@ -661,6 +714,7 @@ func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agen
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -668,7 +722,7 @@ func (q *Queries) ClearAgentMcpConfig(ctx context.Context, id pgtype.UUID) (Agen
|
||||
const clearAgentThinkingLevel = `-- name: ClearAgentThinkingLevel :one
|
||||
UPDATE agent SET thinking_level = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
// Explicit NULL-clear for thinking_level. COALESCE-based UpdateAgent cannot
|
||||
@@ -700,6 +754,7 @@ func (q *Queries) ClearAgentThinkingLevel(ctx context.Context, id pgtype.UUID) (
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -708,7 +763,7 @@ const completeAgentTask = `-- name: CompleteAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'completed', completed_at = now(), result = $2, session_id = $3, work_dir = $4, prepare_lease_expires_at = NULL
|
||||
WHERE id = $1 AND status = 'running'
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CompleteAgentTaskParams struct {
|
||||
@@ -758,6 +813,7 @@ func (q *Queries) CompleteAgentTask(ctx context.Context, arg CompleteAgentTaskPa
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -778,28 +834,35 @@ const createAgent = `-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
instructions, custom_env, custom_args, mcp_config, model, thinking_level,
|
||||
composio_toolkit_allowlist
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16,
|
||||
$17::text[]
|
||||
)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
ThinkingLevel pgtype.Text `json:"thinking_level"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
ThinkingLevel pgtype.Text `json:"thinking_level"`
|
||||
ComposioToolkitAllowlist []string `json:"composio_toolkit_allowlist"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
|
||||
@@ -820,6 +883,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
arg.McpConfig,
|
||||
arg.Model,
|
||||
arg.ThinkingLevel,
|
||||
arg.ComposioToolkitAllowlist,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -845,6 +909,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -853,7 +918,7 @@ const createAgentTask = `-- name: CreateAgentTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
squad_id
|
||||
squad_id, originator_user_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, 'queued', $4, $5,
|
||||
@@ -861,9 +926,10 @@ VALUES (
|
||||
COALESCE($7::boolean, FALSE),
|
||||
COALESCE($8::boolean, FALSE),
|
||||
$9,
|
||||
$10
|
||||
$10,
|
||||
$11
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CreateAgentTaskParams struct {
|
||||
@@ -877,6 +943,7 @@ type CreateAgentTaskParams struct {
|
||||
IsLeaderTask pgtype.Bool `json:"is_leader_task"`
|
||||
HandoffNote pgtype.Text `json:"handoff_note"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams) (AgentTaskQueue, error) {
|
||||
@@ -891,6 +958,7 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
arg.IsLeaderTask,
|
||||
arg.HandoffNote,
|
||||
arg.SquadID,
|
||||
arg.OriginatorUserID,
|
||||
)
|
||||
var i AgentTaskQueue
|
||||
err := row.Scan(
|
||||
@@ -925,21 +993,25 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createQuickCreateTask = `-- name: CreateQuickCreateTask :one
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, context, originator_user_id
|
||||
)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4, $5)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CreateQuickCreateTaskParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
Context []byte `json:"context"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
Context []byte `json:"context"`
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
}
|
||||
|
||||
// Quick-create tasks have no issue / chat / autopilot link; the entire job
|
||||
@@ -951,6 +1023,7 @@ func (q *Queries) CreateQuickCreateTask(ctx context.Context, arg CreateQuickCrea
|
||||
arg.RuntimeID,
|
||||
arg.Priority,
|
||||
arg.Context,
|
||||
arg.OriginatorUserID,
|
||||
)
|
||||
var i AgentTaskQueue
|
||||
err := row.Scan(
|
||||
@@ -985,6 +1058,7 @@ func (q *Queries) CreateQuickCreateTask(ctx context.Context, arg CreateQuickCrea
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -995,7 +1069,7 @@ INSERT INTO agent_task_queue (
|
||||
status, priority, trigger_comment_id, trigger_summary, context,
|
||||
session_id, work_dir,
|
||||
attempt, max_attempts, parent_task_id, force_fresh_session, is_leader_task,
|
||||
squad_id
|
||||
squad_id, originator_user_id
|
||||
)
|
||||
SELECT
|
||||
p.agent_id, p.runtime_id, p.issue_id, p.chat_session_id, p.autopilot_run_id,
|
||||
@@ -1005,10 +1079,11 @@ SELECT
|
||||
p.attempt + 1, p.max_attempts, p.id,
|
||||
p.failure_reason IS NOT DISTINCT FROM 'codex_semantic_inactivity',
|
||||
p.is_leader_task,
|
||||
p.squad_id
|
||||
p.squad_id,
|
||||
p.originator_user_id
|
||||
FROM agent_task_queue p
|
||||
WHERE p.id = $1
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Clones a parent task into a fresh queued attempt. Carries forward the
|
||||
@@ -1022,6 +1097,11 @@ RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, c
|
||||
// parent and the self-trigger guard in shouldEnqueueSquadLeaderOnComment
|
||||
// continues to recognise it as a leader task. Inheriting squad_id also keeps
|
||||
// the squad-leader briefing injection working across retries.
|
||||
//
|
||||
// originator_user_id is inherited so the Composio overlay decision sees the
|
||||
// same top-of-chain human across the retry: the user behind the original
|
||||
// run has not changed, and the dispatch hook in TaskService can keep gating
|
||||
// on (originator == agent.owner_id) without a separate parent lookup.
|
||||
func (q *Queries) CreateRetryTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, createRetryTask, id)
|
||||
var i AgentTaskQueue
|
||||
@@ -1057,6 +1137,7 @@ func (q *Queries) CreateRetryTask(ctx context.Context, id pgtype.UUID) (AgentTas
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1080,7 +1161,7 @@ FROM victims v
|
||||
WHERE t.id = v.id
|
||||
AND t.status = 'queued'
|
||||
AND t.created_at < now() - make_interval(secs => $1::double precision)
|
||||
RETURNING t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay
|
||||
RETURNING t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.originator_user_id
|
||||
`
|
||||
|
||||
type ExpireStaleQueuedTasksParams struct {
|
||||
@@ -1152,6 +1233,7 @@ func (q *Queries) ExpireStaleQueuedTasks(ctx context.Context, arg ExpireStaleQue
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1170,7 +1252,7 @@ WHERE id = $1
|
||||
AND runtime_id = $2
|
||||
AND status IN ('dispatched', 'waiting_local_directory')
|
||||
AND started_at IS NULL
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type ExtendAgentTaskPrepareLeaseParams struct {
|
||||
@@ -1218,6 +1300,7 @@ func (q *Queries) ExtendAgentTaskPrepareLease(ctx context.Context, arg ExtendAge
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1232,7 +1315,7 @@ SET status = 'failed',
|
||||
work_dir = COALESCE($5, work_dir),
|
||||
prepare_lease_expires_at = NULL
|
||||
WHERE id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type FailAgentTaskParams struct {
|
||||
@@ -1293,6 +1376,7 @@ func (q *Queries) FailAgentTask(ctx context.Context, arg FailAgentTaskParams) (A
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1308,7 +1392,7 @@ WHERE (
|
||||
AND (prepare_lease_expires_at IS NULL OR prepare_lease_expires_at < now())
|
||||
)
|
||||
OR (status = 'running' AND started_at < now() - make_interval(secs => $2::double precision))
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type FailStaleTasksParams struct {
|
||||
@@ -1368,6 +1452,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1380,7 +1465,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -1410,12 +1495,13 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentForClaimUpdate = `-- name: GetAgentForClaimUpdate :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`
|
||||
@@ -1446,12 +1532,13 @@ func (q *Queries) GetAgentForClaimUpdate(ctx context.Context, id pgtype.UUID) (A
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
@@ -1486,12 +1573,13 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentTask = `-- name: GetAgentTask :one
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -1530,12 +1618,13 @@ func (q *Queries) GetAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQu
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentTaskInWorkspace = `-- name: GetAgentTaskInWorkspace :one
|
||||
SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay FROM agent_task_queue atq
|
||||
SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.originator_user_id FROM agent_task_queue atq
|
||||
JOIN agent a ON a.id = atq.agent_id
|
||||
WHERE atq.id = $1 AND a.workspace_id = $2
|
||||
`
|
||||
@@ -1587,6 +1676,7 @@ func (q *Queries) GetAgentTaskInWorkspace(ctx context.Context, arg GetAgentTaskI
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1903,7 +1993,7 @@ func (q *Queries) LinkTaskToIssue(ctx context.Context, arg LinkTaskToIssueParams
|
||||
}
|
||||
|
||||
const listActiveAgentsByRuntime = `-- name: ListActiveAgentsByRuntime :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE runtime_id = $1 AND archived_at IS NULL
|
||||
ORDER BY name ASC
|
||||
`
|
||||
@@ -1946,6 +2036,7 @@ func (q *Queries) ListActiveAgentsByRuntime(ctx context.Context, runtimeID pgtyp
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1958,7 +2049,7 @@ func (q *Queries) ListActiveAgentsByRuntime(ctx context.Context, runtimeID pgtyp
|
||||
}
|
||||
|
||||
const listActiveAgentsByRuntimeForUpdate = `-- name: ListActiveAgentsByRuntimeForUpdate :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE runtime_id = $1 AND archived_at IS NULL
|
||||
ORDER BY name ASC
|
||||
FOR UPDATE
|
||||
@@ -2004,6 +2095,7 @@ func (q *Queries) ListActiveAgentsByRuntimeForUpdate(ctx context.Context, runtim
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2016,7 +2108,7 @@ func (q *Queries) ListActiveAgentsByRuntimeForUpdate(ctx context.Context, runtim
|
||||
}
|
||||
|
||||
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -2067,6 +2159,7 @@ func (q *Queries) ListActiveTasksByIssue(ctx context.Context, issueID pgtype.UUI
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2079,7 +2172,7 @@ func (q *Queries) ListActiveTasksByIssue(ctx context.Context, issueID pgtype.UUI
|
||||
}
|
||||
|
||||
const listAgentTasks = `-- name: ListAgentTasks :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE agent_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -2125,6 +2218,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2137,7 +2231,7 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -2174,6 +2268,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2186,7 +2281,7 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||
}
|
||||
|
||||
const listAllAgents = `-- name: ListAllAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -2223,6 +2318,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2235,7 +2331,7 @@ func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([
|
||||
}
|
||||
|
||||
const listPendingTasksByRuntime = `-- name: ListPendingTasksByRuntime :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
`
|
||||
@@ -2281,6 +2377,7 @@ func (q *Queries) ListPendingTasksByRuntime(ctx context.Context, runtimeID pgtyp
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2293,7 +2390,7 @@ func (q *Queries) ListPendingTasksByRuntime(ctx context.Context, runtimeID pgtyp
|
||||
}
|
||||
|
||||
const listQueuedClaimCandidatesByRuntime = `-- name: ListQueuedClaimCandidatesByRuntime :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE runtime_id = $1 AND status = 'queued'
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
`
|
||||
@@ -2347,6 +2444,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntime(ctx context.Context, runtim
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2359,7 +2457,7 @@ func (q *Queries) ListQueuedClaimCandidatesByRuntime(ctx context.Context, runtim
|
||||
}
|
||||
|
||||
const listTasksByIssue = `-- name: ListTasksByIssue :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay FROM agent_task_queue
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id FROM agent_task_queue
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -2405,6 +2503,7 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2417,15 +2516,15 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
||||
}
|
||||
|
||||
const listWorkspaceAgentTaskSnapshot = `-- name: ListWorkspaceAgentTaskSnapshot :many
|
||||
SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay FROM agent_task_queue atq
|
||||
SELECT atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.originator_user_id FROM agent_task_queue atq
|
||||
JOIN agent a ON a.id = atq.agent_id
|
||||
WHERE a.workspace_id = $1
|
||||
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay FROM (
|
||||
SELECT DISTINCT ON (atq.agent_id) atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay
|
||||
SELECT t.id, t.agent_id, t.issue_id, t.status, t.priority, t.dispatched_at, t.started_at, t.completed_at, t.result, t.error, t.created_at, t.context, t.runtime_id, t.session_id, t.work_dir, t.trigger_comment_id, t.chat_session_id, t.autopilot_run_id, t.attempt, t.max_attempts, t.parent_task_id, t.failure_reason, t.trigger_summary, t.force_fresh_session, t.is_leader_task, t.wait_reason, t.initiator_user_id, t.handoff_note, t.prepare_lease_expires_at, t.squad_id, t.runtime_mcp_overlay, t.originator_user_id FROM (
|
||||
SELECT DISTINCT ON (atq.agent_id) atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id, atq.autopilot_run_id, atq.attempt, atq.max_attempts, atq.parent_task_id, atq.failure_reason, atq.trigger_summary, atq.force_fresh_session, atq.is_leader_task, atq.wait_reason, atq.initiator_user_id, atq.handoff_note, atq.prepare_lease_expires_at, atq.squad_id, atq.runtime_mcp_overlay, atq.originator_user_id
|
||||
FROM agent_task_queue atq
|
||||
JOIN agent a ON a.id = atq.agent_id
|
||||
WHERE a.workspace_id = $1
|
||||
@@ -2493,6 +2592,7 @@ func (q *Queries) ListWorkspaceAgentTaskSnapshot(ctx context.Context, workspaceI
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2510,7 +2610,7 @@ SET status = 'waiting_local_directory',
|
||||
wait_reason = $2,
|
||||
prepare_lease_expires_at = now() + make_interval(secs => $3::double precision)
|
||||
WHERE id = $1 AND status = 'dispatched'
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type MarkAgentTaskWaitingLocalDirectoryParams struct {
|
||||
@@ -2563,6 +2663,7 @@ func (q *Queries) MarkAgentTaskWaitingLocalDirectory(ctx context.Context, arg Ma
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2582,7 +2683,7 @@ WHERE id = (
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type ReclaimStaleDispatchedTaskForRuntimeParams struct {
|
||||
@@ -2631,6 +2732,7 @@ func (q *Queries) ReclaimStaleDispatchedTaskForRuntime(ctx context.Context, arg
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2644,7 +2746,7 @@ SET status = 'failed',
|
||||
wait_reason = NULL,
|
||||
prepare_lease_expires_at = NULL
|
||||
WHERE runtime_id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Called by the daemon at startup. Atomically fails any dispatched/running/
|
||||
@@ -2694,6 +2796,7 @@ func (q *Queries) RecoverOrphanedTasksForRuntime(ctx context.Context, runtimeID
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2713,7 +2816,7 @@ SET status = CASE WHEN EXISTS (
|
||||
) THEN 'working' ELSE 'idle' END,
|
||||
updated_at = now()
|
||||
WHERE a.id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
@@ -2742,6 +2845,7 @@ func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUI
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2749,7 +2853,7 @@ func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUI
|
||||
const restoreAgent = `-- name: RestoreAgent :one
|
||||
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
@@ -2778,6 +2882,7 @@ func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, erro
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2812,7 +2917,7 @@ SET status = 'running',
|
||||
wait_reason = NULL,
|
||||
prepare_lease_expires_at = NULL
|
||||
WHERE id = $1 AND status IN ('dispatched', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Transitions a task to running. Accepts either 'dispatched' (the normal
|
||||
@@ -2856,6 +2961,7 @@ func (q *Queries) StartAgentTask(ctx context.Context, id pgtype.UUID) (AgentTask
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2877,30 +2983,38 @@ UPDATE agent SET
|
||||
mcp_config = COALESCE($14, mcp_config),
|
||||
model = COALESCE($15, model),
|
||||
thinking_level = COALESCE($16, thinking_level),
|
||||
composio_toolkit_allowlist = COALESCE($17::text[], composio_toolkit_allowlist),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
RuntimeMode pgtype.Text `json:"runtime_mode"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Visibility pgtype.Text `json:"visibility"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
Instructions pgtype.Text `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
ThinkingLevel pgtype.Text `json:"thinking_level"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
RuntimeMode pgtype.Text `json:"runtime_mode"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Visibility pgtype.Text `json:"visibility"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
Instructions pgtype.Text `json:"instructions"`
|
||||
CustomEnv []byte `json:"custom_env"`
|
||||
CustomArgs []byte `json:"custom_args"`
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
ThinkingLevel pgtype.Text `json:"thinking_level"`
|
||||
ComposioToolkitAllowlist []string `json:"composio_toolkit_allowlist"`
|
||||
}
|
||||
|
||||
// composio_toolkit_allowlist is set wholesale: the API layer is responsible
|
||||
// for normalising the request payload to either (a) the new slug list — sent
|
||||
// here verbatim — or (b) an empty array to explicitly disable Composio.
|
||||
// Distinguish "field omitted" (preserve) from "explicit clear" via
|
||||
// ClearAgentComposioToolkitAllowlist below, mirroring the
|
||||
// thinking_level / mcp_config two-query pattern: COALESCE can't restore NULL.
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgent,
|
||||
arg.ID,
|
||||
@@ -2919,6 +3033,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
arg.McpConfig,
|
||||
arg.Model,
|
||||
arg.ThinkingLevel,
|
||||
arg.ComposioToolkitAllowlist,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
@@ -2944,6 +3059,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2952,7 +3068,7 @@ const updateAgentCustomEnv = `-- name: UpdateAgentCustomEnv :one
|
||||
UPDATE agent
|
||||
SET custom_env = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type UpdateAgentCustomEnvParams struct {
|
||||
@@ -2991,6 +3107,7 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -2998,7 +3115,7 @@ func (q *Queries) UpdateAgentCustomEnv(ctx context.Context, arg UpdateAgentCusto
|
||||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model, thinking_level, composio_toolkit_allowlist
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
@@ -3032,6 +3149,7 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
||||
&i.McpConfig,
|
||||
&i.Model,
|
||||
&i.ThinkingLevel,
|
||||
&i.ComposioToolkitAllowlist,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ const createAutopilotTask = `-- name: CreateAutopilotTask :one
|
||||
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, autopilot_run_id, trigger_summary)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4, $5)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CreateAutopilotTaskParams struct {
|
||||
@@ -230,6 +230,8 @@ func (q *Queries) CreateAutopilotTask(ctx context.Context, arg CreateAutopilotTa
|
||||
&i.HandoffNote,
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -90,13 +90,14 @@ func (q *Queries) CreateChatSession(ctx context.Context, arg CreateChatSessionPa
|
||||
const createChatTask = `-- name: CreateChatTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, chat_session_id,
|
||||
initiator_user_id, force_fresh_session
|
||||
initiator_user_id, originator_user_id, force_fresh_session
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, NULL, 'queued', $3, $4, $5,
|
||||
COALESCE($6::boolean, FALSE)
|
||||
$6,
|
||||
COALESCE($7::boolean, FALSE)
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CreateChatTaskParams struct {
|
||||
@@ -105,6 +106,7 @@ type CreateChatTaskParams struct {
|
||||
Priority int32 `json:"priority"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
InitiatorUserID pgtype.UUID `json:"initiator_user_id"`
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
ForceFreshSession pgtype.Bool `json:"force_fresh_session"`
|
||||
}
|
||||
|
||||
@@ -115,6 +117,7 @@ func (q *Queries) CreateChatTask(ctx context.Context, arg CreateChatTaskParams)
|
||||
arg.Priority,
|
||||
arg.ChatSessionID,
|
||||
arg.InitiatorUserID,
|
||||
arg.OriginatorUserID,
|
||||
arg.ForceFreshSession,
|
||||
)
|
||||
var i AgentTaskQueue
|
||||
@@ -149,6 +152,8 @@ func (q *Queries) CreateChatTask(ctx context.Context, arg CreateChatTaskParams)
|
||||
&i.HandoffNote,
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
@@ -44,6 +44,8 @@ type Agent struct {
|
||||
McpConfig []byte `json:"mcp_config"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
ThinkingLevel pgtype.Text `json:"thinking_level"`
|
||||
// Composio toolkit slugs this agent is allowed to mount as MCP. NULL or empty array = no MCP. Only effective when the run originator matches agent.owner_id; see migration 129 + MUL-3869 / MUL-3721. Stored as TEXT[] so the dispatch path can intersect against the originator's active connections with a single SQL ANY() filter, no JSON parse on the hot path.
|
||||
ComposioToolkitAllowlist []string `json:"composio_toolkit_allowlist"`
|
||||
}
|
||||
|
||||
type AgentRuntime struct {
|
||||
@@ -104,6 +106,8 @@ type AgentTaskQueue struct {
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
// Per-task MCP servers computed at dispatch time, merged on top of agent.mcp_config. Currently used by Composio integration to inject the initiator user's session URL. Cleared after task completes via trg_clear_runtime_mcp_overlay.
|
||||
RuntimeMcpOverlay []byte `json:"runtime_mcp_overlay"`
|
||||
// Top-of-chain human originator for this run. For human-triggered tasks (comment by a member, chat, quick-create) equals that member. For agent-fanout tasks (agent A @-mentions agent B) inherited from the parent task's originator_user_id via comment.source_task_id. NULL when no human is in the chain (autopilot, system-driven). Used by the Composio MCP overlay dispatch to require originator == agent.owner_id; see migration 129 + MUL-3869.
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
|
||||
@@ -16,7 +16,7 @@ UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now()
|
||||
WHERE (runtime_id = ANY($1::uuid[]) OR agent_id = ANY($2::uuid[]))
|
||||
AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
type CancelAgentTasksByRuntimeOrAgentParams struct {
|
||||
@@ -78,6 +78,8 @@ func (q *Queries) CancelAgentTasksByRuntimeOrAgent(ctx context.Context, arg Canc
|
||||
&i.HandoffNote,
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,7 +199,7 @@ WHERE status IN ('dispatched', 'running', 'waiting_local_directory')
|
||||
AND runtime_id IN (
|
||||
SELECT id FROM agent_runtime WHERE status = 'offline'
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, originator_user_id
|
||||
`
|
||||
|
||||
// Marks dispatched/running/waiting_local_directory tasks as failed when
|
||||
@@ -243,6 +245,8 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context) ([]AgentTaskQ
|
||||
&i.HandoffNote,
|
||||
&i.PrepareLeaseExpiresAt,
|
||||
&i.SquadID,
|
||||
&i.RuntimeMcpOverlay,
|
||||
&i.OriginatorUserID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,11 +20,23 @@ WHERE id = $1 AND workspace_id = $2;
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
instructions, custom_env, custom_args, mcp_config, model, thinking_level
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
instructions, custom_env, custom_args, mcp_config, model, thinking_level,
|
||||
composio_toolkit_allowlist
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16,
|
||||
sqlc.narg('composio_toolkit_allowlist')::text[]
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
-- composio_toolkit_allowlist is set wholesale: the API layer is responsible
|
||||
-- for normalising the request payload to either (a) the new slug list — sent
|
||||
-- here verbatim — or (b) an empty array to explicitly disable Composio.
|
||||
-- Distinguish "field omitted" (preserve) from "explicit clear" via
|
||||
-- ClearAgentComposioToolkitAllowlist below, mirroring the
|
||||
-- thinking_level / mcp_config two-query pattern: COALESCE can't restore NULL.
|
||||
UPDATE agent SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
@@ -41,10 +53,22 @@ UPDATE agent SET
|
||||
mcp_config = COALESCE(sqlc.narg('mcp_config'), mcp_config),
|
||||
model = COALESCE(sqlc.narg('model'), model),
|
||||
thinking_level = COALESCE(sqlc.narg('thinking_level'), thinking_level),
|
||||
composio_toolkit_allowlist = COALESCE(sqlc.narg('composio_toolkit_allowlist')::text[], composio_toolkit_allowlist),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearAgentComposioToolkitAllowlist :one
|
||||
-- Explicit NULL-clear for composio_toolkit_allowlist. The COALESCE-based
|
||||
-- UpdateAgent cannot set the column back to NULL — sending an empty array
|
||||
-- through there would persist `{}` (still a non-NULL, equivalent to "no
|
||||
-- toolkits" but distinct from "field never configured"). The API uses this
|
||||
-- dedicated query when the agent owner removes every toolkit; subsequent
|
||||
-- dispatch decisions treat NULL identically to `{}` (both -> no overlay).
|
||||
UPDATE agent SET composio_toolkit_allowlist = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearAgentThinkingLevel :one
|
||||
-- Explicit NULL-clear for thinking_level. COALESCE-based UpdateAgent cannot
|
||||
-- set the column back to NULL, so the API layer routes "user picked Default"
|
||||
@@ -137,7 +161,7 @@ ORDER BY created_at DESC;
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
squad_id
|
||||
squad_id, originator_user_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, 'queued', $4, sqlc.narg(trigger_comment_id),
|
||||
@@ -145,7 +169,8 @@ VALUES (
|
||||
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE),
|
||||
COALESCE(sqlc.narg('is_leader_task')::boolean, FALSE),
|
||||
sqlc.narg(handoff_note),
|
||||
sqlc.narg(squad_id)
|
||||
sqlc.narg(squad_id),
|
||||
sqlc.narg(originator_user_id)
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -153,8 +178,10 @@ RETURNING *;
|
||||
-- Quick-create tasks have no issue / chat / autopilot link; the entire job
|
||||
-- description (prompt, requester, workspace) lives in context JSONB. The
|
||||
-- daemon detects this variant via context.type == "quick_create".
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, context, originator_user_id
|
||||
)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4, sqlc.narg(originator_user_id))
|
||||
RETURNING *;
|
||||
|
||||
-- name: LinkTaskToIssue :exec
|
||||
@@ -191,12 +218,17 @@ WHERE id = $1 AND status = 'queued';
|
||||
-- parent and the self-trigger guard in shouldEnqueueSquadLeaderOnComment
|
||||
-- continues to recognise it as a leader task. Inheriting squad_id also keeps
|
||||
-- the squad-leader briefing injection working across retries.
|
||||
--
|
||||
-- originator_user_id is inherited so the Composio overlay decision sees the
|
||||
-- same top-of-chain human across the retry: the user behind the original
|
||||
-- run has not changed, and the dispatch hook in TaskService can keep gating
|
||||
-- on (originator == agent.owner_id) without a separate parent lookup.
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, chat_session_id, autopilot_run_id,
|
||||
status, priority, trigger_comment_id, trigger_summary, context,
|
||||
session_id, work_dir,
|
||||
attempt, max_attempts, parent_task_id, force_fresh_session, is_leader_task,
|
||||
squad_id
|
||||
squad_id, originator_user_id
|
||||
)
|
||||
SELECT
|
||||
p.agent_id, p.runtime_id, p.issue_id, p.chat_session_id, p.autopilot_run_id,
|
||||
@@ -206,7 +238,8 @@ SELECT
|
||||
p.attempt + 1, p.max_attempts, p.id,
|
||||
p.failure_reason IS NOT DISTINCT FROM 'codex_semantic_inactivity',
|
||||
p.is_leader_task,
|
||||
p.squad_id
|
||||
p.squad_id,
|
||||
p.originator_user_id
|
||||
FROM agent_task_queue p
|
||||
WHERE p.id = $1
|
||||
RETURNING *;
|
||||
|
||||
@@ -110,10 +110,11 @@ WHERE id = $1;
|
||||
-- name: CreateChatTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, chat_session_id,
|
||||
initiator_user_id, force_fresh_session
|
||||
initiator_user_id, originator_user_id, force_fresh_session
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, NULL, 'queued', $3, $4, $5,
|
||||
sqlc.narg(originator_user_id),
|
||||
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE)
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
Reference in New Issue
Block a user