Files
multica/server/pkg/db/queries/runtime.sql
T
Bohan Jiangandmultica-agent ff674c4e1d MUL-8185: Support sharing runtimes with specific members (#9455)
* feat: share runtimes with selected workspace members

Co-authored-by: multica-agent <[email protected]>

* refactor(runtimes): one-click private/public and popover member picker

Private and Public apply immediately again (restrictions still go through
the reviewed revocation dialog). Specific members opens a draft popover
with search, Cancel and Save; selected recipients are summarized under
the scope control with an Edit entry point. The editor moves to its own
runtime-access-editor component.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-23 09:28:22 +08:00

689 lines
32 KiB
SQL

-- name: ListAgentRuntimes :many
SELECT * FROM agent_runtime
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: ListAgentRuntimeIDsByWorkspace :many
SELECT id FROM agent_runtime
WHERE workspace_id = $1
ORDER BY id ASC;
-- name: ListVisibleAgentRuntimes :many
-- A private runtime is another member's machine and must not leak into their
-- runtime list. The owner can always see their own runtime; everyone else
-- sees only runtimes shared with them or with the entire workspace.
SELECT * FROM agent_runtime
WHERE workspace_id = $1
AND (owner_id = $2 OR visibility = 'public' OR (visibility = 'private' AND $2 = ANY(shared_with)))
ORDER BY created_at ASC;
-- name: GetAgentRuntime :one
SELECT * FROM agent_runtime
WHERE id = $1;
-- name: GetAgentRuntimes :many
-- Batch variant of GetAgentRuntime (MUL-4257): loads every runtime in the
-- input set in one round trip so the machine-level batch claim handler can
-- resolve+authorize all of a daemon's runtimes without one point query per
-- runtime. Rows are returned only for ids that exist; the caller matches them
-- back by id and skips any that are missing.
SELECT * FROM agent_runtime
WHERE id = ANY(@ids::uuid[]);
-- name: GetAgentRuntimeHeartbeatLeases :many
-- Narrow connection-time and heartbeat-reconciliation projection. The daemon
-- WebSocket authenticates its whole runtime set in one round trip and then
-- keeps these immutable ownership fields plus liveness state in its connection
-- lease, avoiding a GetAgentRuntime call on every heartbeat.
--
-- owner_id is carried so the WS handshake can enforce runtime ownership for
-- user-credential connections (MUL-8154) with the same single round trip.
SELECT id, workspace_id, daemon_id, owner_id, status, last_seen_at
FROM agent_runtime
WHERE id = ANY(@ids::uuid[]);
-- name: LockAgentRuntime :one
-- Acquires a row-level exclusive lock on the runtime row. Used at the
-- top of the cascade-delete transaction so that:
-- 1. PostgreSQL's FK validation on agent.runtime_id (FK ... ON DELETE
-- RESTRICT) needs FOR KEY SHARE on the parent runtime row, which
-- conflicts with FOR UPDATE — so any concurrent INSERT or UPDATE
-- that would point a new/moved agent at this runtime blocks until
-- our transaction finishes; and
-- 2. concurrent UPDATE/DELETE of the runtime row itself (e.g. another
-- delete attempt) waits for us to commit.
-- Combined with ListUserAgentsByRuntimeForUpdate (which row-locks active and
-- archived user agents) this closes both plan drift and archived-agent restore
-- races under read-committed isolation.
SELECT * FROM agent_runtime
WHERE id = $1
FOR UPDATE;
-- name: GetAgentRuntimeForWorkspace :one
SELECT * FROM agent_runtime
WHERE id = $1 AND workspace_id = $2;
-- name: UpsertAgentRuntime :one
-- (xmax = 0) AS inserted distinguishes a fresh insert (true) from an upsert
-- that updated an existing row (false). Analytics reads this to fire
-- runtime_registered/runtime_ready only on first-time registration.
--
-- Ownership fence (MUL-8154): when required_owner_id is set (user-credential
-- registration), the conflict branch only updates a row already owned by that
-- user. A row owned by someone else - or by nobody - is left untouched and the
-- statement returns no row, so a workspace member cannot take over another
-- member's runtime by re-registering its (daemon_id, provider). Daemon-token
-- registration passes NULL here and NULL owner_id, which keeps the existing
-- owner via the COALESCE below.
INSERT INTO agent_runtime (
workspace_id,
daemon_id,
name,
runtime_mode,
provider,
status,
device_info,
metadata,
owner_id,
last_seen_at
) VALUES (
@workspace_id, @daemon_id, @name, @runtime_mode, @provider, @status,
@device_info, @metadata, @owner_id, now()
)
-- Built-in runtimes carry no profile_id. The arbiter is the partial unique
-- index from migration 121 (WHERE profile_id IS NULL); the predicate must be
-- spelled out so Postgres selects that partial index, not the custom-runtime
-- one on (workspace_id, daemon_id, profile_id).
ON CONFLICT (workspace_id, daemon_id, provider) WHERE profile_id IS NULL
DO UPDATE SET
name = EXCLUDED.name,
runtime_mode = EXCLUDED.runtime_mode,
status = EXCLUDED.status,
device_info = EXCLUDED.device_info,
metadata = EXCLUDED.metadata,
owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id),
last_seen_at = now(),
updated_at = now()
WHERE sqlc.narg('required_owner_id')::uuid IS NULL
OR agent_runtime.owner_id = sqlc.narg('required_owner_id')::uuid
RETURNING *, (xmax = 0) AS inserted;
-- name: UpsertAgentRuntimeWithProfile :one
-- Custom-runtime registration: a daemon resolved a workspace runtime_profile's
-- command_name on PATH and is registering an instance of it. The arbiter is the
-- partial unique index from migration 120 (WHERE profile_id IS NOT NULL), so a
-- single daemon can host the built-in provider AND any number of custom
-- profiles of the same protocol family. provider carries the base runtime
-- identity so ResolveBackend applies its descriptor; profile_id preserves
-- custom-profile provenance. (xmax = 0) AS inserted mirrors UpsertAgentRuntime,
-- and the required_owner_id fence matches it too.
INSERT INTO agent_runtime (
workspace_id,
daemon_id,
name,
runtime_mode,
provider,
status,
device_info,
metadata,
owner_id,
profile_id,
last_seen_at
) VALUES (
@workspace_id, @daemon_id, @name, @runtime_mode, @provider, @status,
@device_info, @metadata, @owner_id, @profile_id, now()
)
ON CONFLICT (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL
DO UPDATE SET
name = EXCLUDED.name,
runtime_mode = EXCLUDED.runtime_mode,
provider = EXCLUDED.provider,
status = EXCLUDED.status,
device_info = EXCLUDED.device_info,
metadata = EXCLUDED.metadata,
owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id),
last_seen_at = now(),
updated_at = now()
WHERE sqlc.narg('required_owner_id')::uuid IS NULL
OR agent_runtime.owner_id = sqlc.narg('required_owner_id')::uuid
RETURNING *, (xmax = 0) AS inserted;
-- name: UpdateAgentRuntimeVisibility :one
-- Replaces the runtime's access policy: private admits owner plus shared_with;
-- public admits any workspace member. Default for new rows is private
-- (see migration 083). Gated at the handler layer to the runtime owner only:
-- visibility is consent to use that person's machine, not a workspace-governance
-- setting.
UPDATE agent_runtime
SET visibility = @visibility, shared_with = @shared_with::uuid[], updated_at = now()
WHERE id = @id
RETURNING *;
-- name: UpdateAgentRuntimeCustomName :one
-- Sets or clears a runtime's user-facing custom name (MUL-4217). custom_name
-- overrides the daemon-proposed `name` for display; passing NULL reverts to
-- the default. Kept separate from the registration upserts above (which do
-- name = EXCLUDED.name on every heartbeat) so a custom name is never
-- clobbered by the daemon. Gated at the handler to owner / workspace admin.
UPDATE agent_runtime
SET custom_name = @custom_name, updated_at = now()
WHERE id = @id
RETURNING *;
-- name: UpdateAgentRuntimeCustomNameByDaemon :many
-- Machine-level rename (MUL-4217): applies one custom name to every runtime
-- sharing a daemon_id in the workspace, since a single machine hosts one
-- runtime per provider. @owner_id is NULL for workspace owners/admins (rename
-- the whole machine) or the actor's user id otherwise (only their own
-- runtimes on that machine), so a member cannot relabel someone else's
-- runtime that happens to share the host.
UPDATE agent_runtime
SET custom_name = @custom_name, updated_at = now()
WHERE workspace_id = @workspace_id
AND daemon_id = @daemon_id
AND (@owner_id::uuid IS NULL OR owner_id = @owner_id)
RETURNING *;
-- name: ListDaemonCustomNames :many
-- Lists the custom_name of every OTHER runtime on (workspace_id, daemon_id)
-- (MUL-4217). @exclude_id drops the just-registered row. The caller derives
-- the machine-level name in Go — the same "all runtimes share one non-null
-- name" rule the frontend applies in sharedCustomName — so a freshly-added
-- runtime on an already-named machine can inherit that name and keep the
-- machine's display name stable. A daemon hosts only a handful of runtimes
-- (one per provider), so this is a tiny read.
SELECT custom_name FROM agent_runtime
WHERE workspace_id = @workspace_id
AND daemon_id = @daemon_id
AND id <> @exclude_id;
-- name: TouchAgentRuntimeLastSeen :execrows
-- Bumps last_seen_at on an already-online runtime. Deliberately does NOT
-- touch status or updated_at: status is unchanged on the hot heartbeat path,
-- and avoiding updated_at keeps the row HOT-eligible (no index columns
-- change) and avoids invalidating any downstream consumer that watches
-- updated_at.
--
-- The status='online' predicate is load-bearing: callers read rt.Status from
-- a prior SELECT and may race with the sweeper, which can flip the row to
-- offline between that SELECT and this UPDATE. Without the predicate this
-- query would silently leave a freshly-heartbeated runtime stuck in offline.
-- Returning affected rows lets callers detect that race and fall back to
-- MarkAgentRuntimeOnline to flip the row back online.
UPDATE agent_runtime
SET last_seen_at = now()
WHERE id = $1 AND status = 'online';
-- name: TouchAgentRuntimesLastSeenBatch :many
-- Bulk variant of TouchAgentRuntimeLastSeen used by the BatchedHeartbeatScheduler:
-- coalesces N per-runtime "bump last_seen_at" requests into a single UPDATE so a
-- fleet beating every 15s costs ~1 DB transaction per batch tick instead of N.
--
-- Same load-bearing predicate as the single-id form: status='online' avoids
-- silently un-deleting a sweeper-flipped offline row, and we deliberately do
-- NOT touch updated_at so the rows stay HOT-eligible. RETURNING is load-bearing:
-- the scheduler reconciles omitted IDs in one narrow batch query, restoring
-- sweeper-raced offline rows and invalidating connections for deleted rows.
UPDATE agent_runtime
SET last_seen_at = now()
WHERE id = ANY(@ids::uuid[]) AND status = 'online'
RETURNING id;
-- name: MarkAgentRuntimeOnline :one
-- Used on the offline→online transition (and on first heartbeat after
-- registration). Writes status, last_seen_at, and updated_at because the
-- status flip is a real state change and we want updated_at to reflect it.
UPDATE agent_runtime
SET status = 'online', last_seen_at = now(), updated_at = now()
WHERE id = $1
RETURNING *;
-- name: MarkAgentRuntimeOnlineIfOffline :execrows
-- Reports whether this heartbeat performed an offline -> online transition.
-- The conditional update prevents concurrent stale heartbeat snapshots from
-- publishing duplicate lifecycle refresh events after another beat already
-- recovered the runtime.
UPDATE agent_runtime
SET status = 'online', last_seen_at = now(), updated_at = now()
WHERE id = $1 AND status <> 'online';
-- name: SetAgentRuntimeOffline :exec
UPDATE agent_runtime
SET status = 'offline', updated_at = now()
WHERE id = $1;
-- name: SetAgentRuntimeOfflineWithReason :exec
-- Takes a runtime offline and records WHY, for the one class of cause the user
-- has to repair before the runtime can come back (MUL-6164). Everything that
-- merely stops — daemon shutdown, laptop asleep — uses SetAgentRuntimeOffline
-- and leaves no reason, because "wait for it" needs no explanation.
--
-- Merged into metadata rather than a column: registration overwrites metadata
-- wholesale (`metadata = EXCLUDED.metadata`), so a runtime that comes back
-- drops the reason as a side effect of being usable again — there is no stale
-- explanation to clean up and no code path that has to remember to clear it.
UPDATE agent_runtime
SET status = 'offline',
metadata = metadata || jsonb_build_object('offline_reason', @offline_reason::jsonb),
updated_at = now()
WHERE id = $1;
-- name: SelectStaleOnlineRuntimes :many
-- Lists online runtimes whose last_seen_at exceeds the stale window. The
-- sweeper uses this as a candidate set, then optionally filters via the
-- LivenessStore before flipping rows to offline (a fresh Redis liveness
-- record means the DB row is just lagging, not actually dead).
SELECT id, workspace_id, owner_id, daemon_id, provider FROM agent_runtime
WHERE status = 'online'
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision);
-- name: MarkRuntimesOfflineByIDs :many
-- Flips a known set of runtime IDs from online to offline. Paired with
-- SelectStaleOnlineRuntimes in the sweeper so the candidate selection and
-- the actual write are decoupled (the LivenessStore filter sits between).
--
-- Re-checks the stale predicate inside the UPDATE so a concurrent heartbeat
-- between the SELECT (candidate gather), the LivenessStore filter, and this
-- UPDATE cannot demote a runtime that just refreshed last_seen_at. The
-- legacy MarkStaleRuntimesOffline UPDATE had this property implicitly
-- because the predicate and the write lived in one statement; here we
-- carry it forward explicitly so the SELECT/filter/UPDATE pipeline retains
-- the same race-freedom.
UPDATE agent_runtime
SET status = 'offline', updated_at = now()
WHERE status = 'online'
AND id = ANY(@ids::uuid[])
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision)
RETURNING id, workspace_id, owner_id, daemon_id, provider;
-- name: FailTasksForOfflineRuntimes :many
-- Marks dispatched/running/waiting_local_directory tasks as failed when
-- their runtime has remained offline beyond the reconnect grace. A short or
-- medium network partition must not terminate a daemon process that is still
-- running locally; a real daemon restart is recovered separately through
-- RecoverOrphanedTasksForRuntime. Bounded per tick so a large recovery backlog
-- cannot monopolise the sweeper transaction. last_seen_at is normally set by
-- the first heartbeat; updated_at is only the fallback for a never-heartbeated
-- runtime, and a forced-offline write starts its grace from that update.
WITH victims AS (
SELECT task.id
FROM agent_task_queue task
JOIN agent_runtime runtime ON runtime.id = task.runtime_id
WHERE task.status IN ('dispatched', 'running', 'waiting_local_directory')
AND runtime.status = 'offline'
AND COALESCE(runtime.last_seen_at, runtime.updated_at) <
now() - make_interval(secs => @reconnect_grace_secs::double precision)
ORDER BY COALESCE(runtime.last_seen_at, runtime.updated_at), task.created_at
LIMIT @max_per_tick::int
FOR UPDATE OF task SKIP LOCKED
)
UPDATE agent_task_queue AS task
SET status = 'failed', completed_at = now(), error = 'runtime went offline',
failure_reason = 'runtime_offline',
wait_reason = NULL
FROM victims
WHERE task.id = victims.id
AND task.status IN ('dispatched', 'running', 'waiting_local_directory')
RETURNING task.*;
-- name: ListAgentRuntimesByOwner :many
SELECT * FROM agent_runtime
WHERE workspace_id = $1 AND owner_id = $2
ORDER BY created_at ASC;
-- name: ForceOfflineRuntimesByIDs :many
-- Unconditionally flips a known set of runtime IDs to offline. Distinct from
-- MarkRuntimesOfflineByIDs (which keeps a stale-window predicate so the
-- sweeper cannot demote a runtime that just heartbeated): this variant is
-- used by intentional revocation paths — e.g. removing a workspace member —
-- where the caller has already decided the runtime should be offline
-- regardless of recent liveness.
UPDATE agent_runtime
SET status = 'offline', updated_at = now()
WHERE id = ANY(@runtime_ids::uuid[]) AND status = 'online'
RETURNING id, workspace_id, owner_id, daemon_id, provider;
-- name: CancelAgentTasksByRuntimeOrAgent :many
-- Cancels every active task that either lives on one of the given runtimes
-- OR belongs to one of the given agents. Used by the member-revocation flow:
-- the runtime-side covers tasks queued against the leaving member's runtimes;
-- the agent-side covers tasks pinned to a different runtime that those agents
-- left behind from a prior UpdateAgent (agent.runtime_id can change, but
-- agent_task_queue.runtime_id does not get rewritten when it does, so a task
-- queued on runtime A by agent X — later moved to runtime B — survives the
-- runtime-only revoke and could still be claimed because ClaimAgentTask does
-- not gate on agent.archived_at).
--
-- We use 'cancelled' rather than 'failed' so the daemon's per-task status
-- poller (watchTaskCancellation) interrupts the running agent gracefully.
-- Returns the affected rows so the caller can broadcast task:cancelled and
-- reconcile per-agent status.
--
-- The status list must cover EVERY non-terminal status, not just the ones the
-- daemon is actively working: 'deferred' (migration 128, comment-routing
-- escalation) was missing here and only went unnoticed because the runtime
-- delete used to cascade those rows away. Since MUL-5559 the runtime delete
-- unbinds history rows instead, and agent_task_queue_active_requires_runtime
-- rejects an active row without a runtime — so a missed status now surfaces as
-- a failed delete (runtime_delete_not_drained) instead of silent data loss.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(),
cancelled_by_type = 'system', cancelled_by_id = NULL, cancelled_by_name = NULL
WHERE (runtime_id = ANY(@runtime_ids::uuid[]) OR agent_id = ANY(@agent_ids::uuid[]))
AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: ListNonOwnerActiveTasksByRuntime :many
-- Lists live tasks whose agent owner will lose runtime access, including
-- hidden Builder execution carriers. shared_with is the desired recipient list.
SELECT task.*
FROM agent_task_queue AS task
JOIN agent ON agent.id = task.agent_id
WHERE task.runtime_id = @runtime_id
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
ORDER BY task.id;
-- name: ListNonOwnerActiveTasksByRuntimeForUpdate :many
-- Acquired after the affected agent and chat-session locks. Bindings serialize
-- on LockAgentRuntimeForAccessChange; claim/delivery rechecks authorization.
SELECT task.*
FROM agent_task_queue AS task
JOIN agent ON agent.id = task.agent_id
WHERE task.runtime_id = @runtime_id
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
ORDER BY task.id
FOR UPDATE OF task;
-- name: LockAgentRuntimeForAccessChange :one
-- Binding and visibility writers share this lock and recheck access under it.
-- NO KEY UPDATE deliberately permits legacy FK KEY SHARE locks: a chat enqueue
-- holding session/agent locks must not wait here while revocation locks them.
SELECT * FROM agent_runtime WHERE id = $1 FOR NO KEY UPDATE;
-- name: LockRuntimeRevocationChatSessions :many
-- Revocation already holds affected agents. Never wait in the reverse of the
-- normal session -> agent/task order: a concurrent chat writer yields a 409
-- and the entire revocation rolls back before changing anything.
SELECT cs.id FROM chat_session cs
WHERE EXISTS (
SELECT 1 FROM agent_task_queue task JOIN agent ON agent.id = task.agent_id
WHERE task.chat_session_id = cs.id
AND task.runtime_id = @runtime_id
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
)
ORDER BY cs.id
FOR UPDATE OF cs NOWAIT;
-- name: CancelNonOwnerTasksByRuntime :many
-- Server-side execution revocation for a public runtime made private. The
-- join deliberately classifies work by its agent owner rather than cancelling
-- every task on the runtime, so the runtime owner's own tasks keep running.
-- cancellation polling on an online daemon observes the state change; an
-- offline daemon cannot run new work and will observe it after reconnecting.
UPDATE agent_task_queue AS task
SET status = 'cancelled',
completed_at = now(), prepare_lease_expires_at = NULL,
error = 'runtime access was revoked',
failure_reason = @failure_reason::text,
cancelled_by_type = 'system', cancelled_by_id = NULL, cancelled_by_name = NULL,
wait_reason = NULL
FROM agent
WHERE task.agent_id = agent.id
AND task.runtime_id = @runtime_id
AND agent.owner_id IS DISTINCT FROM @runtime_owner_id
AND NOT COALESCE(agent.owner_id = ANY(@shared_with::uuid[]), false)
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING task.*;
-- name: CountUndrainedTasksByRuntimeOrAgent :one
-- Belt-and-braces gate for the runtime-delete transaction: after cancelling,
-- every task on this runtime OR owned by an agent being unbound must be terminal
-- (completed_at IS NOT NULL) before the unbind UPDATE runs. The agent-side
-- predicate must mirror CancelAgentTasksByRuntimeOrAgent: a task can remain
-- pinned to another runtime after its agent moves. Non-zero means some
-- non-terminal status escaped the cancel query — the handler aborts with 409
-- runtime_delete_not_drained rather than letting the CHECK constraint turn it
-- into an opaque 500, and rather than deleting rows to make it go away.
SELECT count(*) FROM agent_task_queue
WHERE (runtime_id = ANY(@runtime_ids::uuid[]) OR agent_id = ANY(@agent_ids::uuid[]))
AND completed_at IS NULL;
-- name: UnbindTasksFromRuntime :execrows
-- Detaches this runtime's task history so deleting the runtime row cannot
-- cascade it away (agent_task_queue.runtime_id is ON DELETE CASCADE, and
-- task_message / task_usage / task_token cascade from the task in turn).
-- Restricted to terminal rows: an active task must keep its runtime, per
-- agent_task_queue_active_requires_runtime. The caller runs
-- CancelAgentTasksByRuntimeOrAgent +
-- CountUndrainedTasksByRuntimeOrAgent first, so at this point "terminal" is
-- every row on the runtime.
UPDATE agent_task_queue
SET runtime_id = NULL
WHERE runtime_id = $1 AND completed_at IS NOT NULL;
-- name: UnbindUserAgentsFromRuntime :many
-- MUL-5559: the runtime-delete replacement for archive-then-hard-delete. Every
-- user agent bound to this runtime becomes unbound (runtime_id IS NULL) and
-- keeps its row, chats, labels, channel installations and autopilot config.
--
-- Deliberately NOT filtered on archived_at: an agent archived earlier is just
-- as much the user's data as an active one, and hard-deleting it was the same
-- bug. Deliberately restricted to kind = 'user': system agents are invisible
-- execution infrastructure with no UI to rebind them (see
-- DeleteSystemAgentsByRuntime), so leaving them unbound would strand rows no
-- one can repair.
UPDATE agent
SET runtime_id = NULL, updated_at = now()
WHERE runtime_id = $1 AND kind = 'user'
RETURNING *;
-- name: UnbindNonOwnerAgentsFromRuntime :many
-- Companion to CancelNonOwnerTasksByRuntime: preserve agents belonging to
-- the runtime owner or to recipients who retain access.
UPDATE agent
SET runtime_id = NULL, updated_at = now()
WHERE runtime_id = @runtime_id
AND owner_id IS DISTINCT FROM @runtime_owner_id
AND NOT COALESCE(owner_id = ANY(@shared_with::uuid[]), false)
RETURNING *;
-- name: DeleteAgentRuntime :exec
DELETE FROM agent_runtime WHERE id = $1;
-- name: DeleteSystemAgentsByRuntime :exec
-- System agents are invisible execution infrastructure (for example the Agent
-- Builder). Remove them before deleting their runtime so the RESTRICT runtime
-- FK cannot block an otherwise dependency-free delete.
DELETE FROM agent WHERE runtime_id = $1 AND kind = 'system';
-- name: CountActiveAgentsByRuntime :one
SELECT count(*) FROM agent WHERE runtime_id = $1 AND archived_at IS NULL;
-- name: FindLegacyRuntimesByDaemonID :many
-- Looks up runtime rows keyed on a prior (hostname-derived) daemon_id. Used
-- at register-time to find rows owned by the same machine under its old
-- identity so agents/tasks can be re-pointed at the new UUID-keyed row.
--
-- Comparison is case-insensitive because os.Hostname() has been observed to
-- return different casings on the same machine (e.g. `Jiayuans-MacBook-Pro`
-- vs `jiayuans-macbook-pro`) across reboots/mDNS state changes. A case-
-- sensitive `=` would strand the old row; LOWER() on both sides handles drift
-- without forcing the daemon to enumerate cased permutations.
--
-- Returns many rather than one because case drift may have already minted
-- duplicate rows historically (e.g. `Foo.local` AND `foo.local` under the
-- same workspace+provider). A single-row lookup would consolidate only one
-- of them and leave the rest orphaned. Callers must merge every returned
-- row into the new UUID-keyed runtime.
SELECT * FROM agent_runtime
WHERE workspace_id = @workspace_id
AND provider = @provider
AND LOWER(daemon_id) = LOWER(@daemon_id);
-- name: ReassignAgentsToRuntime :execrows
-- Re-points every agent referencing old_runtime_id at new_runtime_id.
UPDATE agent
SET runtime_id = @new_runtime_id
WHERE runtime_id = @old_runtime_id;
-- name: LockWorkspaceForRuntimeMerge :exec
-- Step 1 of the legacy runtime merge's fence, and the same first step every task
-- write takes (lock_task_owner_rows, migration 284): the workspace row, FOR KEY
-- SHARE. Taking it here rather than relying on the fence inside the reassignment
-- keeps the merge's lock order identical to the writers' — workspaces before owner
-- rows — so the two can never hold each other's next lock (MUL-5999).
SELECT 1 FROM workspace w
WHERE w.id IN (
SELECT r.workspace_id FROM agent_runtime r WHERE r.id = ANY(@runtime_ids::uuid[])
)
ORDER BY w.id
FOR KEY SHARE;
-- name: LockRuntimesForMerge :many
-- Step 2: the runtime rows themselves, FOR UPDATE, in id order.
--
-- FOR UPDATE is the point. A task write's fence takes FOR KEY SHARE on the runtime
-- it references, and KEY SHARE conflicts with UPDATE but not with another KEY
-- SHARE — so while the merge held only the workspace's KEY SHARE, a concurrent
-- enqueue against the OLD runtime went straight through after the task scan and was
-- then silently removed by ON DELETE CASCADE when the old runtime was deleted.
-- Holding FOR UPDATE on both runtimes from before the scan until COMMIT means a
-- late writer either commits first (and the scan sees its task) or waits and finds
-- the runtime gone, which its fence reports as "no row written".
--
-- Both runtimes are locked in one ordered statement so two merges running in
-- opposite directions cannot take the same pair in opposite orders.
--
-- Returns the ids it actually locked: a caller that asked for two and got fewer
-- knows a runtime disappeared before it got there and must abandon the merge.
-- workspace_id and owner_id come back with the lock so the merge can verify,
-- against the locked rows, that the caller owns both sides (MUL-8154).
SELECT id, workspace_id, owner_id FROM agent_runtime
WHERE id = ANY(@runtime_ids::uuid[])
ORDER BY id
FOR UPDATE;
-- name: ReassignTasksToRuntime :one
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
-- locks the owners' workspace rows in the writer's own transaction and returns
-- false once they are gone, so this statement writes no row instead of stranding
-- a task in a workspace that has just been deleted (MUL-5999).
-- Re-points every queued/running/completed task referencing old_runtime_id.
-- Required before deleting the old runtime row because agent_task_queue has
-- an ON DELETE CASCADE FK that would otherwise drop historical tasks.
--
-- Returns the fence verdict separately from the row count on purpose. "0 rows"
-- is ambiguous — it means either "the old runtime had no tasks" or "the fence
-- refused" — and a caller that cannot tell them apart would go on to delete the
-- old runtime, letting that same ON DELETE CASCADE drop the very history this
-- statement exists to preserve. FenceOk = false must abort the merge.
WITH fence AS MATERIALIZED (
-- Once per statement rather than once per row: the predicate is VOLATILE, so
-- calling it from the WHERE clause of a bulk UPDATE would re-run it for every
-- candidate row.
SELECT lock_task_owner_rows(NULL, NULL, @new_runtime_id) AS ok
),
reassigned AS (
UPDATE agent_task_queue
SET runtime_id = @new_runtime_id
WHERE runtime_id = @old_runtime_id
AND (SELECT ok FROM fence)
RETURNING id
)
SELECT
(SELECT ok FROM fence) AS fence_ok,
(SELECT count(*) FROM reassigned) AS reassigned_tasks;
-- name: RecordRuntimeLegacyDaemonID :exec
-- Remembers the most recent hostname-derived daemon_id that was merged into
-- this row. Useful for debugging when tracing back why a given runtime row
-- subsumed an old one, and only overwrites NULL so the earliest merge is
-- preserved.
UPDATE agent_runtime
SET legacy_daemon_id = COALESCE(legacy_daemon_id, $2)
WHERE id = $1;
-- name: ListStaleOfflineRuntimeGCCandidates :many
-- Bounded gather for runtime GC. Non-terminal task owners are deliberately
-- excluded here so one permanently-deferred task cannot monopolise the front
-- of every batch and starve otherwise-drainable runtimes. The per-runtime
-- transaction re-checks every predicate after taking FOR UPDATE, so this is an
-- efficiency filter rather than the correctness boundary.
SELECT id FROM agent_runtime
WHERE status = 'offline'
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision)
AND NOT EXISTS (
SELECT 1
FROM agent
WHERE agent.runtime_id = agent_runtime.id
AND agent.kind = 'user'
AND agent.archived_at IS NULL
)
AND NOT EXISTS (
SELECT 1
FROM agent_task_queue
WHERE agent_task_queue.runtime_id = agent_runtime.id
AND agent_task_queue.completed_at IS NULL
)
ORDER BY last_seen_at ASC, id ASC
LIMIT @max_per_tick::int;
-- name: IsAgentRuntimeEligibleForGC :one
-- Re-checks the mutable GC predicates after the caller has locked the runtime
-- row FOR UPDATE. Agent inserts/updates and task ownership writes take FOR KEY
-- SHARE on that row, so no new dependency can commit between this check and
-- DeleteAgentRuntime in the same transaction.
SELECT EXISTS (
SELECT 1 FROM agent_runtime
WHERE agent_runtime.id = @id
AND status = 'offline'
AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision)
AND NOT EXISTS (
SELECT 1
FROM agent
WHERE agent.runtime_id = agent_runtime.id
AND agent.kind = 'user'
AND agent.archived_at IS NULL
)
) AS eligible;
-- name: CountTasksByRuntime :one
-- Final fail-closed assertion after UnbindTasksFromRuntime. A non-zero result
-- aborts the transaction instead of relying on the legacy ON DELETE CASCADE.
SELECT count(*) FROM agent_task_queue WHERE runtime_id = $1;
-- name: ListRuntimeOwnersByDaemon :many
-- Registration pre-check (MUL-8154): every runtime row a daemon_id already
-- holds in a workspace, with its owner. A user-credential registration is
-- refused before any write when any of them belongs to someone else.
SELECT id, owner_id FROM agent_runtime
WHERE workspace_id = @workspace_id AND daemon_id = @daemon_id;
-- name: LockRuntimeShareMembers :many
-- Keep grant validation and member removal serialized until commit. NOWAIT
-- avoids the member-removal path's reverse member/runtime lock order.
SELECT user_id FROM member
WHERE workspace_id = @workspace_id AND user_id = ANY(@user_ids::uuid[])
ORDER BY user_id
FOR KEY SHARE NOWAIT;
-- name: LockRuntimeShareMemberRemoval :one
-- Prevent new grants until membership deletion commits. Grant writers use
-- NOWAIT on this row, so they never wait while holding a runtime we need.
SELECT id FROM member
WHERE workspace_id = @workspace_id AND user_id = @user_id
FOR UPDATE;
-- name: ListSharedAgentRuntimesForUpdate :many
SELECT * FROM agent_runtime
WHERE workspace_id = @workspace_id AND @user_id::uuid = ANY(shared_with)
ORDER BY id
FOR NO KEY UPDATE;